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,16 @@
|
|
|
1
|
+
"""Domain policies package."""
|
|
2
|
+
|
|
3
|
+
from ml_mcp.domain.policies.rbac import ROLE_PERMISSIONS, Principal, Role, Scope
|
|
4
|
+
from ml_mcp.domain.policies.tenant_isolation import validate_tenant_access
|
|
5
|
+
from ml_mcp.domain.policies.tool_policy import TOOL_POLICIES, ToolClass, ToolPolicy
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Role",
|
|
9
|
+
"Scope",
|
|
10
|
+
"ROLE_PERMISSIONS",
|
|
11
|
+
"Principal",
|
|
12
|
+
"ToolClass",
|
|
13
|
+
"ToolPolicy",
|
|
14
|
+
"TOOL_POLICIES",
|
|
15
|
+
"validate_tenant_access",
|
|
16
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Role-Based Access Control (RBAC) and permission scope evaluation."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from ml_mcp.domain.errors import AuthorizationDeniedError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Role(StrEnum):
|
|
11
|
+
"""Supported principal roles in ModelLab."""
|
|
12
|
+
|
|
13
|
+
VIEWER = "viewer"
|
|
14
|
+
RESEARCHER = "researcher"
|
|
15
|
+
OPERATOR = "operator"
|
|
16
|
+
ADMIN = "admin"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Scope(StrEnum):
|
|
20
|
+
"""Granular permission scopes for ML control plane operations."""
|
|
21
|
+
|
|
22
|
+
PROJECTS_READ = "ml:projects:read"
|
|
23
|
+
PROJECTS_WRITE = "ml:projects:write"
|
|
24
|
+
MODELS_READ = "ml:models:read"
|
|
25
|
+
DATASETS_READ = "ml:datasets:read"
|
|
26
|
+
DATASETS_WRITE = "ml:datasets:write"
|
|
27
|
+
EXPERIMENTS_READ = "ml:experiments:read"
|
|
28
|
+
EXPERIMENTS_CREATE = "ml:experiments:create"
|
|
29
|
+
EXPERIMENTS_CANCEL = "ml:experiments:cancel"
|
|
30
|
+
ANALYSIS_CREATE = "ml:analysis:create"
|
|
31
|
+
ARTIFACTS_READ = "ml:artifacts:read"
|
|
32
|
+
WORKER_OPERATE = "ml:worker:operate"
|
|
33
|
+
EXPERIMENT_OVERRIDE = "ml:experiment:override"
|
|
34
|
+
ADMIN = "ml:admin"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Standard role permissions matrix per Architecture.md section 7
|
|
38
|
+
ROLE_PERMISSIONS: dict[Role, set[Scope]] = {
|
|
39
|
+
Role.VIEWER: {
|
|
40
|
+
Scope.PROJECTS_READ,
|
|
41
|
+
Scope.MODELS_READ,
|
|
42
|
+
Scope.DATASETS_READ,
|
|
43
|
+
Scope.EXPERIMENTS_READ,
|
|
44
|
+
Scope.ARTIFACTS_READ,
|
|
45
|
+
},
|
|
46
|
+
Role.RESEARCHER: {
|
|
47
|
+
Scope.PROJECTS_READ,
|
|
48
|
+
Scope.PROJECTS_WRITE,
|
|
49
|
+
Scope.MODELS_READ,
|
|
50
|
+
Scope.DATASETS_READ,
|
|
51
|
+
Scope.DATASETS_WRITE,
|
|
52
|
+
Scope.EXPERIMENTS_READ,
|
|
53
|
+
Scope.EXPERIMENTS_CREATE,
|
|
54
|
+
Scope.EXPERIMENTS_CANCEL,
|
|
55
|
+
Scope.ANALYSIS_CREATE,
|
|
56
|
+
Scope.ARTIFACTS_READ,
|
|
57
|
+
},
|
|
58
|
+
Role.OPERATOR: {
|
|
59
|
+
Scope.PROJECTS_READ,
|
|
60
|
+
Scope.PROJECTS_WRITE,
|
|
61
|
+
Scope.MODELS_READ,
|
|
62
|
+
Scope.DATASETS_READ,
|
|
63
|
+
Scope.DATASETS_WRITE,
|
|
64
|
+
Scope.EXPERIMENTS_READ,
|
|
65
|
+
Scope.EXPERIMENTS_CREATE,
|
|
66
|
+
Scope.EXPERIMENTS_CANCEL,
|
|
67
|
+
Scope.ANALYSIS_CREATE,
|
|
68
|
+
Scope.ARTIFACTS_READ,
|
|
69
|
+
Scope.WORKER_OPERATE,
|
|
70
|
+
Scope.EXPERIMENT_OVERRIDE,
|
|
71
|
+
},
|
|
72
|
+
Role.ADMIN: set(Scope),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Principal(BaseModel):
|
|
77
|
+
"""Authenticated caller identity."""
|
|
78
|
+
|
|
79
|
+
principal_id: str = Field(..., min_length=1)
|
|
80
|
+
tenant_id: str = Field(..., min_length=1)
|
|
81
|
+
project_ids: list[str] = Field(
|
|
82
|
+
default_factory=list,
|
|
83
|
+
description="List of authorized project IDs, empty allows all tenant projects",
|
|
84
|
+
)
|
|
85
|
+
role: Role = Role.VIEWER
|
|
86
|
+
scopes: set[str] = Field(
|
|
87
|
+
default_factory=set, description="Explicit scopes granted via token claims"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def has_permission(self, required_scope: Scope) -> bool:
|
|
91
|
+
"""Evaluate if the principal holds the required permission via role and scope intersection.
|
|
92
|
+
|
|
93
|
+
Enforces strict OAuth 2.1 scope attenuation:
|
|
94
|
+
1. The requested scope MUST be allowed by the principal's role.
|
|
95
|
+
2. If explicit token scopes are present, the requested scope MUST also be granted
|
|
96
|
+
by the token (or covered by a wildcard '*').
|
|
97
|
+
"""
|
|
98
|
+
role_allowed = ROLE_PERMISSIONS.get(self.role, set())
|
|
99
|
+
if required_scope not in role_allowed:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
# If no explicit token scopes are set, the principal has full role permissions
|
|
103
|
+
if not self.scopes:
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
# If explicit scopes are set, evaluate scope intersection
|
|
107
|
+
scope_vals = {s.value if isinstance(s, Scope) else s for s in self.scopes}
|
|
108
|
+
req_val = required_scope.value
|
|
109
|
+
req_short = req_val.removeprefix("ml:")
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
"*" in self.scopes
|
|
113
|
+
or "admin" in scope_vals
|
|
114
|
+
or Scope.ADMIN.value in scope_vals
|
|
115
|
+
or Scope.ADMIN in self.scopes
|
|
116
|
+
or req_val in scope_vals
|
|
117
|
+
or req_short in scope_vals
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def enforce_permission(self, required_scope: Scope) -> None:
|
|
121
|
+
"""Enforce permission, raising AuthorizationDeniedError if missing."""
|
|
122
|
+
if not self.has_permission(required_scope):
|
|
123
|
+
raise AuthorizationDeniedError(
|
|
124
|
+
f"Principal '{self.principal_id}' lacks required permission scope '{required_scope.value}'",
|
|
125
|
+
details={"required_scope": required_scope.value, "role": self.role.value},
|
|
126
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Tenant isolation and project boundary enforcement."""
|
|
2
|
+
|
|
3
|
+
from ml_mcp.domain.errors import AuthorizationDeniedError, ResourceNotFoundError
|
|
4
|
+
from ml_mcp.domain.policies.rbac import Principal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def validate_tenant_access(
|
|
8
|
+
principal: Principal,
|
|
9
|
+
resource_tenant_id: str,
|
|
10
|
+
resource_project_id: str | None = None,
|
|
11
|
+
) -> None:
|
|
12
|
+
"""Validate that the authenticated principal is authorized to access resources belonging to a given tenant and project.
|
|
13
|
+
|
|
14
|
+
Raises:
|
|
15
|
+
ResourceNotFoundError: if cross-tenant access is detected (anti-enumeration: 404 with 0 foreign tenant leaks).
|
|
16
|
+
AuthorizationDeniedError: if unauthorized cross-project access within the same tenant is detected.
|
|
17
|
+
"""
|
|
18
|
+
# Tenant boundary enforcement: cross-tenant access returns 404 with zero foreign tenant leakage
|
|
19
|
+
if principal.tenant_id != resource_tenant_id:
|
|
20
|
+
raise ResourceNotFoundError("Resource", resource_project_id or "requested")
|
|
21
|
+
|
|
22
|
+
# Project boundary enforcement: if principal has restricted project_ids, check membership
|
|
23
|
+
if (
|
|
24
|
+
resource_project_id is not None
|
|
25
|
+
and principal.project_ids
|
|
26
|
+
and resource_project_id not in principal.project_ids
|
|
27
|
+
):
|
|
28
|
+
raise AuthorizationDeniedError(
|
|
29
|
+
f"Principal '{principal.principal_id}' does not have access to project '{resource_project_id}'",
|
|
30
|
+
details={
|
|
31
|
+
"allowed_projects": principal.project_ids,
|
|
32
|
+
"requested_project": resource_project_id,
|
|
33
|
+
},
|
|
34
|
+
)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Tool classification and security requirements."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from ml_mcp.domain.policies.rbac import Scope
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolClass(StrEnum):
|
|
11
|
+
"""Policy classification for rate limiting, auditing, and access control."""
|
|
12
|
+
|
|
13
|
+
READ = "READ"
|
|
14
|
+
MUTATION = "MUTATION"
|
|
15
|
+
EXPERIMENT = "EXPERIMENT"
|
|
16
|
+
CONTROL = "CONTROL"
|
|
17
|
+
ANALYSIS = "ANALYSIS"
|
|
18
|
+
ARTIFACT = "ARTIFACT"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ToolPolicy(BaseModel):
|
|
22
|
+
"""Security and rate-limiting policy governing an MCP tool."""
|
|
23
|
+
|
|
24
|
+
tool_name: str
|
|
25
|
+
tool_class: ToolClass
|
|
26
|
+
required_scope: Scope
|
|
27
|
+
requires_auth: bool = True
|
|
28
|
+
requires_audit: bool = True
|
|
29
|
+
rate_limit_category: str # "read", "metadata", "experiment", "analysis"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
TOOL_POLICIES: dict[str, ToolPolicy] = {
|
|
33
|
+
# Discovery tools
|
|
34
|
+
"list_models": ToolPolicy(
|
|
35
|
+
tool_name="list_models",
|
|
36
|
+
tool_class=ToolClass.READ,
|
|
37
|
+
required_scope=Scope.MODELS_READ,
|
|
38
|
+
requires_audit=False,
|
|
39
|
+
rate_limit_category="read",
|
|
40
|
+
),
|
|
41
|
+
"get_model": ToolPolicy(
|
|
42
|
+
tool_name="get_model",
|
|
43
|
+
tool_class=ToolClass.READ,
|
|
44
|
+
required_scope=Scope.MODELS_READ,
|
|
45
|
+
requires_audit=False,
|
|
46
|
+
rate_limit_category="read",
|
|
47
|
+
),
|
|
48
|
+
"list_model_versions": ToolPolicy(
|
|
49
|
+
tool_name="list_model_versions",
|
|
50
|
+
tool_class=ToolClass.READ,
|
|
51
|
+
required_scope=Scope.MODELS_READ,
|
|
52
|
+
requires_audit=False,
|
|
53
|
+
rate_limit_category="read",
|
|
54
|
+
),
|
|
55
|
+
"list_datasets": ToolPolicy(
|
|
56
|
+
tool_name="list_datasets",
|
|
57
|
+
tool_class=ToolClass.READ,
|
|
58
|
+
required_scope=Scope.DATASETS_READ,
|
|
59
|
+
requires_audit=False,
|
|
60
|
+
rate_limit_category="read",
|
|
61
|
+
),
|
|
62
|
+
"get_dataset": ToolPolicy(
|
|
63
|
+
tool_name="get_dataset",
|
|
64
|
+
tool_class=ToolClass.READ,
|
|
65
|
+
required_scope=Scope.DATASETS_READ,
|
|
66
|
+
requires_audit=False,
|
|
67
|
+
rate_limit_category="read",
|
|
68
|
+
),
|
|
69
|
+
"list_dataset_versions": ToolPolicy(
|
|
70
|
+
tool_name="list_dataset_versions",
|
|
71
|
+
tool_class=ToolClass.READ,
|
|
72
|
+
required_scope=Scope.DATASETS_READ,
|
|
73
|
+
requires_audit=False,
|
|
74
|
+
rate_limit_category="read",
|
|
75
|
+
),
|
|
76
|
+
# Dataset management tools
|
|
77
|
+
"register_dataset": ToolPolicy(
|
|
78
|
+
tool_name="register_dataset",
|
|
79
|
+
tool_class=ToolClass.MUTATION,
|
|
80
|
+
required_scope=Scope.DATASETS_WRITE,
|
|
81
|
+
requires_audit=True,
|
|
82
|
+
rate_limit_category="experiment",
|
|
83
|
+
),
|
|
84
|
+
"validate_dataset": ToolPolicy(
|
|
85
|
+
tool_name="validate_dataset",
|
|
86
|
+
tool_class=ToolClass.READ,
|
|
87
|
+
required_scope=Scope.DATASETS_READ,
|
|
88
|
+
requires_audit=True,
|
|
89
|
+
rate_limit_category="metadata",
|
|
90
|
+
),
|
|
91
|
+
"inspect_dataset": ToolPolicy(
|
|
92
|
+
tool_name="inspect_dataset",
|
|
93
|
+
tool_class=ToolClass.READ,
|
|
94
|
+
required_scope=Scope.DATASETS_READ,
|
|
95
|
+
requires_audit=True,
|
|
96
|
+
rate_limit_category="metadata",
|
|
97
|
+
),
|
|
98
|
+
# Experiment tools
|
|
99
|
+
"create_experiment": ToolPolicy(
|
|
100
|
+
tool_name="create_experiment",
|
|
101
|
+
tool_class=ToolClass.EXPERIMENT,
|
|
102
|
+
required_scope=Scope.EXPERIMENTS_CREATE,
|
|
103
|
+
requires_audit=True,
|
|
104
|
+
rate_limit_category="experiment",
|
|
105
|
+
),
|
|
106
|
+
"get_experiment": ToolPolicy(
|
|
107
|
+
tool_name="get_experiment",
|
|
108
|
+
tool_class=ToolClass.READ,
|
|
109
|
+
required_scope=Scope.EXPERIMENTS_READ,
|
|
110
|
+
requires_audit=False,
|
|
111
|
+
rate_limit_category="read",
|
|
112
|
+
),
|
|
113
|
+
"cancel_experiment": ToolPolicy(
|
|
114
|
+
tool_name="cancel_experiment",
|
|
115
|
+
tool_class=ToolClass.CONTROL,
|
|
116
|
+
required_scope=Scope.EXPERIMENTS_CANCEL,
|
|
117
|
+
requires_audit=True,
|
|
118
|
+
rate_limit_category="experiment",
|
|
119
|
+
),
|
|
120
|
+
"list_experiments": ToolPolicy(
|
|
121
|
+
tool_name="list_experiments",
|
|
122
|
+
tool_class=ToolClass.READ,
|
|
123
|
+
required_scope=Scope.EXPERIMENTS_READ,
|
|
124
|
+
requires_audit=False,
|
|
125
|
+
rate_limit_category="read",
|
|
126
|
+
),
|
|
127
|
+
# Result tools
|
|
128
|
+
"get_experiment_metrics": ToolPolicy(
|
|
129
|
+
tool_name="get_experiment_metrics",
|
|
130
|
+
tool_class=ToolClass.READ,
|
|
131
|
+
required_scope=Scope.EXPERIMENTS_READ,
|
|
132
|
+
requires_audit=False,
|
|
133
|
+
rate_limit_category="read",
|
|
134
|
+
),
|
|
135
|
+
"get_experiment_predictions": ToolPolicy(
|
|
136
|
+
tool_name="get_experiment_predictions",
|
|
137
|
+
tool_class=ToolClass.ARTIFACT,
|
|
138
|
+
required_scope=Scope.ARTIFACTS_READ,
|
|
139
|
+
requires_audit=True,
|
|
140
|
+
rate_limit_category="metadata",
|
|
141
|
+
),
|
|
142
|
+
"list_experiment_artifacts": ToolPolicy(
|
|
143
|
+
tool_name="list_experiment_artifacts",
|
|
144
|
+
tool_class=ToolClass.READ,
|
|
145
|
+
required_scope=Scope.ARTIFACTS_READ,
|
|
146
|
+
requires_audit=False,
|
|
147
|
+
rate_limit_category="read",
|
|
148
|
+
),
|
|
149
|
+
"read_experiment_artifact": ToolPolicy(
|
|
150
|
+
tool_name="read_experiment_artifact",
|
|
151
|
+
tool_class=ToolClass.ARTIFACT,
|
|
152
|
+
required_scope=Scope.ARTIFACTS_READ,
|
|
153
|
+
requires_audit=True,
|
|
154
|
+
rate_limit_category="metadata",
|
|
155
|
+
),
|
|
156
|
+
"compare_experiments": ToolPolicy(
|
|
157
|
+
tool_name="compare_experiments",
|
|
158
|
+
tool_class=ToolClass.READ,
|
|
159
|
+
required_scope=Scope.EXPERIMENTS_READ,
|
|
160
|
+
requires_audit=False,
|
|
161
|
+
rate_limit_category="read",
|
|
162
|
+
),
|
|
163
|
+
# Analysis tools
|
|
164
|
+
"analyze_experiment": ToolPolicy(
|
|
165
|
+
tool_name="analyze_experiment",
|
|
166
|
+
tool_class=ToolClass.ANALYSIS,
|
|
167
|
+
required_scope=Scope.ANALYSIS_CREATE,
|
|
168
|
+
requires_audit=True,
|
|
169
|
+
rate_limit_category="analysis",
|
|
170
|
+
),
|
|
171
|
+
"analyze_model_errors": ToolPolicy(
|
|
172
|
+
tool_name="analyze_model_errors",
|
|
173
|
+
tool_class=ToolClass.ANALYSIS,
|
|
174
|
+
required_scope=Scope.ANALYSIS_CREATE,
|
|
175
|
+
requires_audit=True,
|
|
176
|
+
rate_limit_category="analysis",
|
|
177
|
+
),
|
|
178
|
+
"check_experiment_validity": ToolPolicy(
|
|
179
|
+
tool_name="check_experiment_validity",
|
|
180
|
+
tool_class=ToolClass.ANALYSIS,
|
|
181
|
+
required_scope=Scope.ANALYSIS_CREATE,
|
|
182
|
+
requires_audit=True,
|
|
183
|
+
rate_limit_category="analysis",
|
|
184
|
+
),
|
|
185
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Domain value objects package."""
|
|
2
|
+
|
|
3
|
+
from ml_mcp.domain.value_objects.dataset import (
|
|
4
|
+
ColumnSchema,
|
|
5
|
+
DatasetFormat,
|
|
6
|
+
DatasetSchema,
|
|
7
|
+
FeatureType,
|
|
8
|
+
)
|
|
9
|
+
from ml_mcp.domain.value_objects.experiment import (
|
|
10
|
+
EvaluationConfig,
|
|
11
|
+
ExperimentSpec,
|
|
12
|
+
ExperimentStatus,
|
|
13
|
+
ModelFamily,
|
|
14
|
+
ResourcePolicy,
|
|
15
|
+
SplitStrategy,
|
|
16
|
+
SplitStrategyType,
|
|
17
|
+
TaskType,
|
|
18
|
+
)
|
|
19
|
+
from ml_mcp.domain.value_objects.model import (
|
|
20
|
+
ApprovalStatus,
|
|
21
|
+
HyperparameterConstraint,
|
|
22
|
+
ModelMetadata,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"TaskType",
|
|
27
|
+
"ModelFamily",
|
|
28
|
+
"SplitStrategyType",
|
|
29
|
+
"SplitStrategy",
|
|
30
|
+
"EvaluationConfig",
|
|
31
|
+
"ResourcePolicy",
|
|
32
|
+
"ExperimentStatus",
|
|
33
|
+
"ExperimentSpec",
|
|
34
|
+
"DatasetFormat",
|
|
35
|
+
"FeatureType",
|
|
36
|
+
"ColumnSchema",
|
|
37
|
+
"DatasetSchema",
|
|
38
|
+
"ApprovalStatus",
|
|
39
|
+
"HyperparameterConstraint",
|
|
40
|
+
"ModelMetadata",
|
|
41
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Dataset value objects and schema contracts."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, field_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DatasetFormat(StrEnum):
|
|
10
|
+
"""Allowed tabular dataset formats."""
|
|
11
|
+
|
|
12
|
+
CSV = "csv"
|
|
13
|
+
PARQUET = "parquet"
|
|
14
|
+
JSONL = "jsonl"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FeatureType(StrEnum):
|
|
18
|
+
"""Canonical feature types inferred and used in ML preprocessing."""
|
|
19
|
+
|
|
20
|
+
NUMERIC = "numeric"
|
|
21
|
+
CATEGORICAL = "categorical"
|
|
22
|
+
BOOLEAN = "boolean"
|
|
23
|
+
DATETIME = "datetime"
|
|
24
|
+
TEXT = "text"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ColumnSchema(BaseModel):
|
|
28
|
+
"""Schema and statistical metadata for a single dataset column."""
|
|
29
|
+
|
|
30
|
+
name: str = Field(..., min_length=1)
|
|
31
|
+
feature_type: FeatureType
|
|
32
|
+
nullable: bool = True
|
|
33
|
+
null_count: int = Field(default=0, ge=0)
|
|
34
|
+
unique_count: int = Field(default=0, ge=0)
|
|
35
|
+
sample_values: list[Any] = Field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DatasetSchema(BaseModel):
|
|
39
|
+
"""Complete dataset schema structure with content digest and profile."""
|
|
40
|
+
|
|
41
|
+
columns: list[ColumnSchema]
|
|
42
|
+
row_count: int = Field(..., ge=0)
|
|
43
|
+
column_count: int = Field(..., ge=1)
|
|
44
|
+
size_bytes: int = Field(..., ge=0)
|
|
45
|
+
content_hash: str = Field(..., description="SHA-256 content hash of canonicalized dataset")
|
|
46
|
+
|
|
47
|
+
@field_validator("columns")
|
|
48
|
+
@classmethod
|
|
49
|
+
def validate_unique_column_names(cls, v: list[ColumnSchema]) -> list[ColumnSchema]:
|
|
50
|
+
names = [col.name.lower() for col in v]
|
|
51
|
+
if len(names) != len(set(names)):
|
|
52
|
+
raise ValueError("Dataset schema contains duplicate column names (case-insensitive)")
|
|
53
|
+
return v
|
|
54
|
+
|
|
55
|
+
def get_column(self, name: str) -> ColumnSchema | None:
|
|
56
|
+
name_lower = name.lower()
|
|
57
|
+
for col in self.columns:
|
|
58
|
+
if col.name.lower() == name_lower:
|
|
59
|
+
return col
|
|
60
|
+
return None
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Experiment value objects and specification contracts."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, model_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TaskType(StrEnum):
|
|
10
|
+
"""Supported machine learning task types."""
|
|
11
|
+
|
|
12
|
+
BINARY_CLASSIFICATION = "binary_classification"
|
|
13
|
+
MULTICLASS_CLASSIFICATION = "multiclass_classification"
|
|
14
|
+
REGRESSION = "regression"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ModelFamily(StrEnum):
|
|
18
|
+
"""Initial approved tabular model families."""
|
|
19
|
+
|
|
20
|
+
LOGISTIC_REGRESSION = "logistic_regression"
|
|
21
|
+
RANDOM_FOREST = "random_forest"
|
|
22
|
+
XGBOOST = "xgboost"
|
|
23
|
+
LIGHTGBM = "lightgbm"
|
|
24
|
+
CATBOOST = "catboost"
|
|
25
|
+
LINEAR_SVM = "linear_svm"
|
|
26
|
+
MLP = "mlp"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SplitStrategyType(StrEnum):
|
|
30
|
+
"""Dataset splitting strategies."""
|
|
31
|
+
|
|
32
|
+
TRAIN_TEST_SPLIT = "train_test_split"
|
|
33
|
+
K_FOLD = "k_fold"
|
|
34
|
+
STRATIFIED_K_FOLD = "stratified_k_fold"
|
|
35
|
+
TIME_SERIES_SPLIT = "time_series_split"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SplitStrategy(BaseModel):
|
|
39
|
+
"""Configuration for splitting dataset into training and evaluation partitions."""
|
|
40
|
+
|
|
41
|
+
strategy: SplitStrategyType = SplitStrategyType.TRAIN_TEST_SPLIT
|
|
42
|
+
test_size: float = Field(
|
|
43
|
+
default=0.2, ge=0.05, le=0.5, description="Fraction of data reserved for testing"
|
|
44
|
+
)
|
|
45
|
+
n_splits: int = Field(
|
|
46
|
+
default=5, ge=2, le=20, description="Folds for cross-validation strategies"
|
|
47
|
+
)
|
|
48
|
+
shuffle: bool = True
|
|
49
|
+
random_seed: int = Field(default=42, ge=0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class EvaluationConfig(BaseModel):
|
|
53
|
+
"""Evaluation metrics configuration."""
|
|
54
|
+
|
|
55
|
+
primary_metric: str = Field(..., description="Target optimization and reporting metric")
|
|
56
|
+
additional_metrics: list[str] = Field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ResourcePolicy(BaseModel):
|
|
60
|
+
"""Hardware quotas and execution bounds for worker isolation."""
|
|
61
|
+
|
|
62
|
+
max_runtime_seconds: int = Field(default=1800, ge=10, le=7200)
|
|
63
|
+
max_memory_mb: int = Field(default=4096, ge=256, le=32768)
|
|
64
|
+
max_cpu_cores: int = Field(default=2, ge=1, le=16)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ExperimentStatus(StrEnum):
|
|
68
|
+
"""Lifecycle states of an ML experiment."""
|
|
69
|
+
|
|
70
|
+
CREATED = "CREATED"
|
|
71
|
+
VALIDATING = "VALIDATING"
|
|
72
|
+
REJECTED = "REJECTED"
|
|
73
|
+
QUEUED = "QUEUED"
|
|
74
|
+
RUNNING = "RUNNING"
|
|
75
|
+
SUCCEEDED = "SUCCEEDED"
|
|
76
|
+
FAILED = "FAILED"
|
|
77
|
+
CANCEL_REQUESTED = "CANCEL_REQUESTED"
|
|
78
|
+
CANCELLED = "CANCELLED"
|
|
79
|
+
ANALYZING = "ANALYZING"
|
|
80
|
+
ANALYSIS_READY = "ANALYSIS_READY"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ExperimentSpec(BaseModel):
|
|
84
|
+
"""Complete, immutable experiment specification defining reproducible execution."""
|
|
85
|
+
|
|
86
|
+
tenant_id: str = Field(..., min_length=1)
|
|
87
|
+
project_id: str = Field(..., min_length=1)
|
|
88
|
+
dataset_version_id: str = Field(..., min_length=1)
|
|
89
|
+
model_version_id: str = Field(..., min_length=1)
|
|
90
|
+
task_type: TaskType
|
|
91
|
+
target_column: str = Field(..., min_length=1)
|
|
92
|
+
feature_columns: list[str] | None = None
|
|
93
|
+
split_strategy: SplitStrategy = Field(default_factory=SplitStrategy)
|
|
94
|
+
evaluation_config: EvaluationConfig
|
|
95
|
+
hyperparameters: dict[str, Any] = Field(default_factory=dict)
|
|
96
|
+
resource_policy: ResourcePolicy = Field(default_factory=ResourcePolicy)
|
|
97
|
+
random_seed: int = Field(default=42, ge=0)
|
|
98
|
+
idempotency_key: str | None = None
|
|
99
|
+
created_by: str = Field(..., min_length=1)
|
|
100
|
+
|
|
101
|
+
@model_validator(mode="after")
|
|
102
|
+
def validate_metrics_compatible_with_task(self) -> "ExperimentSpec":
|
|
103
|
+
classification_metrics = {
|
|
104
|
+
"accuracy",
|
|
105
|
+
"f1",
|
|
106
|
+
"f1_macro",
|
|
107
|
+
"f1_weighted",
|
|
108
|
+
"precision",
|
|
109
|
+
"recall",
|
|
110
|
+
"roc_auc",
|
|
111
|
+
"pr_auc",
|
|
112
|
+
"log_loss",
|
|
113
|
+
"brier_score",
|
|
114
|
+
}
|
|
115
|
+
regression_metrics = {"mse", "rmse", "mae", "r2", "mape", "explained_variance"}
|
|
116
|
+
|
|
117
|
+
pm = self.evaluation_config.primary_metric.lower()
|
|
118
|
+
if (
|
|
119
|
+
self.task_type in (TaskType.BINARY_CLASSIFICATION, TaskType.MULTICLASS_CLASSIFICATION)
|
|
120
|
+
and pm in regression_metrics
|
|
121
|
+
and pm not in classification_metrics
|
|
122
|
+
):
|
|
123
|
+
raise ValueError(f"Metric '{pm}' is not compatible with classification task")
|
|
124
|
+
if (
|
|
125
|
+
self.task_type == TaskType.REGRESSION
|
|
126
|
+
and pm in classification_metrics
|
|
127
|
+
and pm not in regression_metrics
|
|
128
|
+
):
|
|
129
|
+
raise ValueError(f"Metric '{pm}' is not compatible with regression task")
|
|
130
|
+
|
|
131
|
+
return self
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Model registry value objects and constraints."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from ml_mcp.domain.value_objects.experiment import ModelFamily, TaskType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApprovalStatus(StrEnum):
|
|
12
|
+
"""Approval lifecycle of registered models."""
|
|
13
|
+
|
|
14
|
+
PENDING = "PENDING"
|
|
15
|
+
APPROVED = "APPROVED"
|
|
16
|
+
REJECTED = "REJECTED"
|
|
17
|
+
REVOKED = "REVOKED"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HyperparameterConstraint(BaseModel):
|
|
21
|
+
"""Schema constraint definition for a model hyperparameter."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
type: str # "int", "float", "str", "bool"
|
|
25
|
+
default: Any
|
|
26
|
+
min_value: float | int | None = None
|
|
27
|
+
max_value: float | int | None = None
|
|
28
|
+
allowed_values: list[Any] | None = None
|
|
29
|
+
description: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ModelMetadata(BaseModel):
|
|
33
|
+
"""Immutable model architecture specification in the catalog."""
|
|
34
|
+
|
|
35
|
+
model_id: str = Field(..., min_length=1)
|
|
36
|
+
version: str = Field(..., min_length=1)
|
|
37
|
+
name: str = Field(..., min_length=1)
|
|
38
|
+
description: str = ""
|
|
39
|
+
family: ModelFamily
|
|
40
|
+
supported_task_types: list[TaskType]
|
|
41
|
+
approval_status: ApprovalStatus = ApprovalStatus.PENDING
|
|
42
|
+
container_image_digest: str = Field(
|
|
43
|
+
..., description="SHA-256 digest of verified worker container image"
|
|
44
|
+
)
|
|
45
|
+
artifact_digest: str | None = None
|
|
46
|
+
supported_hyperparameters: list[HyperparameterConstraint] = Field(default_factory=list)
|
|
47
|
+
license: str = "Apache-2.0"
|
|
48
|
+
publisher: str = "ModelLab"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Infrastructure layer package."""
|