SKSurrogate 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.
- SKSurrogate/DataProcess.py +409 -0
- SKSurrogate/NpyProximation.py +457 -0
- SKSurrogate/__init__.py +110 -0
- SKSurrogate/aml.py +1156 -0
- SKSurrogate/ci.py +93 -0
- SKSurrogate/deployment.py +31 -0
- SKSurrogate/eoa.py +381 -0
- SKSurrogate/execution.py +416 -0
- SKSurrogate/inference.py +224 -0
- SKSurrogate/mltrace.py +2138 -0
- SKSurrogate/modelbundle.py +600 -0
- SKSurrogate/monitoring.py +360 -0
- SKSurrogate/retraining.py +49 -0
- SKSurrogate/sensapprx.py +250 -0
- SKSurrogate/structsearch.py +1411 -0
- SKSurrogate/synthdat.py +503 -0
- sksurrogate-0.1.0.dist-info/METADATA +139 -0
- sksurrogate-0.1.0.dist-info/RECORD +21 -0
- sksurrogate-0.1.0.dist-info/WHEEL +5 -0
- sksurrogate-0.1.0.dist-info/entry_points.txt +2 -0
- sksurrogate-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""Portable model bundles and a small filesystem-backed model registry."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import joblib
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
BUNDLE_FORMAT_VERSION = 1
|
|
17
|
+
REGISTRY_STATES = {"candidate", "validated", "staging", "production", "archived"}
|
|
18
|
+
_SENSITIVE_KEYS = {
|
|
19
|
+
"api_key",
|
|
20
|
+
"apikey",
|
|
21
|
+
"password",
|
|
22
|
+
"passwd",
|
|
23
|
+
"secret",
|
|
24
|
+
"token",
|
|
25
|
+
"private_key",
|
|
26
|
+
"privatekey",
|
|
27
|
+
"ssn",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _redact_sensitive_value(key, value):
|
|
32
|
+
if isinstance(key, str):
|
|
33
|
+
normalized = key.lower().replace("-", "_")
|
|
34
|
+
if any(token in normalized for token in _SENSITIVE_KEYS):
|
|
35
|
+
return "[REDACTED]"
|
|
36
|
+
if isinstance(value, dict):
|
|
37
|
+
return {inner_key: _redact_sensitive_value(inner_key, inner_value) for inner_key, inner_value in value.items()}
|
|
38
|
+
if isinstance(value, list):
|
|
39
|
+
return [_redact_sensitive_value(key, item) for item in value]
|
|
40
|
+
if isinstance(value, tuple):
|
|
41
|
+
return tuple(_redact_sensitive_value(key, item) for item in value)
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _dependencies():
|
|
46
|
+
dependencies = {}
|
|
47
|
+
for package in ("numpy", "pandas", "scikit-learn", "SKSurrogate"):
|
|
48
|
+
try:
|
|
49
|
+
dependencies[package] = importlib.metadata.version(package)
|
|
50
|
+
except importlib.metadata.PackageNotFoundError:
|
|
51
|
+
continue
|
|
52
|
+
return dependencies
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ModelBundle:
|
|
56
|
+
"""A fitted estimator and the metadata needed to reproduce its inputs."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
model,
|
|
61
|
+
*,
|
|
62
|
+
task_name=None,
|
|
63
|
+
schema=None,
|
|
64
|
+
preprocessing=None,
|
|
65
|
+
metadata=None,
|
|
66
|
+
metrics=None,
|
|
67
|
+
dataset_fingerprint=None,
|
|
68
|
+
config_version=None,
|
|
69
|
+
model_version=None,
|
|
70
|
+
dependencies=None,
|
|
71
|
+
owner=None,
|
|
72
|
+
run_id=None,
|
|
73
|
+
sensitive_features=None,
|
|
74
|
+
audit_events=None,
|
|
75
|
+
):
|
|
76
|
+
self.model = model
|
|
77
|
+
self.task_name = task_name
|
|
78
|
+
self.schema = {} if schema is None else schema
|
|
79
|
+
self.preprocessing = preprocessing
|
|
80
|
+
self.metadata = {} if metadata is None else metadata
|
|
81
|
+
self.metrics = {} if metrics is None else metrics
|
|
82
|
+
self.dataset_fingerprint = dataset_fingerprint
|
|
83
|
+
self.config_version = config_version
|
|
84
|
+
self.model_version = model_version or uuid.uuid4().hex
|
|
85
|
+
self.dependencies = _dependencies() if dependencies is None else dependencies
|
|
86
|
+
self.owner = owner
|
|
87
|
+
self.run_id = run_id
|
|
88
|
+
self.sensitive_features = list(sensitive_features) if sensitive_features is not None else []
|
|
89
|
+
self.audit_events = list(audit_events) if audit_events is not None else []
|
|
90
|
+
self.created_at = datetime.now(timezone.utc).isoformat()
|
|
91
|
+
|
|
92
|
+
def record_audit_event(self, event_type, **details):
|
|
93
|
+
snapshot = {**self.metadata, **details}
|
|
94
|
+
event = {
|
|
95
|
+
"event_type": event_type,
|
|
96
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
97
|
+
"details": _redact_sensitive_value("details", snapshot),
|
|
98
|
+
}
|
|
99
|
+
self.audit_events.append(event)
|
|
100
|
+
return event
|
|
101
|
+
|
|
102
|
+
def to_record(self):
|
|
103
|
+
return {
|
|
104
|
+
"format_version": BUNDLE_FORMAT_VERSION,
|
|
105
|
+
"model_version": self.model_version,
|
|
106
|
+
"created_at": self.created_at,
|
|
107
|
+
"task_name": self.task_name,
|
|
108
|
+
"model": self.model,
|
|
109
|
+
"preprocessing": self.preprocessing,
|
|
110
|
+
"schema": self.schema,
|
|
111
|
+
"metadata": _redact_sensitive_value("metadata", self.metadata),
|
|
112
|
+
"metrics": self.metrics,
|
|
113
|
+
"dataset_fingerprint": self.dataset_fingerprint,
|
|
114
|
+
"config_version": self.config_version,
|
|
115
|
+
"dependencies": self.dependencies,
|
|
116
|
+
"owner": self.owner,
|
|
117
|
+
"run_id": self.run_id,
|
|
118
|
+
"sensitive_features": list(self.sensitive_features),
|
|
119
|
+
"audit_events": self.audit_events,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_record(cls, record):
|
|
124
|
+
if record.get("format_version") != BUNDLE_FORMAT_VERSION:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"Unsupported model bundle format: %r" % record.get("format_version")
|
|
127
|
+
)
|
|
128
|
+
bundle = cls(
|
|
129
|
+
record["model"],
|
|
130
|
+
task_name=record.get("task_name"),
|
|
131
|
+
schema=record.get("schema"),
|
|
132
|
+
preprocessing=record.get("preprocessing"),
|
|
133
|
+
metadata=record.get("metadata"),
|
|
134
|
+
metrics=record.get("metrics"),
|
|
135
|
+
dataset_fingerprint=record.get("dataset_fingerprint"),
|
|
136
|
+
config_version=record.get("config_version"),
|
|
137
|
+
model_version=record.get("model_version"),
|
|
138
|
+
dependencies=record.get("dependencies"),
|
|
139
|
+
owner=record.get("owner"),
|
|
140
|
+
run_id=record.get("run_id"),
|
|
141
|
+
sensitive_features=record.get("sensitive_features"),
|
|
142
|
+
audit_events=record.get("audit_events", []),
|
|
143
|
+
)
|
|
144
|
+
bundle.created_at = record.get("created_at", bundle.created_at)
|
|
145
|
+
return bundle
|
|
146
|
+
|
|
147
|
+
def predict(self, *args, **kwargs):
|
|
148
|
+
"""Delegate prediction to the fitted model in the bundle."""
|
|
149
|
+
return self.model.predict(*args, **kwargs)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def save_bundle(bundle, path):
|
|
153
|
+
"""Atomically write a :class:`ModelBundle` and return its path."""
|
|
154
|
+
if not isinstance(bundle, ModelBundle):
|
|
155
|
+
raise TypeError("bundle must be a ModelBundle")
|
|
156
|
+
destination = Path(path)
|
|
157
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
with tempfile.NamedTemporaryFile(
|
|
159
|
+
mode="wb", dir=str(destination.parent), prefix=".bundle-", delete=False
|
|
160
|
+
) as temporary:
|
|
161
|
+
temporary_path = Path(temporary.name)
|
|
162
|
+
joblib.dump(bundle.to_record(), temporary)
|
|
163
|
+
temporary.flush()
|
|
164
|
+
os.fsync(temporary.fileno())
|
|
165
|
+
try:
|
|
166
|
+
os.replace(str(temporary_path), str(destination))
|
|
167
|
+
except Exception:
|
|
168
|
+
temporary_path.unlink(missing_ok=True)
|
|
169
|
+
raise
|
|
170
|
+
return destination
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def load_bundle(path, *, expected_schema=None, strict_dependencies=True):
|
|
174
|
+
"""Load a bundle and reject incompatible schema or runtime dependencies."""
|
|
175
|
+
record = joblib.load(path)
|
|
176
|
+
bundle = ModelBundle.from_record(record)
|
|
177
|
+
if expected_schema is not None and bundle.schema != expected_schema:
|
|
178
|
+
raise ValueError("Model bundle schema is incompatible with the expected schema")
|
|
179
|
+
if strict_dependencies:
|
|
180
|
+
current = _dependencies()
|
|
181
|
+
incompatible = {
|
|
182
|
+
name: (required, current.get(name))
|
|
183
|
+
for name, required in bundle.dependencies.items()
|
|
184
|
+
if current.get(name) != required
|
|
185
|
+
}
|
|
186
|
+
if incompatible:
|
|
187
|
+
raise ValueError("Model bundle dependencies are incompatible: %s" % incompatible)
|
|
188
|
+
return bundle
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def export_mlflow(bundle, path):
|
|
192
|
+
"""Export a bundle using the standard MLflow model directory layout.
|
|
193
|
+
|
|
194
|
+
MLflow remains an optional dependency. When installed, its sklearn flavor
|
|
195
|
+
can consume the exported ``MLmodel`` and ``model.pkl`` files directly.
|
|
196
|
+
"""
|
|
197
|
+
if not isinstance(bundle, ModelBundle):
|
|
198
|
+
raise TypeError("bundle must be a ModelBundle")
|
|
199
|
+
destination = Path(path)
|
|
200
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
if destination.exists():
|
|
202
|
+
raise FileExistsError("MLflow export destination already exists: %s" % destination)
|
|
203
|
+
staging = Path(tempfile.mkdtemp(prefix=".mlflow-", dir=str(destination.parent)))
|
|
204
|
+
try:
|
|
205
|
+
joblib.dump(bundle.model, staging / "model.pkl")
|
|
206
|
+
with (staging / "bundle.json").open("w", encoding="utf-8") as stream:
|
|
207
|
+
json.dump(bundle.to_record(), stream, indent=2, sort_keys=True, default=str)
|
|
208
|
+
sklearn_version = bundle.dependencies.get("scikit-learn", "unknown")
|
|
209
|
+
mlmodel = "\n".join(
|
|
210
|
+
[
|
|
211
|
+
"artifact_path: model",
|
|
212
|
+
"flavors:",
|
|
213
|
+
" python_function:",
|
|
214
|
+
" loader_module: mlflow.sklearn",
|
|
215
|
+
" model_path: model.pkl",
|
|
216
|
+
" python_version: %s" % platform.python_version(),
|
|
217
|
+
" sklearn:",
|
|
218
|
+
" pickled_model: model.pkl",
|
|
219
|
+
" sklearn_version: %s" % sklearn_version,
|
|
220
|
+
"metadata:",
|
|
221
|
+
" model_version: %s" % bundle.model_version,
|
|
222
|
+
" task_name: %s" % (bundle.task_name or ""),
|
|
223
|
+
"",
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
(staging / "MLmodel").write_text(mlmodel, encoding="utf-8")
|
|
227
|
+
os.replace(str(staging), str(destination))
|
|
228
|
+
except Exception:
|
|
229
|
+
import shutil
|
|
230
|
+
|
|
231
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
232
|
+
raise
|
|
233
|
+
return destination
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class ModelRegistry:
|
|
237
|
+
"""Filesystem registry for versioned bundles and promotion history."""
|
|
238
|
+
|
|
239
|
+
def __init__(self, root):
|
|
240
|
+
self.root = Path(root)
|
|
241
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
self._index_path = self.root / "registry.json"
|
|
243
|
+
|
|
244
|
+
def _read_index(self):
|
|
245
|
+
if not self._index_path.exists():
|
|
246
|
+
return {"models": {}}
|
|
247
|
+
with self._index_path.open(encoding="utf-8") as stream:
|
|
248
|
+
return json.load(stream)
|
|
249
|
+
|
|
250
|
+
def _write_index(self, index):
|
|
251
|
+
with tempfile.NamedTemporaryFile(
|
|
252
|
+
mode="w", dir=str(self.root), prefix=".registry-", delete=False, encoding="utf-8"
|
|
253
|
+
) as temporary:
|
|
254
|
+
temporary_path = Path(temporary.name)
|
|
255
|
+
json.dump(index, temporary, indent=2, sort_keys=True)
|
|
256
|
+
temporary.flush()
|
|
257
|
+
os.fsync(temporary.fileno())
|
|
258
|
+
os.replace(str(temporary_path), str(self._index_path))
|
|
259
|
+
|
|
260
|
+
def register(self, bundle):
|
|
261
|
+
"""Store a new bundle as a candidate version."""
|
|
262
|
+
if not isinstance(bundle, ModelBundle):
|
|
263
|
+
raise TypeError("bundle must be a ModelBundle")
|
|
264
|
+
task_name = bundle.task_name or "default"
|
|
265
|
+
bundle.record_audit_event("register", task_name=task_name, model_version=bundle.model_version)
|
|
266
|
+
task_dir = self.root / task_name
|
|
267
|
+
path = task_dir / (bundle.model_version + ".bundle")
|
|
268
|
+
save_bundle(bundle, path)
|
|
269
|
+
index = self._read_index()
|
|
270
|
+
task = index["models"].setdefault(
|
|
271
|
+
task_name,
|
|
272
|
+
{"versions": {}, "aliases": {}, "history": [], "audit": []},
|
|
273
|
+
)
|
|
274
|
+
task["versions"][bundle.model_version] = {
|
|
275
|
+
"path": str(path.relative_to(self.root)),
|
|
276
|
+
"state": "candidate",
|
|
277
|
+
"created_at": bundle.created_at,
|
|
278
|
+
}
|
|
279
|
+
task["aliases"]["latest"] = bundle.model_version
|
|
280
|
+
task["audit"].append({
|
|
281
|
+
"event_type": "register",
|
|
282
|
+
"task_name": task_name,
|
|
283
|
+
"model_version": bundle.model_version,
|
|
284
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
285
|
+
"details": {"state": "candidate"},
|
|
286
|
+
})
|
|
287
|
+
self._write_index(index)
|
|
288
|
+
return bundle.model_version
|
|
289
|
+
|
|
290
|
+
def promote(self, task_name, model_version, state):
|
|
291
|
+
"""Set a version state and update the matching lifecycle alias."""
|
|
292
|
+
if state not in REGISTRY_STATES:
|
|
293
|
+
raise ValueError("Unknown model registry state: %r" % state)
|
|
294
|
+
index = self._read_index()
|
|
295
|
+
task = index["models"].get(task_name)
|
|
296
|
+
if task is None or model_version not in task["versions"]:
|
|
297
|
+
raise KeyError("Unknown model version %r for task %r" % (model_version, task_name))
|
|
298
|
+
previous = task["versions"][model_version]["state"]
|
|
299
|
+
task["versions"][model_version]["state"] = state
|
|
300
|
+
task["aliases"][state] = model_version
|
|
301
|
+
task["history"].append({
|
|
302
|
+
"event_type": "promote",
|
|
303
|
+
"action": "promote",
|
|
304
|
+
"from": previous,
|
|
305
|
+
"to": state,
|
|
306
|
+
"model_version": model_version,
|
|
307
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
308
|
+
})
|
|
309
|
+
task.setdefault("audit", []).append({
|
|
310
|
+
"event_type": "promote",
|
|
311
|
+
"action": "promote",
|
|
312
|
+
"from": previous,
|
|
313
|
+
"to": state,
|
|
314
|
+
"model_version": model_version,
|
|
315
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
316
|
+
})
|
|
317
|
+
self._write_index(index)
|
|
318
|
+
|
|
319
|
+
def record_approval(self, task_name, model_version, approver, *, state):
|
|
320
|
+
"""Persist an approval event for a pending lifecycle transition."""
|
|
321
|
+
if not str(approver).strip():
|
|
322
|
+
raise ValueError("approver must not be empty")
|
|
323
|
+
index = self._read_index()
|
|
324
|
+
task = index["models"].get(task_name)
|
|
325
|
+
if task is None or model_version not in task["versions"]:
|
|
326
|
+
raise KeyError("Unknown model version %r for task %r" % (model_version, task_name))
|
|
327
|
+
event = {
|
|
328
|
+
"event_type": "approval",
|
|
329
|
+
"action": "approve",
|
|
330
|
+
"state": state,
|
|
331
|
+
"model_version": model_version,
|
|
332
|
+
"approver": str(approver),
|
|
333
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
334
|
+
}
|
|
335
|
+
task.setdefault("audit", []).append(event)
|
|
336
|
+
self._write_index(index)
|
|
337
|
+
return event
|
|
338
|
+
|
|
339
|
+
def rollback(self, task_name, state, model_version=None):
|
|
340
|
+
"""Point a lifecycle alias at a prior version and record the rollback."""
|
|
341
|
+
index = self._read_index()
|
|
342
|
+
task = index["models"].get(task_name)
|
|
343
|
+
if task is None:
|
|
344
|
+
raise KeyError("Unknown model task: %r" % task_name)
|
|
345
|
+
target = model_version or next(
|
|
346
|
+
(item["model_version"] for item in reversed(task["history"])
|
|
347
|
+
if item["action"] == "promote" and item["to"] == state),
|
|
348
|
+
None,
|
|
349
|
+
)
|
|
350
|
+
if target is None or target not in task["versions"]:
|
|
351
|
+
raise KeyError("No rollback target for state %r" % state)
|
|
352
|
+
previous = task["aliases"].get(state)
|
|
353
|
+
task["aliases"][state] = target
|
|
354
|
+
task["history"].append({
|
|
355
|
+
"event_type": "rollback",
|
|
356
|
+
"action": "rollback",
|
|
357
|
+
"state": state,
|
|
358
|
+
"from": previous,
|
|
359
|
+
"to": target,
|
|
360
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
361
|
+
})
|
|
362
|
+
task.setdefault("audit", []).append({
|
|
363
|
+
"event_type": "rollback",
|
|
364
|
+
"action": "rollback",
|
|
365
|
+
"state": state,
|
|
366
|
+
"from": previous,
|
|
367
|
+
"to": target,
|
|
368
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
369
|
+
})
|
|
370
|
+
self._write_index(index)
|
|
371
|
+
|
|
372
|
+
def audit_log(self, task_name):
|
|
373
|
+
index = self._read_index()
|
|
374
|
+
task = index["models"].get(task_name)
|
|
375
|
+
if task is None:
|
|
376
|
+
raise KeyError("Unknown model task: %r" % task_name)
|
|
377
|
+
return list(task.get("audit", task.get("history", [])))
|
|
378
|
+
|
|
379
|
+
def register_dataset(self, task_name, dataset_fingerprint, source_path, *, metadata=None):
|
|
380
|
+
"""Register a stored dataset artifact and keep retention metadata for the task."""
|
|
381
|
+
index = self._read_index()
|
|
382
|
+
task = index["models"].setdefault(
|
|
383
|
+
task_name,
|
|
384
|
+
{"versions": {}, "aliases": {}, "history": [], "audit": [], "datasets": {}, "predictions": {}},
|
|
385
|
+
)
|
|
386
|
+
dataset_dir = self.root / task_name / "datasets"
|
|
387
|
+
dataset_dir.mkdir(parents=True, exist_ok=True)
|
|
388
|
+
dataset_file = dataset_dir / (dataset_fingerprint + ".json")
|
|
389
|
+
payload = {
|
|
390
|
+
"task_name": task_name,
|
|
391
|
+
"dataset_fingerprint": dataset_fingerprint,
|
|
392
|
+
"source_path": str(source_path),
|
|
393
|
+
"registered_at": datetime.now(timezone.utc).isoformat(),
|
|
394
|
+
"metadata": metadata or {},
|
|
395
|
+
}
|
|
396
|
+
with dataset_file.open("w", encoding="utf-8") as stream:
|
|
397
|
+
json.dump(payload, stream, indent=2, sort_keys=True)
|
|
398
|
+
task.setdefault("datasets", {})[dataset_fingerprint] = {
|
|
399
|
+
"path": str(dataset_file.relative_to(self.root)),
|
|
400
|
+
"source_path": str(source_path),
|
|
401
|
+
"registered_at": payload["registered_at"],
|
|
402
|
+
"metadata": payload["metadata"],
|
|
403
|
+
}
|
|
404
|
+
self._write_index(index)
|
|
405
|
+
return dataset_file
|
|
406
|
+
|
|
407
|
+
def register_prediction(self, task_name, model_version, dataset_fingerprint, source_path, *, prediction_id=None, metadata=None):
|
|
408
|
+
"""Register a stored prediction artifact keyed to a model and dataset."""
|
|
409
|
+
index = self._read_index()
|
|
410
|
+
task = index["models"].setdefault(
|
|
411
|
+
task_name,
|
|
412
|
+
{"versions": {}, "aliases": {}, "history": [], "audit": [], "datasets": {}, "predictions": {}},
|
|
413
|
+
)
|
|
414
|
+
prediction_id = prediction_id or uuid.uuid4().hex
|
|
415
|
+
target_dir = self.root / task_name / "predictions" / model_version / dataset_fingerprint
|
|
416
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
417
|
+
target_path = target_dir / (prediction_id + ".csv")
|
|
418
|
+
source = Path(source_path)
|
|
419
|
+
if source.exists() and source != target_path:
|
|
420
|
+
shutil.copy2(str(source), str(target_path))
|
|
421
|
+
else:
|
|
422
|
+
target_path.write_text(str(source_path), encoding="utf-8")
|
|
423
|
+
task.setdefault("predictions", {}).setdefault(model_version, {}).setdefault(dataset_fingerprint, {})[prediction_id] = {
|
|
424
|
+
"path": str(target_path.relative_to(self.root)),
|
|
425
|
+
"registered_at": datetime.now(timezone.utc).isoformat(),
|
|
426
|
+
"metadata": metadata or {},
|
|
427
|
+
}
|
|
428
|
+
self._write_index(index)
|
|
429
|
+
return target_path
|
|
430
|
+
|
|
431
|
+
def delete_artifacts(self, task_name, *, model_version=None, dataset_fingerprint=None):
|
|
432
|
+
"""Delete stored dataset and prediction artifacts by task, model, and dataset identity."""
|
|
433
|
+
index = self._read_index()
|
|
434
|
+
task = index["models"].get(task_name)
|
|
435
|
+
if task is None:
|
|
436
|
+
raise KeyError("Unknown model task: %r" % task_name)
|
|
437
|
+
deleted = {"datasets": 0, "predictions": 0}
|
|
438
|
+
|
|
439
|
+
datasets = task.get("datasets", {})
|
|
440
|
+
if dataset_fingerprint is not None:
|
|
441
|
+
entry = datasets.pop(dataset_fingerprint, None)
|
|
442
|
+
if entry is not None:
|
|
443
|
+
path = self.root / entry["path"]
|
|
444
|
+
if path.exists():
|
|
445
|
+
path.unlink()
|
|
446
|
+
deleted["datasets"] += 1
|
|
447
|
+
else:
|
|
448
|
+
for dataset_key in list(datasets):
|
|
449
|
+
entry = datasets.pop(dataset_key, None)
|
|
450
|
+
path = self.root / entry["path"]
|
|
451
|
+
if path.exists():
|
|
452
|
+
path.unlink()
|
|
453
|
+
deleted["datasets"] += 1
|
|
454
|
+
|
|
455
|
+
predictions = task.get("predictions", {})
|
|
456
|
+
if model_version is not None:
|
|
457
|
+
model_predictions = predictions.get(model_version, {})
|
|
458
|
+
if dataset_fingerprint is not None:
|
|
459
|
+
dataset_predictions = model_predictions.pop(dataset_fingerprint, {})
|
|
460
|
+
for prediction_key, entry in list(dataset_predictions.items()):
|
|
461
|
+
path = self.root / entry["path"]
|
|
462
|
+
if path.exists():
|
|
463
|
+
path.unlink()
|
|
464
|
+
deleted["predictions"] += 1
|
|
465
|
+
if not model_predictions:
|
|
466
|
+
predictions.pop(model_version, None)
|
|
467
|
+
else:
|
|
468
|
+
for dataset_key in list(model_predictions):
|
|
469
|
+
dataset_predictions = model_predictions.pop(dataset_key, {})
|
|
470
|
+
for prediction_key, entry in list(dataset_predictions.items()):
|
|
471
|
+
path = self.root / entry["path"]
|
|
472
|
+
if path.exists():
|
|
473
|
+
path.unlink()
|
|
474
|
+
deleted["predictions"] += 1
|
|
475
|
+
if not model_predictions:
|
|
476
|
+
predictions.pop(model_version, None)
|
|
477
|
+
elif dataset_fingerprint is not None:
|
|
478
|
+
for model_key, model_predictions in list(predictions.items()):
|
|
479
|
+
dataset_predictions = model_predictions.pop(dataset_fingerprint, {})
|
|
480
|
+
for prediction_key, entry in list(dataset_predictions.items()):
|
|
481
|
+
path = self.root / entry["path"]
|
|
482
|
+
if path.exists():
|
|
483
|
+
path.unlink()
|
|
484
|
+
deleted["predictions"] += 1
|
|
485
|
+
if not model_predictions:
|
|
486
|
+
predictions.pop(model_key, None)
|
|
487
|
+
else:
|
|
488
|
+
for model_key, model_predictions in list(predictions.items()):
|
|
489
|
+
for dataset_key in list(model_predictions):
|
|
490
|
+
dataset_predictions = model_predictions.pop(dataset_key, {})
|
|
491
|
+
for prediction_key, entry in list(dataset_predictions.items()):
|
|
492
|
+
path = self.root / entry["path"]
|
|
493
|
+
if path.exists():
|
|
494
|
+
path.unlink()
|
|
495
|
+
deleted["predictions"] += 1
|
|
496
|
+
if not model_predictions:
|
|
497
|
+
predictions.pop(model_key, None)
|
|
498
|
+
|
|
499
|
+
task["datasets"] = datasets
|
|
500
|
+
task["predictions"] = predictions
|
|
501
|
+
task.setdefault("audit", []).append({
|
|
502
|
+
"event_type": "delete_artifacts",
|
|
503
|
+
"task_name": task_name,
|
|
504
|
+
"model_version": model_version,
|
|
505
|
+
"dataset_fingerprint": dataset_fingerprint,
|
|
506
|
+
"deleted": deleted,
|
|
507
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
508
|
+
})
|
|
509
|
+
self._write_index(index)
|
|
510
|
+
return deleted
|
|
511
|
+
|
|
512
|
+
def artifacts(self, task_name):
|
|
513
|
+
"""List the dataset and prediction artifacts registered for a task.
|
|
514
|
+
|
|
515
|
+
Returns a plain, JSON-serializable view with one entry per artifact
|
|
516
|
+
(``kind`` is ``"dataset"`` or ``"prediction"``), each carrying the
|
|
517
|
+
stored path, registration timestamp, free-form metadata and the
|
|
518
|
+
on-disk size, so a retention/cleanup UI can show exactly what a call
|
|
519
|
+
to :meth:`delete_artifacts` would remove. Raises KeyError if the task
|
|
520
|
+
is unknown to the registry.
|
|
521
|
+
"""
|
|
522
|
+
index = self._read_index()
|
|
523
|
+
task = index["models"].get(task_name)
|
|
524
|
+
if task is None:
|
|
525
|
+
raise KeyError("Unknown model task: %r" % task_name)
|
|
526
|
+
|
|
527
|
+
def _entry(kind, path, registered_at, metadata, **extra):
|
|
528
|
+
absolute = self.root / path
|
|
529
|
+
return {
|
|
530
|
+
"kind": kind,
|
|
531
|
+
"path": path,
|
|
532
|
+
"exists": absolute.exists(),
|
|
533
|
+
"size_bytes": absolute.stat().st_size if absolute.exists() else 0,
|
|
534
|
+
"registered_at": registered_at,
|
|
535
|
+
"metadata": metadata or {},
|
|
536
|
+
**extra,
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
datasets = [
|
|
540
|
+
_entry("dataset", entry["path"], entry.get("registered_at"), entry.get("metadata"),
|
|
541
|
+
dataset_fingerprint=fingerprint, source_path=entry.get("source_path"))
|
|
542
|
+
for fingerprint, entry in sorted(task.get("datasets", {}).items())
|
|
543
|
+
]
|
|
544
|
+
predictions = [
|
|
545
|
+
_entry("prediction", entry["path"], entry.get("registered_at"), entry.get("metadata"),
|
|
546
|
+
model_version=model_version, dataset_fingerprint=dataset_fingerprint,
|
|
547
|
+
prediction_id=prediction_id)
|
|
548
|
+
for model_version, by_dataset in sorted(task.get("predictions", {}).items())
|
|
549
|
+
for dataset_fingerprint, by_id in sorted(by_dataset.items())
|
|
550
|
+
for prediction_id, entry in sorted(by_id.items())
|
|
551
|
+
]
|
|
552
|
+
return {"datasets": datasets, "predictions": predictions}
|
|
553
|
+
|
|
554
|
+
def load(self, task_name, alias="production", **kwargs):
|
|
555
|
+
index = self._read_index()
|
|
556
|
+
task = index["models"].get(task_name)
|
|
557
|
+
if task is None or alias not in task["aliases"]:
|
|
558
|
+
raise KeyError("Unknown model alias %r for task %r" % (alias, task_name))
|
|
559
|
+
version = task["aliases"][alias]
|
|
560
|
+
return load_bundle(self.root / task["versions"][version]["path"], **kwargs)
|
|
561
|
+
|
|
562
|
+
def history(self, task_name):
|
|
563
|
+
index = self._read_index()
|
|
564
|
+
task = index["models"].get(task_name)
|
|
565
|
+
if task is None:
|
|
566
|
+
raise KeyError("Unknown model task: %r" % task_name)
|
|
567
|
+
return list(task.get("history", []))
|
|
568
|
+
|
|
569
|
+
def version_state(self, task_name, model_version):
|
|
570
|
+
"""Return the current lifecycle state of a registered version.
|
|
571
|
+
|
|
572
|
+
The authoritative value lives in ``versions`` (set by register/promote);
|
|
573
|
+
promotion history alone is empty for freshly-registered candidates.
|
|
574
|
+
Raises KeyError if the task or version is unknown to the registry.
|
|
575
|
+
"""
|
|
576
|
+
index = self._read_index()
|
|
577
|
+
task = index["models"].get(task_name)
|
|
578
|
+
if task is None:
|
|
579
|
+
raise KeyError("Unknown model task: %r" % task_name)
|
|
580
|
+
versions = task.get("versions", {})
|
|
581
|
+
if model_version not in versions:
|
|
582
|
+
raise KeyError("Unknown model version %r for task %r" % (model_version, task_name))
|
|
583
|
+
return versions[model_version]["state"]
|
|
584
|
+
|
|
585
|
+
def summary(self, task_name):
|
|
586
|
+
"""Return the registry state of one task as a plain dict.
|
|
587
|
+
|
|
588
|
+
Includes every lifecycle alias (``latest`` plus any promoted states)
|
|
589
|
+
and the current state of each registered version, so clients can
|
|
590
|
+
render the full lifecycle in a single round-trip instead of probing
|
|
591
|
+
aliases one by one. Raises KeyError if the task is unknown.
|
|
592
|
+
"""
|
|
593
|
+
index = self._read_index()
|
|
594
|
+
task = index["models"].get(task_name)
|
|
595
|
+
if task is None:
|
|
596
|
+
raise KeyError("Unknown model task: %r" % task_name)
|
|
597
|
+
return {
|
|
598
|
+
"aliases": dict(task.get("aliases", {})),
|
|
599
|
+
"versions": {v: meta.get("state") for v, meta in task.get("versions", {}).items()},
|
|
600
|
+
}
|