QuickLab-MCP 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. ml_mcp/__init__.py +3 -0
  2. ml_mcp/application/__init__.py +1 -0
  3. ml_mcp/application/analysis/__init__.py +15 -0
  4. ml_mcp/application/analysis/diagnostics.py +122 -0
  5. ml_mcp/application/analysis/service.py +155 -0
  6. ml_mcp/application/artifacts/__init__.py +5 -0
  7. ml_mcp/application/artifacts/service.py +149 -0
  8. ml_mcp/application/datasets/__init__.py +6 -0
  9. ml_mcp/application/datasets/service.py +178 -0
  10. ml_mcp/application/datasets/validator.py +156 -0
  11. ml_mcp/application/experiments/__init__.py +5 -0
  12. ml_mcp/application/experiments/service.py +254 -0
  13. ml_mcp/application/models/__init__.py +6 -0
  14. ml_mcp/application/models/catalog_seed.py +131 -0
  15. ml_mcp/application/models/service.py +177 -0
  16. ml_mcp/application/projects/__init__.py +5 -0
  17. ml_mcp/application/projects/service.py +93 -0
  18. ml_mcp/config/__init__.py +5 -0
  19. ml_mcp/config/settings.py +176 -0
  20. ml_mcp/domain/__init__.py +1 -0
  21. ml_mcp/domain/errors/__init__.py +39 -0
  22. ml_mcp/domain/errors/base.py +155 -0
  23. ml_mcp/domain/errors/codes.py +23 -0
  24. ml_mcp/domain/policies/__init__.py +16 -0
  25. ml_mcp/domain/policies/rbac.py +126 -0
  26. ml_mcp/domain/policies/tenant_isolation.py +34 -0
  27. ml_mcp/domain/policies/tool_policy.py +185 -0
  28. ml_mcp/domain/value_objects/__init__.py +41 -0
  29. ml_mcp/domain/value_objects/dataset.py +60 -0
  30. ml_mcp/domain/value_objects/experiment.py +131 -0
  31. ml_mcp/domain/value_objects/model.py +48 -0
  32. ml_mcp/infrastructure/__init__.py +1 -0
  33. ml_mcp/infrastructure/object_storage/__init__.py +5 -0
  34. ml_mcp/infrastructure/object_storage/s3.py +272 -0
  35. ml_mcp/infrastructure/postgres/__init__.py +14 -0
  36. ml_mcp/infrastructure/postgres/base.py +40 -0
  37. ml_mcp/infrastructure/postgres/models.py +356 -0
  38. ml_mcp/infrastructure/postgres/repositories/__init__.py +21 -0
  39. ml_mcp/infrastructure/postgres/repositories/artifacts.py +34 -0
  40. ml_mcp/infrastructure/postgres/repositories/audit.py +34 -0
  41. ml_mcp/infrastructure/postgres/repositories/datasets.py +85 -0
  42. ml_mcp/infrastructure/postgres/repositories/experiments.py +134 -0
  43. ml_mcp/infrastructure/postgres/repositories/metrics.py +61 -0
  44. ml_mcp/infrastructure/postgres/repositories/models.py +61 -0
  45. ml_mcp/infrastructure/postgres/repositories/outbox.py +47 -0
  46. ml_mcp/infrastructure/postgres/repositories/projects.py +83 -0
  47. ml_mcp/infrastructure/postgres/session.py +117 -0
  48. ml_mcp/infrastructure/queue/__init__.py +6 -0
  49. ml_mcp/infrastructure/queue/outbox_processor.py +67 -0
  50. ml_mcp/infrastructure/queue/task_queue.py +98 -0
  51. ml_mcp/infrastructure/redis/__init__.py +11 -0
  52. ml_mcp/infrastructure/redis/client.py +64 -0
  53. ml_mcp/infrastructure/redis/rate_limiter.py +129 -0
  54. ml_mcp/infrastructure/telemetry/__init__.py +23 -0
  55. ml_mcp/infrastructure/telemetry/logging.py +155 -0
  56. ml_mcp/infrastructure/telemetry/otel.py +141 -0
  57. ml_mcp/mcp/__init__.py +1 -0
  58. ml_mcp/mcp/prompts.py +38 -0
  59. ml_mcp/mcp/resources.py +35 -0
  60. ml_mcp/mcp/schemas/__init__.py +1 -0
  61. ml_mcp/mcp/schemas/tools.py +124 -0
  62. ml_mcp/mcp/tools/__init__.py +63 -0
  63. ml_mcp/mcp/tools/analysis.py +37 -0
  64. ml_mcp/mcp/tools/datasets.py +82 -0
  65. ml_mcp/mcp/tools/discovery.py +82 -0
  66. ml_mcp/mcp/tools/experiments.py +96 -0
  67. ml_mcp/mcp/tools/projects.py +39 -0
  68. ml_mcp/mcp/tools/results.py +59 -0
  69. ml_mcp/server/__init__.py +1 -0
  70. ml_mcp/server/app.py +488 -0
  71. ml_mcp/server/auth.py +146 -0
  72. ml_mcp/server/context.py +59 -0
  73. ml_mcp/server/http.py +183 -0
  74. ml_mcp/server/middleware.py +121 -0
  75. ml_mcp/server/stdio.py +42 -0
  76. ml_mcp/workers/__init__.py +1 -0
  77. ml_mcp/workers/daemon.py +130 -0
  78. ml_mcp/workers/metrics.py +110 -0
  79. ml_mcp/workers/preprocessing.py +170 -0
  80. ml_mcp/workers/runner.py +250 -0
  81. ml_mcp/workers/sandbox.py +35 -0
  82. ml_mcp/workers/trainers/__init__.py +52 -0
  83. ml_mcp/workers/trainers/base.py +52 -0
  84. ml_mcp/workers/trainers/catboost_model.py +85 -0
  85. ml_mcp/workers/trainers/lightgbm_model.py +81 -0
  86. ml_mcp/workers/trainers/sklearn_models.py +266 -0
  87. ml_mcp/workers/trainers/xgboost_model.py +82 -0
  88. quicklab_mcp-0.1.0.dist-info/METADATA +540 -0
  89. quicklab_mcp-0.1.0.dist-info/RECORD +92 -0
  90. quicklab_mcp-0.1.0.dist-info/WHEEL +5 -0
  91. quicklab_mcp-0.1.0.dist-info/entry_points.txt +4 -0
  92. quicklab_mcp-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,156 @@
1
+ """Dataset validation, schema extraction, and canonicalization."""
2
+
3
+ import csv
4
+ import io
5
+ import warnings
6
+
7
+ import pandas as pd
8
+
9
+ from ml_mcp.config import Settings, get_settings
10
+ from ml_mcp.domain.errors import DatasetValidationError
11
+ from ml_mcp.domain.value_objects import (
12
+ ColumnSchema,
13
+ DatasetFormat,
14
+ DatasetSchema,
15
+ FeatureType,
16
+ )
17
+ from ml_mcp.infrastructure.object_storage.s3 import S3StorageService
18
+
19
+
20
+ class DatasetValidator:
21
+ """Performs strict validation and schema extraction on untrusted tabular datasets."""
22
+
23
+ def __init__(self, settings: Settings | None = None) -> None:
24
+ self.settings = settings or get_settings()
25
+
26
+ def _infer_feature_type(self, series: pd.Series) -> FeatureType:
27
+ """Map pandas/pyarrow series to standard FeatureType."""
28
+ if pd.api.types.is_bool_dtype(series):
29
+ return FeatureType.BOOLEAN
30
+ elif pd.api.types.is_numeric_dtype(series):
31
+ return FeatureType.NUMERIC
32
+ elif pd.api.types.is_datetime64_any_dtype(series):
33
+ return FeatureType.DATETIME
34
+ elif pd.api.types.is_categorical_dtype(series):
35
+ return FeatureType.CATEGORICAL
36
+ else:
37
+ # String / object column
38
+ non_null = series.dropna()
39
+ if len(non_null) > 0:
40
+ unique_ratio = len(non_null.unique()) / len(non_null)
41
+ # If unique ratio is low, treat as categorical; otherwise text
42
+ if unique_ratio < 0.2 or len(non_null.unique()) <= 50:
43
+ return FeatureType.CATEGORICAL
44
+ return FeatureType.TEXT
45
+
46
+ def parse_dataframe(self, raw_bytes: bytes, data_format: DatasetFormat | str) -> pd.DataFrame:
47
+ """Parse raw bytes into a pandas DataFrame, rejecting malformed inputs."""
48
+ # 1. Size constraint
49
+ if len(raw_bytes) > self.settings.security.max_dataset_upload_bytes:
50
+ raise DatasetValidationError(
51
+ f"Dataset size ({len(raw_bytes)} bytes) exceeds maximum permitted limit ({self.settings.security.max_dataset_upload_bytes} bytes)"
52
+ )
53
+ if len(raw_bytes) == 0:
54
+ raise DatasetValidationError("Dataset upload is empty")
55
+
56
+ fmt = str(data_format).lower()
57
+ try:
58
+ bio = io.BytesIO(raw_bytes)
59
+ if fmt == DatasetFormat.CSV.value:
60
+ # Deterministic check for consistent column counts across all rows
61
+ try:
62
+ text_content = raw_bytes.decode("utf-8")
63
+ except UnicodeDecodeError as exc:
64
+ raise DatasetValidationError(f"Malformed encoding: dataset must be valid UTF-8: {exc}") from exc
65
+
66
+ reader = csv.reader(io.StringIO(text_content))
67
+ try:
68
+ header = next(reader, None)
69
+ except Exception as exc:
70
+ raise DatasetValidationError(f"Failed to parse dataset as csv: {exc}") from exc
71
+
72
+ if header is None or len(header) == 0:
73
+ raise DatasetValidationError("Dataset upload is empty or has no header")
74
+
75
+ expected_cols = len(header)
76
+ for line_idx, row in enumerate(reader, start=2):
77
+ if not row:
78
+ continue
79
+ if len(row) != expected_cols:
80
+ raise DatasetValidationError(
81
+ f"Failed to parse dataset as csv: Row {line_idx} has {len(row)} columns, expected {expected_cols}"
82
+ )
83
+
84
+ with warnings.catch_warnings():
85
+ warnings.simplefilter("error", pd.errors.ParserWarning)
86
+ df = pd.read_csv(bio, index_col=False)
87
+ elif fmt == DatasetFormat.PARQUET.value:
88
+ df = pd.read_parquet(bio)
89
+ elif fmt == DatasetFormat.JSONL.value:
90
+ df = pd.read_json(bio, lines=True)
91
+ else:
92
+ raise DatasetValidationError(f"Unsupported dataset format '{data_format}'. Allowed: csv, parquet, jsonl")
93
+ except Exception as exc:
94
+ raise DatasetValidationError(f"Failed to parse dataset as {fmt}: {exc}") from exc
95
+
96
+ # 2. Structural constraints
97
+ if df.empty or len(df) == 0:
98
+ raise DatasetValidationError("Dataset contains 0 rows")
99
+
100
+ if len(df.columns) > self.settings.security.max_dataset_columns:
101
+ raise DatasetValidationError(
102
+ f"Dataset column count ({len(df.columns)}) exceeds maximum allowed ({self.settings.security.max_dataset_columns})"
103
+ )
104
+
105
+ if len(df) > self.settings.security.max_dataset_rows:
106
+ raise DatasetValidationError(
107
+ f"Dataset row count ({len(df)}) exceeds maximum allowed ({self.settings.security.max_dataset_rows})"
108
+ )
109
+
110
+ return df
111
+
112
+ def extract_schema(self, df: pd.DataFrame, canonical_bytes: bytes) -> DatasetSchema:
113
+ """Profile dataset columns and construct validated DatasetSchema."""
114
+ columns: list[ColumnSchema] = []
115
+ for col_name in df.columns:
116
+ series = df[col_name]
117
+ ft = self._infer_feature_type(series)
118
+ null_cnt = int(series.isna().sum())
119
+ unique_cnt = int(series.nunique(dropna=True))
120
+
121
+ # Grab up to 3 non-null sample values
122
+ samples = [str(x) for x in series.dropna().iloc[:3].tolist()]
123
+
124
+ columns.append(
125
+ ColumnSchema(
126
+ name=str(col_name),
127
+ feature_type=ft,
128
+ nullable=null_cnt > 0,
129
+ null_count=null_cnt,
130
+ unique_count=unique_cnt,
131
+ sample_values=samples,
132
+ )
133
+ )
134
+
135
+ content_hash = S3StorageService.compute_sha256(canonical_bytes)
136
+
137
+ return DatasetSchema(
138
+ columns=columns,
139
+ row_count=len(df),
140
+ column_count=len(df.columns),
141
+ size_bytes=len(canonical_bytes),
142
+ content_hash=f"sha256:{content_hash}",
143
+ )
144
+
145
+ def canonicalize_to_parquet(self, df: pd.DataFrame) -> bytes:
146
+ """Convert DataFrame to standardized, compressed Apache Parquet format."""
147
+ out_buf = io.BytesIO()
148
+ df.to_parquet(out_buf, index=False, engine="pyarrow", compression="snappy")
149
+ return out_buf.getvalue()
150
+
151
+ def process(self, raw_bytes: bytes, data_format: DatasetFormat | str) -> tuple[pd.DataFrame, bytes, DatasetSchema]:
152
+ """Validate, convert to canonical parquet, and extract complete schema."""
153
+ df = self.parse_dataframe(raw_bytes, data_format)
154
+ parquet_bytes = self.canonicalize_to_parquet(df)
155
+ schema = self.extract_schema(df, parquet_bytes)
156
+ return df, parquet_bytes, schema
@@ -0,0 +1,5 @@
1
+ """Experiments application module."""
2
+
3
+ from ml_mcp.application.experiments.service import ExperimentService
4
+
5
+ __all__ = ["ExperimentService"]
@@ -0,0 +1,254 @@
1
+ """Experiment application service managing lifecycle, validation, idempotency, and outbox publication."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from ml_mcp.application.models.service import ModelService
9
+ from ml_mcp.domain.errors import (
10
+ ExperimentNotCancellableError,
11
+ InvalidExperimentError,
12
+ ResourceNotFoundError,
13
+ )
14
+ from ml_mcp.domain.value_objects import (
15
+ EvaluationConfig,
16
+ ExperimentSpec,
17
+ ResourcePolicy,
18
+ TaskType,
19
+ )
20
+ from ml_mcp.infrastructure.postgres.base import generate_uuid7
21
+ from ml_mcp.infrastructure.postgres.models import ExperimentOrm, OutboxEventOrm
22
+ from ml_mcp.infrastructure.postgres.repositories.datasets import DatasetRepository
23
+ from ml_mcp.infrastructure.postgres.repositories.experiments import ExperimentRepository
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class ExperimentService:
29
+ """Encapsulates experiment creation, validation, querying, cancellation, and outbox emission."""
30
+
31
+ def __init__(self, session: AsyncSession) -> None:
32
+ self.session = session
33
+ self.repo = ExperimentRepository(session)
34
+ self.model_svc = ModelService(session)
35
+ self.dataset_repo = DatasetRepository(session)
36
+
37
+ async def create_experiment(
38
+ self,
39
+ tenant_id: str,
40
+ project_id: str,
41
+ dataset_version_id: str,
42
+ model_version_id: str,
43
+ task_type: str,
44
+ target_column: str,
45
+ principal_id: str,
46
+ feature_columns: list[str] | None = None,
47
+ primary_metric: str = "roc_auc",
48
+ additional_metrics: list[str] | None = None,
49
+ hyperparameters: dict[str, Any] | None = None,
50
+ random_seed: int = 42,
51
+ idempotency_key: str | None = None,
52
+ resource_policy: ResourcePolicy | None = None,
53
+ ) -> dict[str, Any]:
54
+ """Validate model approval, create experiment record and transactional outbox event."""
55
+ # 1. Enforce Model Approval and bounds validation
56
+ await self.model_svc.validate_model_for_experiment(
57
+ task_type=task_type,
58
+ hyperparameters=hyperparameters or {},
59
+ model_version_id=model_version_id,
60
+ )
61
+
62
+ # 2. Validate Dataset Version and Column Existence upfront
63
+ dsv = await self.dataset_repo.get_version_by_id(tenant_id, dataset_version_id)
64
+ if not dsv:
65
+ raise ResourceNotFoundError("DatasetVersion", dataset_version_id)
66
+
67
+ available_columns = [
68
+ col["name"]
69
+ for col in dsv.schema_json.get("columns", [])
70
+ if isinstance(col, dict) and "name" in col
71
+ ]
72
+ if available_columns:
73
+ if target_column not in available_columns:
74
+ raise InvalidExperimentError(
75
+ f"Target column '{target_column}' not found in dataset '{dsv.dataset_id}' version '{dsv.version}'. Available columns: {available_columns}"
76
+ )
77
+ if feature_columns is not None:
78
+ if len(feature_columns) == 0:
79
+ raise InvalidExperimentError("feature_columns cannot be empty when specified")
80
+ missing_features = [c for c in feature_columns if c not in available_columns]
81
+ if missing_features:
82
+ raise InvalidExperimentError(
83
+ f"Feature columns {missing_features} not found in dataset '{dsv.dataset_id}' version '{dsv.version}'. Available columns: {available_columns}"
84
+ )
85
+ if target_column in feature_columns:
86
+ raise InvalidExperimentError(
87
+ f"Target column '{target_column}' cannot be included in feature_columns"
88
+ )
89
+
90
+ # 3. Check Idempotency
91
+ if idempotency_key:
92
+ existing = await self.repo.get_by_idempotency_key(
93
+ tenant_id, project_id, idempotency_key
94
+ )
95
+ if existing:
96
+ return {
97
+ "experiment_id": existing.id,
98
+ "status": existing.status,
99
+ "idempotent_replay": True,
100
+ "created_at": existing.created_at.isoformat(),
101
+ }
102
+
103
+ # 3. Build ExperimentSpec
104
+ spec = ExperimentSpec(
105
+ tenant_id=tenant_id,
106
+ project_id=project_id,
107
+ dataset_version_id=dataset_version_id,
108
+ model_version_id=model_version_id,
109
+ task_type=TaskType(task_type),
110
+ target_column=target_column,
111
+ feature_columns=feature_columns,
112
+ evaluation_config=EvaluationConfig(
113
+ primary_metric=primary_metric,
114
+ additional_metrics=additional_metrics or [],
115
+ ),
116
+ hyperparameters=hyperparameters or {},
117
+ resource_policy=resource_policy or ResourcePolicy(),
118
+ random_seed=random_seed,
119
+ idempotency_key=idempotency_key,
120
+ created_by=principal_id,
121
+ )
122
+
123
+ # 4. Create ORM entities
124
+ experiment_id = generate_uuid7()
125
+ exp_orm = ExperimentOrm(
126
+ id=experiment_id,
127
+ tenant_id=tenant_id,
128
+ project_id=project_id,
129
+ dataset_version_id=dataset_version_id,
130
+ model_version_id=model_version_id,
131
+ spec_json=spec.model_dump(),
132
+ status="QUEUED",
133
+ idempotency_key=idempotency_key,
134
+ created_by=principal_id,
135
+ )
136
+
137
+ outbox_event = OutboxEventOrm(
138
+ id=generate_uuid7(),
139
+ event_type="EXPERIMENT_SUBMITTED",
140
+ aggregate_type="EXPERIMENT",
141
+ aggregate_id=experiment_id,
142
+ payload_json={
143
+ "experiment_id": experiment_id,
144
+ "tenant_id": tenant_id,
145
+ "project_id": project_id,
146
+ },
147
+ )
148
+
149
+ await self.repo.create_experiment(exp_orm, initial_outbox_event=outbox_event)
150
+
151
+ # 5. Direct enqueue to Redis for immediate execution
152
+ try:
153
+ from ml_mcp.infrastructure.queue.task_queue import RedisTaskQueue
154
+
155
+ queue = RedisTaskQueue()
156
+ await queue.enqueue(
157
+ experiment_id=experiment_id,
158
+ tenant_id=tenant_id,
159
+ payload={
160
+ "experiment_id": experiment_id,
161
+ "tenant_id": tenant_id,
162
+ "project_id": project_id,
163
+ },
164
+ )
165
+ except Exception as q_exc:
166
+ logger.debug("Direct Redis enqueue deferred to outbox processor: %s", q_exc)
167
+
168
+ return {
169
+ "experiment_id": experiment_id,
170
+ "status": "QUEUED",
171
+ "idempotent_replay": False,
172
+ "task_type": task_type,
173
+ "target_column": target_column,
174
+ "created_at": exp_orm.created_at.isoformat(),
175
+ }
176
+
177
+ async def get_experiment(self, tenant_id: str, experiment_id: str) -> dict[str, Any]:
178
+ """Fetch experiment by tenant-scoped ID with full run details."""
179
+ exp = await self.repo.get_experiment(tenant_id, experiment_id)
180
+ if not exp:
181
+ raise ResourceNotFoundError("Experiment", experiment_id)
182
+
183
+ sorted_runs = sorted(
184
+ exp.runs, key=lambda r: (r.run_number or 0, r.created_at), reverse=True
185
+ )
186
+ latest_run = sorted_runs[0] if sorted_runs else None
187
+
188
+ error_message = (
189
+ latest_run.failure_reason if latest_run and latest_run.failure_reason else None
190
+ )
191
+ error_type = None
192
+ if error_message:
193
+ if ":" in error_message:
194
+ error_type = error_message.split(":")[0].strip()
195
+ else:
196
+ error_type = "ExecutionError"
197
+
198
+ return {
199
+ "experiment_id": exp.id,
200
+ "project_id": exp.project_id,
201
+ "status": exp.status,
202
+ "spec": exp.spec_json,
203
+ "runs_count": len(exp.runs),
204
+ "latest_run_status": latest_run.status if latest_run else None,
205
+ "error_message": error_message,
206
+ "error_type": error_type,
207
+ "metrics_count": len(exp.metrics),
208
+ "artifacts_count": len(exp.artifacts),
209
+ "created_at": exp.created_at.isoformat(),
210
+ "updated_at": exp.updated_at.isoformat(),
211
+ }
212
+
213
+ async def cancel_experiment(self, tenant_id: str, experiment_id: str) -> dict[str, Any]:
214
+ """Cancel an in-flight or queued experiment."""
215
+ exp = await self.repo.get_experiment(tenant_id, experiment_id)
216
+ if not exp:
217
+ raise ResourceNotFoundError("Experiment", experiment_id)
218
+
219
+ if exp.status in ("SUCCEEDED", "FAILED", "CANCELLED"):
220
+ raise ExperimentNotCancellableError(experiment_id, exp.status)
221
+
222
+ await self.repo.update_status(tenant_id, experiment_id, "CANCELLED")
223
+ return {
224
+ "experiment_id": experiment_id,
225
+ "status": "CANCELLED",
226
+ "message": f"Experiment {experiment_id} successfully cancelled.",
227
+ }
228
+
229
+ async def list_experiments(
230
+ self,
231
+ tenant_id: str,
232
+ project_id: str | None = None,
233
+ status: str | None = None,
234
+ limit: int = 50,
235
+ offset: int = 0,
236
+ ) -> list[dict[str, Any]]:
237
+ """List experiments for tenant."""
238
+ experiments = await self.repo.list_experiments(
239
+ tenant_id=tenant_id,
240
+ project_id=project_id,
241
+ status=status,
242
+ limit=limit,
243
+ offset=offset,
244
+ )
245
+ return [
246
+ {
247
+ "experiment_id": e.id,
248
+ "project_id": e.project_id,
249
+ "status": e.status,
250
+ "primary_metric": e.spec_json.get("evaluation_config", {}).get("primary_metric"),
251
+ "created_at": e.created_at.isoformat(),
252
+ }
253
+ for e in experiments
254
+ ]
@@ -0,0 +1,6 @@
1
+ """Model catalog package."""
2
+
3
+ from ml_mcp.application.models.catalog_seed import APPROVED_MODELS
4
+ from ml_mcp.application.models.service import ModelService
5
+
6
+ __all__ = ["APPROVED_MODELS", "ModelService"]
@@ -0,0 +1,131 @@
1
+ """Catalog seed definitions for all 7 approved tabular machine learning models."""
2
+
3
+ import hashlib
4
+
5
+ from ml_mcp.domain.value_objects import (
6
+ ApprovalStatus,
7
+ HyperparameterConstraint,
8
+ ModelFamily,
9
+ ModelMetadata,
10
+ TaskType,
11
+ )
12
+
13
+
14
+ def _make_digest(model_id: str) -> str:
15
+ """Generate deterministic SHA-256 container image digest for model family."""
16
+ return f"sha256:{hashlib.sha256(f'modellab/trainer-{model_id}:1.0.0'.encode()).hexdigest()}"
17
+
18
+
19
+ APPROVED_MODELS: list[ModelMetadata] = [
20
+ # 1. Logistic Regression
21
+ ModelMetadata(
22
+ model_id="logistic_regression",
23
+ version="1.0.0",
24
+ name="Logistic Regression",
25
+ description="Regularized linear model for binary and multiclass classification",
26
+ family=ModelFamily.LOGISTIC_REGRESSION,
27
+ supported_task_types=[TaskType.BINARY_CLASSIFICATION, TaskType.MULTICLASS_CLASSIFICATION],
28
+ approval_status=ApprovalStatus.APPROVED,
29
+ container_image_digest=_make_digest("logistic_regression"),
30
+ supported_hyperparameters=[
31
+ HyperparameterConstraint(name="C", type="float", default=1.0, min_value=0.0001, max_value=10000.0, description="Inverse of regularization strength"),
32
+ HyperparameterConstraint(name="penalty", type="str", default="l2", allowed_values=["l1", "l2", "elasticnet", "none"], description="Norm used in penalization"),
33
+ HyperparameterConstraint(name="max_iter", type="int", default=200, min_value=10, max_value=5000, description="Maximum iterations for solver convergence"),
34
+ ],
35
+ ),
36
+ # 2. Random Forest
37
+ ModelMetadata(
38
+ model_id="random_forest",
39
+ version="1.0.0",
40
+ name="Random Forest",
41
+ description="Ensemble of decision trees trained with bagging and feature subsampling",
42
+ family=ModelFamily.RANDOM_FOREST,
43
+ supported_task_types=[TaskType.BINARY_CLASSIFICATION, TaskType.MULTICLASS_CLASSIFICATION, TaskType.REGRESSION],
44
+ approval_status=ApprovalStatus.APPROVED,
45
+ container_image_digest=_make_digest("random_forest"),
46
+ supported_hyperparameters=[
47
+ HyperparameterConstraint(name="n_estimators", type="int", default=100, min_value=10, max_value=1000, description="Number of trees in the forest"),
48
+ HyperparameterConstraint(name="max_depth", type="int", default=10, min_value=1, max_value=50, description="Maximum tree depth"),
49
+ HyperparameterConstraint(name="min_samples_split", type="int", default=2, min_value=2, max_value=20, description="Minimum samples to split an internal node"),
50
+ ],
51
+ ),
52
+ # 3. XGBoost
53
+ ModelMetadata(
54
+ model_id="xgboost",
55
+ version="1.0.0",
56
+ name="XGBoost",
57
+ description="Extreme Gradient Boosting decision trees optimized for speed and performance",
58
+ family=ModelFamily.XGBOOST,
59
+ supported_task_types=[TaskType.BINARY_CLASSIFICATION, TaskType.MULTICLASS_CLASSIFICATION, TaskType.REGRESSION],
60
+ approval_status=ApprovalStatus.APPROVED,
61
+ container_image_digest=_make_digest("xgboost"),
62
+ supported_hyperparameters=[
63
+ HyperparameterConstraint(name="n_estimators", type="int", default=100, min_value=10, max_value=2000, description="Number of boosting rounds"),
64
+ HyperparameterConstraint(name="max_depth", type="int", default=6, min_value=1, max_value=20, description="Maximum tree depth"),
65
+ HyperparameterConstraint(name="learning_rate", type="float", default=0.1, min_value=0.001, max_value=1.0, description="Step size shrinkage"),
66
+ HyperparameterConstraint(name="subsample", type="float", default=1.0, min_value=0.1, max_value=1.0, description="Subsample ratio of the training instances"),
67
+ ],
68
+ ),
69
+ # 4. LightGBM
70
+ ModelMetadata(
71
+ model_id="lightgbm",
72
+ version="1.0.0",
73
+ name="LightGBM",
74
+ description="Fast, distributed, high-performance gradient boosting based on decision tree algorithms",
75
+ family=ModelFamily.LIGHTGBM,
76
+ supported_task_types=[TaskType.BINARY_CLASSIFICATION, TaskType.MULTICLASS_CLASSIFICATION, TaskType.REGRESSION],
77
+ approval_status=ApprovalStatus.APPROVED,
78
+ container_image_digest=_make_digest("lightgbm"),
79
+ supported_hyperparameters=[
80
+ HyperparameterConstraint(name="n_estimators", type="int", default=100, min_value=10, max_value=2000, description="Number of boosting iterations"),
81
+ HyperparameterConstraint(name="num_leaves", type="int", default=31, min_value=2, max_value=256, description="Max tree leaves"),
82
+ HyperparameterConstraint(name="learning_rate", type="float", default=0.1, min_value=0.001, max_value=1.0, description="Boosting learning rate"),
83
+ ],
84
+ ),
85
+ # 5. CatBoost
86
+ ModelMetadata(
87
+ model_id="catboost",
88
+ version="1.0.0",
89
+ name="CatBoost",
90
+ description="Gradient boosting on decision trees with native categorical feature support",
91
+ family=ModelFamily.CATBOOST,
92
+ supported_task_types=[TaskType.BINARY_CLASSIFICATION, TaskType.MULTICLASS_CLASSIFICATION, TaskType.REGRESSION],
93
+ approval_status=ApprovalStatus.APPROVED,
94
+ container_image_digest=_make_digest("catboost"),
95
+ supported_hyperparameters=[
96
+ HyperparameterConstraint(name="iterations", type="int", default=100, min_value=10, max_value=2000, description="Number of boosting iterations"),
97
+ HyperparameterConstraint(name="depth", type="int", default=6, min_value=1, max_value=16, description="Depth of the trees"),
98
+ HyperparameterConstraint(name="learning_rate", type="float", default=0.1, min_value=0.001, max_value=1.0, description="Learning rate"),
99
+ ],
100
+ ),
101
+ # 6. Linear SVM
102
+ ModelMetadata(
103
+ model_id="linear_svm",
104
+ version="1.0.0",
105
+ name="Linear Support Vector Machine",
106
+ description="Linear support vector machine classifier and regressor. Note: LinearSVC uses decision boundaries without native probability estimates, so probability-dependent metrics (roc_auc, pr_auc, log_loss, brier_score) are omitted.",
107
+ family=ModelFamily.LINEAR_SVM,
108
+ supported_task_types=[TaskType.BINARY_CLASSIFICATION, TaskType.MULTICLASS_CLASSIFICATION, TaskType.REGRESSION],
109
+ approval_status=ApprovalStatus.APPROVED,
110
+ container_image_digest=_make_digest("linear_svm"),
111
+ supported_hyperparameters=[
112
+ HyperparameterConstraint(name="C", type="float", default=1.0, min_value=0.001, max_value=1000.0, description="Regularization parameter"),
113
+ HyperparameterConstraint(name="max_iter", type="int", default=1000, min_value=100, max_value=10000, description="Max iterations"),
114
+ ],
115
+ ),
116
+ # 7. MLP (Multi-Layer Perceptron)
117
+ ModelMetadata(
118
+ model_id="mlp",
119
+ version="1.0.0",
120
+ name="Multi-Layer Perceptron",
121
+ description="Feedforward artificial neural network with backpropagation training",
122
+ family=ModelFamily.MLP,
123
+ supported_task_types=[TaskType.BINARY_CLASSIFICATION, TaskType.MULTICLASS_CLASSIFICATION, TaskType.REGRESSION],
124
+ approval_status=ApprovalStatus.APPROVED,
125
+ container_image_digest=_make_digest("mlp"),
126
+ supported_hyperparameters=[
127
+ HyperparameterConstraint(name="alpha", type="float", default=0.0001, min_value=0.000001, max_value=1.0, description="L2 penalty regularization"),
128
+ HyperparameterConstraint(name="max_iter", type="int", default=200, min_value=50, max_value=2000, description="Maximum iterations"),
129
+ ],
130
+ ),
131
+ ]