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.
@@ -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
+ }