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.
- ml_mcp/__init__.py +3 -0
- ml_mcp/application/__init__.py +1 -0
- ml_mcp/application/analysis/__init__.py +15 -0
- ml_mcp/application/analysis/diagnostics.py +122 -0
- ml_mcp/application/analysis/service.py +155 -0
- ml_mcp/application/artifacts/__init__.py +5 -0
- ml_mcp/application/artifacts/service.py +149 -0
- ml_mcp/application/datasets/__init__.py +6 -0
- ml_mcp/application/datasets/service.py +178 -0
- ml_mcp/application/datasets/validator.py +156 -0
- ml_mcp/application/experiments/__init__.py +5 -0
- ml_mcp/application/experiments/service.py +254 -0
- ml_mcp/application/models/__init__.py +6 -0
- ml_mcp/application/models/catalog_seed.py +131 -0
- ml_mcp/application/models/service.py +177 -0
- ml_mcp/application/projects/__init__.py +5 -0
- ml_mcp/application/projects/service.py +93 -0
- ml_mcp/config/__init__.py +5 -0
- ml_mcp/config/settings.py +176 -0
- ml_mcp/domain/__init__.py +1 -0
- ml_mcp/domain/errors/__init__.py +39 -0
- ml_mcp/domain/errors/base.py +155 -0
- ml_mcp/domain/errors/codes.py +23 -0
- ml_mcp/domain/policies/__init__.py +16 -0
- ml_mcp/domain/policies/rbac.py +126 -0
- ml_mcp/domain/policies/tenant_isolation.py +34 -0
- ml_mcp/domain/policies/tool_policy.py +185 -0
- ml_mcp/domain/value_objects/__init__.py +41 -0
- ml_mcp/domain/value_objects/dataset.py +60 -0
- ml_mcp/domain/value_objects/experiment.py +131 -0
- ml_mcp/domain/value_objects/model.py +48 -0
- ml_mcp/infrastructure/__init__.py +1 -0
- ml_mcp/infrastructure/object_storage/__init__.py +5 -0
- ml_mcp/infrastructure/object_storage/s3.py +272 -0
- ml_mcp/infrastructure/postgres/__init__.py +14 -0
- ml_mcp/infrastructure/postgres/base.py +40 -0
- ml_mcp/infrastructure/postgres/models.py +356 -0
- ml_mcp/infrastructure/postgres/repositories/__init__.py +21 -0
- ml_mcp/infrastructure/postgres/repositories/artifacts.py +34 -0
- ml_mcp/infrastructure/postgres/repositories/audit.py +34 -0
- ml_mcp/infrastructure/postgres/repositories/datasets.py +85 -0
- ml_mcp/infrastructure/postgres/repositories/experiments.py +134 -0
- ml_mcp/infrastructure/postgres/repositories/metrics.py +61 -0
- ml_mcp/infrastructure/postgres/repositories/models.py +61 -0
- ml_mcp/infrastructure/postgres/repositories/outbox.py +47 -0
- ml_mcp/infrastructure/postgres/repositories/projects.py +83 -0
- ml_mcp/infrastructure/postgres/session.py +117 -0
- ml_mcp/infrastructure/queue/__init__.py +6 -0
- ml_mcp/infrastructure/queue/outbox_processor.py +67 -0
- ml_mcp/infrastructure/queue/task_queue.py +98 -0
- ml_mcp/infrastructure/redis/__init__.py +11 -0
- ml_mcp/infrastructure/redis/client.py +64 -0
- ml_mcp/infrastructure/redis/rate_limiter.py +129 -0
- ml_mcp/infrastructure/telemetry/__init__.py +23 -0
- ml_mcp/infrastructure/telemetry/logging.py +155 -0
- ml_mcp/infrastructure/telemetry/otel.py +141 -0
- ml_mcp/mcp/__init__.py +1 -0
- ml_mcp/mcp/prompts.py +38 -0
- ml_mcp/mcp/resources.py +35 -0
- ml_mcp/mcp/schemas/__init__.py +1 -0
- ml_mcp/mcp/schemas/tools.py +124 -0
- ml_mcp/mcp/tools/__init__.py +63 -0
- ml_mcp/mcp/tools/analysis.py +37 -0
- ml_mcp/mcp/tools/datasets.py +82 -0
- ml_mcp/mcp/tools/discovery.py +82 -0
- ml_mcp/mcp/tools/experiments.py +96 -0
- ml_mcp/mcp/tools/projects.py +39 -0
- ml_mcp/mcp/tools/results.py +59 -0
- ml_mcp/server/__init__.py +1 -0
- ml_mcp/server/app.py +488 -0
- ml_mcp/server/auth.py +146 -0
- ml_mcp/server/context.py +59 -0
- ml_mcp/server/http.py +183 -0
- ml_mcp/server/middleware.py +121 -0
- ml_mcp/server/stdio.py +42 -0
- ml_mcp/workers/__init__.py +1 -0
- ml_mcp/workers/daemon.py +130 -0
- ml_mcp/workers/metrics.py +110 -0
- ml_mcp/workers/preprocessing.py +170 -0
- ml_mcp/workers/runner.py +250 -0
- ml_mcp/workers/sandbox.py +35 -0
- ml_mcp/workers/trainers/__init__.py +52 -0
- ml_mcp/workers/trainers/base.py +52 -0
- ml_mcp/workers/trainers/catboost_model.py +85 -0
- ml_mcp/workers/trainers/lightgbm_model.py +81 -0
- ml_mcp/workers/trainers/sklearn_models.py +266 -0
- ml_mcp/workers/trainers/xgboost_model.py +82 -0
- quicklab_mcp-0.1.0.dist-info/METADATA +540 -0
- quicklab_mcp-0.1.0.dist-info/RECORD +92 -0
- quicklab_mcp-0.1.0.dist-info/WHEEL +5 -0
- quicklab_mcp-0.1.0.dist-info/entry_points.txt +4 -0
- 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,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,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
|
+
]
|