medagentrx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. medagentrx/__init__.py +30 -0
  2. medagentrx/__main__.py +8 -0
  3. medagentrx/api.py +109 -0
  4. medagentrx/card.py +152 -0
  5. medagentrx/checks/__init__.py +14 -0
  6. medagentrx/checks/context.py +111 -0
  7. medagentrx/checks/data_checks.py +265 -0
  8. medagentrx/checks/doc_checks.py +78 -0
  9. medagentrx/checks/life_checks.py +71 -0
  10. medagentrx/checks/perf_checks.py +416 -0
  11. medagentrx/checks/plan_checks.py +147 -0
  12. medagentrx/checks/registry.py +108 -0
  13. medagentrx/cli.py +669 -0
  14. medagentrx/demo/__init__.py +7 -0
  15. medagentrx/demo/cohort.py +194 -0
  16. medagentrx/demo/intended-use.txt +1 -0
  17. medagentrx/demo/plan.py +105 -0
  18. medagentrx/demo/scenarios.py +639 -0
  19. medagentrx/document.py +175 -0
  20. medagentrx/engine.py +487 -0
  21. medagentrx/errors.py +37 -0
  22. medagentrx/exit_codes.py +14 -0
  23. medagentrx/guard.py +296 -0
  24. medagentrx/history.py +55 -0
  25. medagentrx/io/__init__.py +3 -0
  26. medagentrx/io/fingerprint.py +45 -0
  27. medagentrx/io/hashing.py +19 -0
  28. medagentrx/io/limits.py +30 -0
  29. medagentrx/io/manifest.py +123 -0
  30. medagentrx/io/metadata.py +67 -0
  31. medagentrx/io/paths.py +88 -0
  32. medagentrx/io/predictions.py +252 -0
  33. medagentrx/io/tables.py +213 -0
  34. medagentrx/io/text.py +26 -0
  35. medagentrx/ledger.py +601 -0
  36. medagentrx/plan/__init__.py +19 -0
  37. medagentrx/plan/conditions.py +256 -0
  38. medagentrx/plan/fingerprint.py +26 -0
  39. medagentrx/plan/loader.py +127 -0
  40. medagentrx/plan/logic.py +71 -0
  41. medagentrx/plan/models.py +437 -0
  42. medagentrx/plan/schema.py +32 -0
  43. medagentrx/plan/template.py +123 -0
  44. medagentrx/py.typed +0 -0
  45. medagentrx/render_plan.py +373 -0
  46. medagentrx/report/__init__.py +3 -0
  47. medagentrx/report/build.py +151 -0
  48. medagentrx/report/figures.py +320 -0
  49. medagentrx/report/html.py +80 -0
  50. medagentrx/report/json_report.py +24 -0
  51. medagentrx/report/junit.py +52 -0
  52. medagentrx/report/markdown.py +44 -0
  53. medagentrx/report/report.schema.json +86 -0
  54. medagentrx/report/templates/report.html.j2 +118 -0
  55. medagentrx/report/writer.py +63 -0
  56. medagentrx/safe_yaml.py +137 -0
  57. medagentrx/signing.py +133 -0
  58. medagentrx/slack.py +183 -0
  59. medagentrx/stats/__init__.py +7 -0
  60. medagentrx/stats/_common.py +126 -0
  61. medagentrx/stats/alpha.py +91 -0
  62. medagentrx/stats/bootstrap.py +180 -0
  63. medagentrx/stats/calibration.py +204 -0
  64. medagentrx/stats/delong.py +230 -0
  65. medagentrx/stats/metrics.py +50 -0
  66. medagentrx/stats/multiplicity.py +94 -0
  67. medagentrx/stats/net_benefit.py +93 -0
  68. medagentrx/stats/power.py +159 -0
  69. medagentrx/stats/proportions.py +182 -0
  70. medagentrx/stats/smd.py +56 -0
  71. medagentrx/web/__init__.py +3 -0
  72. medagentrx/web/app.py +202 -0
  73. medagentrx/web/static/style.css +22 -0
  74. medagentrx/web/templates/base.html +22 -0
  75. medagentrx/web/templates/budgets.html +13 -0
  76. medagentrx/web/templates/entry.html +17 -0
  77. medagentrx/web/templates/overview.html +39 -0
  78. medagentrx/web/templates/timeline.html +12 -0
  79. medagentrx-0.1.0.dist-info/METADATA +201 -0
  80. medagentrx-0.1.0.dist-info/RECORD +84 -0
  81. medagentrx-0.1.0.dist-info/WHEEL +4 -0
  82. medagentrx-0.1.0.dist-info/entry_points.txt +3 -0
  83. medagentrx-0.1.0.dist-info/licenses/LICENSE +73 -0
  84. medagentrx-0.1.0.dist-info/licenses/NOTICE +4 -0
medagentrx/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ # SPDX-FileCopyrightText: 2026 Ahmed Hemedan
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """MedAgentRx: an executable gate for changes to clinical prediction models.
4
+
5
+ Every model update, by an engineer, a pipeline or an AI agent, is checked
6
+ against its approved change plan and receives one verdict: ACCEPTED, FAILED
7
+ or OUTSIDE_PLAN.
8
+ """
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ from medagentrx.api import CheckResult, check
13
+ from medagentrx.engine import Evaluation, Verdict, evaluate
14
+ from medagentrx.errors import InputError, MedAgentRxError, PlanError, StatsError
15
+ from medagentrx.plan import ChangePlan, load_plan
16
+
17
+ __all__ = [
18
+ "ChangePlan",
19
+ "CheckResult",
20
+ "Evaluation",
21
+ "InputError",
22
+ "MedAgentRxError",
23
+ "PlanError",
24
+ "StatsError",
25
+ "Verdict",
26
+ "__version__",
27
+ "check",
28
+ "evaluate",
29
+ "load_plan",
30
+ ]
medagentrx/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-FileCopyrightText: 2026 Ahmed Hemedan
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Allow ``python -m medagentrx``."""
4
+
5
+ from medagentrx.cli import run
6
+
7
+ if __name__ == "__main__":
8
+ run()
medagentrx/api.py ADDED
@@ -0,0 +1,109 @@
1
+ # SPDX-FileCopyrightText: 2026 Ahmed Hemedan
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Python API: check one update and record it, as ``medagentrx check`` does.
4
+
5
+ from medagentrx import check, Verdict
6
+
7
+ result = check(plan="change-plan.yaml", candidate="cand.parquet",
8
+ previous="prev.parquet", anchor="anchor.parquet",
9
+ manifest="train_manifest.parquet", model_meta="model.yaml",
10
+ ledger="ledger.jsonl", out="report/")
11
+ assert result.verdict is Verdict.ACCEPTED
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Iterable
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from medagentrx.engine import Evaluation, Verdict, evaluate
22
+ from medagentrx.errors import PlanError
23
+ from medagentrx.io.limits import Limits
24
+ from medagentrx.ledger import append, exclusive, load_history, read_entries, signing_policy
25
+ from medagentrx.plan import load_plan
26
+ from medagentrx.report.writer import FORMATS, write_reports
27
+ from medagentrx.stats.bootstrap import DEFAULT_REPLICATES
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CheckResult:
32
+ """Outcome of ``check``.
33
+
34
+ Attributes:
35
+ evaluation: Every finding and number behind the verdict.
36
+ ledger_entry: The entry written to the ledger, or None for an invalid plan.
37
+ reports: Written report files by format.
38
+ """
39
+
40
+ evaluation: Evaluation
41
+ ledger_entry: dict[str, Any] | None
42
+ reports: dict[str, Path] = field(default_factory=dict)
43
+
44
+ @property
45
+ def verdict(self) -> Verdict:
46
+ """ACCEPTED, FAILED or OUTSIDE_PLAN."""
47
+ return self.evaluation.verdict
48
+
49
+ @property
50
+ def exit_code(self) -> int:
51
+ """0 ACCEPTED, 1 FAILED, 2 OUTSIDE_PLAN."""
52
+ return int(self.evaluation.exit_code)
53
+
54
+
55
+ def check(
56
+ *,
57
+ plan: str | Path,
58
+ candidate: str | Path,
59
+ previous: str | Path,
60
+ anchor: str | Path,
61
+ manifest: str | Path,
62
+ model_meta: str | Path,
63
+ ledger: str | Path,
64
+ out: str | Path,
65
+ seed: int = 0,
66
+ n_replicates: int = DEFAULT_REPLICATES,
67
+ formats: Iterable[str] = tuple(FORMATS),
68
+ limits: Limits | None = None,
69
+ signing_key: str | Path | None = None,
70
+ ) -> CheckResult:
71
+ """Check one update against its plan, record it in the ledger and write reports.
72
+
73
+ The ledger is locked from reading its budgets until the new entry is
74
+ written. An invalid plan gives OUTSIDE_PLAN and is not recorded. When the
75
+ plan names a ledger signing key, ``signing_key`` must be that private key;
76
+ the existing head is verified with it and the new head is signed.
77
+
78
+ Raises:
79
+ InputError: If an input file other than the plan is missing or invalid.
80
+ LedgerError: If the ledger fails verification.
81
+ """
82
+ ledger_path = Path(ledger)
83
+ try:
84
+ change_plan = load_plan(plan)
85
+ except PlanError:
86
+ change_plan = None
87
+ key = signing_policy(change_plan, Path(signing_key) if signing_key is not None else None)
88
+ public_key = key.public_key() if key is not None else None
89
+ ledger_path.parent.mkdir(parents=True, exist_ok=True)
90
+ with exclusive(ledger_path):
91
+ evaluation = evaluate(
92
+ plan=plan,
93
+ candidate=candidate,
94
+ previous=previous,
95
+ anchor=anchor,
96
+ manifest=manifest,
97
+ model_meta=model_meta,
98
+ history=load_history(ledger_path, public_key=public_key),
99
+ seed=seed,
100
+ n_replicates=n_replicates,
101
+ limits=limits,
102
+ )
103
+ entry = None
104
+ if evaluation.plan_id is not None:
105
+ entry = append(ledger_path, evaluation, already_locked=True, signing_key=key)
106
+ reports = write_reports(
107
+ Path(out), evaluation, formats=formats, entry=entry, entries=read_entries(ledger_path)
108
+ )
109
+ return CheckResult(evaluation=evaluation, ledger_entry=entry, reports=reports)
medagentrx/card.py ADDED
@@ -0,0 +1,152 @@
1
+ # SPDX-FileCopyrightText: 2026 Ahmed Hemedan
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Model card sections built from an accepted report.
4
+
5
+ Each version gets one Markdown section between markers. Running the command
6
+ again for the same version replaces that section, so the card never holds
7
+ two copies. Only accepted versions are written, because only they may be
8
+ released.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from functools import cache
16
+ from importlib import resources
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import jsonschema
21
+
22
+ from medagentrx.errors import InputError
23
+ from medagentrx.io.paths import resolve_input
24
+ from medagentrx.io.text import read_text_limited
25
+ from medagentrx.plan import plan_fingerprint
26
+ from medagentrx.plan.models import ChangePlan
27
+ from medagentrx.report.build import check
28
+ from medagentrx.report.figures import signed
29
+
30
+ MAX_REPORT_BYTES = 50_000_000
31
+
32
+
33
+ @cache
34
+ def report_schema() -> dict[str, Any]:
35
+ """The published JSON Schema of ``report.json``."""
36
+ text = (resources.files("medagentrx.report") / "report.schema.json").read_text(encoding="utf-8")
37
+ schema: dict[str, Any] = json.loads(text)
38
+ return schema
39
+
40
+
41
+ def _reject_constant(name: str) -> None:
42
+ raise ValueError(f"non-standard JSON constant {name}")
43
+
44
+
45
+ def load_report(path: str | Path) -> dict[str, Any]:
46
+ """Read and validate a ``report.json``.
47
+
48
+ Raises:
49
+ InputError: If the file is unsafe, not JSON, or does not match the schema.
50
+ """
51
+ real = resolve_input(path)
52
+ text = read_text_limited(real, max_bytes=MAX_REPORT_BYTES)
53
+ try:
54
+ data = json.loads(text, parse_constant=_reject_constant)
55
+ except (json.JSONDecodeError, ValueError) as exc:
56
+ raise InputError(f"{path}: not valid JSON ({exc})") from None
57
+ errors = sorted(jsonschema.Draft202012Validator(report_schema()).iter_errors(data), key=str)
58
+ if errors:
59
+ raise InputError(
60
+ f"{path}: not a MedAgentRx report",
61
+ [
62
+ f"{'.'.join(map(str, e.absolute_path)) or '<root>'}: {e.message}"
63
+ for e in errors[:10]
64
+ ],
65
+ )
66
+ result: dict[str, Any] = data
67
+ return result
68
+
69
+
70
+ def _markers(version: str) -> tuple[str, str]:
71
+ return (
72
+ f"<!-- medagentrx:begin version={version} -->",
73
+ f"<!-- medagentrx:end version={version} -->",
74
+ )
75
+
76
+
77
+ def card_section(report: dict[str, Any]) -> str:
78
+ """Markdown section for the report's candidate version."""
79
+ card = check(report, "DOC-001")["details"].get("card")
80
+ if card is None:
81
+ raise InputError("the report has no model card data (DOC-001 did not run)")
82
+ version = report["candidate_version"]
83
+ begin, end = _markers(version)
84
+ low, high = card["auroc_ci"]
85
+ rows = [
86
+ ("AUROC (95% CI)", f"{card['auroc']:.3f} ({low:.3f} to {high:.3f})"),
87
+ ("Calibration slope", f"{card['calibration_slope']:.3f}"),
88
+ ("Calibration intercept", signed(card["calibration_intercept"], 3)),
89
+ ("Brier score", f"{card['brier']:.4f}"),
90
+ (f"Sensitivity at threshold {card['threshold']:g}", f"{card['sensitivity']:.3f}"),
91
+ (f"Specificity at threshold {card['threshold']:g}", f"{card['specificity']:.3f}"),
92
+ ("Locked test set", f"{card['n']} rows, {card['n_pos']} positive"),
93
+ ]
94
+ for name, test in check(report, "PERF-001")["details"].get("tests", {}).items():
95
+ label = name.replace("vs_", "AUROC change against ")
96
+ rows.append(
97
+ (label, f"{signed(test['delta'], 4)} (lower bound {signed(test['ci_lower'], 4)})")
98
+ )
99
+ ledger = report.get("ledger") or {}
100
+ entry = f" Ledger entry {ledger['seq']} ({ledger['hash'][:16]})." if ledger.get("hash") else ""
101
+ lines = [
102
+ begin,
103
+ f"## Version {version}",
104
+ "",
105
+ (
106
+ f"Accepted under plan {report['plan']['id']} revision {report['plan']['revision']} "
107
+ f"as modification {report['modification_id']}, replacing {report['previous_version']}. "
108
+ f"Evaluated {report['evaluated_at'][:10]}.{entry}"
109
+ ),
110
+ "",
111
+ "| Measure | Value |",
112
+ "|---|---|",
113
+ *(f"| {k} | {v} |" for k, v in rows),
114
+ "",
115
+ end,
116
+ ]
117
+ return "\n".join(lines) + "\n"
118
+
119
+
120
+ def update_card(card_path: Path, plan: ChangePlan, report: dict[str, Any]) -> Path:
121
+ """Insert or replace the report's version section in the model card.
122
+
123
+ Raises:
124
+ InputError: If the report is not accepted, belongs to another plan, or
125
+ the card path is unsafe.
126
+ """
127
+ if report["verdict"] != "ACCEPTED":
128
+ raise InputError(
129
+ f"only accepted versions go on the model card; this report is {report['verdict']}"
130
+ )
131
+ if report["plan"]["fingerprint"] != plan_fingerprint(plan):
132
+ raise InputError("the report was produced under a different plan or plan revision")
133
+ if card_path.is_symlink() or (card_path.exists() and not card_path.is_file()):
134
+ raise InputError(f"{card_path}: the model card must be a regular file")
135
+ section = card_section(report)
136
+ begin, end = _markers(report["candidate_version"])
137
+ if card_path.exists():
138
+ text = card_path.read_text(encoding="utf-8")
139
+ else:
140
+ text = (
141
+ f"# Model card: {plan.plan.device}\n\n"
142
+ "Performance of each released version on the locked test set, "
143
+ "recorded by MedAgentRx.\n\n"
144
+ )
145
+ pattern = re.compile(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", re.S)
146
+ if pattern.search(text):
147
+ text = pattern.sub(lambda _: section, text, count=1)
148
+ else:
149
+ text = text.rstrip("\n") + "\n\n" + section
150
+ card_path.parent.mkdir(parents=True, exist_ok=True)
151
+ card_path.write_text(text, encoding="utf-8")
152
+ return card_path
@@ -0,0 +1,14 @@
1
+ # SPDX-FileCopyrightText: 2026 Ahmed Hemedan
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Check families. Importing this package registers every check."""
4
+
5
+ from medagentrx.checks import ( # noqa: F401
6
+ data_checks,
7
+ doc_checks,
8
+ life_checks,
9
+ perf_checks,
10
+ plan_checks,
11
+ )
12
+ from medagentrx.checks.registry import REGISTRY, Breaks, Family, Finding, Status, catalogue
13
+
14
+ __all__ = ["REGISTRY", "Breaks", "Family", "Finding", "Status", "catalogue"]
@@ -0,0 +1,111 @@
1
+ # SPDX-FileCopyrightText: 2026 Ahmed Hemedan
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Everything the checks read, gathered once per evaluation."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import datetime as dt
8
+ from dataclasses import dataclass, field
9
+ from functools import cached_property
10
+
11
+ from medagentrx.history import History
12
+ from medagentrx.io.manifest import Manifest
13
+ from medagentrx.io.metadata import ModelMeta
14
+ from medagentrx.io.predictions import Predictions, RowMismatch
15
+ from medagentrx.plan.models import ChangePlan
16
+ from medagentrx.stats.alpha import AlphaState
17
+ from medagentrx.stats.delong import AucCovariance, delong
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Versions:
22
+ """Candidate, previous and anchor predictions aligned on the same rows."""
23
+
24
+ candidate: Predictions
25
+ previous: Predictions
26
+ anchor: Predictions
27
+
28
+
29
+ @dataclass
30
+ class Context:
31
+ """Inputs and shared results for one evaluation.
32
+
33
+ Attributes:
34
+ plan: The validated change plan.
35
+ plan_fingerprint: Fingerprint of the plan.
36
+ meta: Candidate model metadata.
37
+ candidate: Candidate predictions as loaded.
38
+ test_fingerprint: Fingerprint of the candidate's test rows and labels.
39
+ versions: Aligned predictions, or None if rows or labels differ.
40
+ mismatches: Row mismatch per comparator file, empty when aligned.
41
+ manifest: Training data manifest.
42
+ manifest_sha256: SHA-256 of the manifest file.
43
+ prediction_sha256: SHA-256 of the candidate, previous and anchor files, by role.
44
+ test_features: Feature columns present in both the manifest and the test file.
45
+ history: Budgets used by earlier evaluations.
46
+ alpha_state: Alpha accounting before this evaluation.
47
+ alpha: One-sided level for this evaluation, or None if the budget is exhausted.
48
+ seed: Seed for every resampling step.
49
+ n_replicates: Bootstrap replicates.
50
+ now: Time of the evaluation.
51
+ """
52
+
53
+ plan: ChangePlan
54
+ plan_fingerprint: str
55
+ meta: ModelMeta
56
+ candidate: Predictions
57
+ test_fingerprint: str
58
+ versions: Versions | None
59
+ mismatches: dict[str, RowMismatch]
60
+ manifest: Manifest
61
+ manifest_sha256: str
62
+ prediction_sha256: dict[str, str]
63
+ test_features: dict[str, tuple[str, ...]]
64
+ history: History
65
+ alpha_state: AlphaState
66
+ alpha: float | None
67
+ seed: int
68
+ n_replicates: int
69
+ now: dt.datetime
70
+ notes: list[str] = field(default_factory=list)
71
+ outside_plan_failures: list[str] = field(default_factory=list)
72
+
73
+ @property
74
+ def test_set_is_locked(self) -> bool:
75
+ """True when the candidate's test rows match the plan's fingerprint."""
76
+ return self.test_fingerprint == self.plan.data_management.locked_test_set.sha256
77
+
78
+ @property
79
+ def performance_blocker(self) -> str | None:
80
+ """Why performance cannot be assessed, or None when it can.
81
+
82
+ An update already outside the plan is not evaluated on the locked
83
+ test set, so it spends no look.
84
+ """
85
+ if self.outside_plan_failures:
86
+ return f"the update is outside the plan ({', '.join(self.outside_plan_failures)})"
87
+ if not self.test_set_is_locked:
88
+ return "the test set is not the locked test set (DATA-001)"
89
+ if self.versions is None:
90
+ return "prediction files do not share the same rows and labels (DATA-001)"
91
+ if self.alpha is None:
92
+ return "the alpha budget is exhausted (LIFE-002)"
93
+ return None
94
+
95
+ def require_versions(self) -> Versions:
96
+ """Aligned predictions. Only call after ``performance_blocker`` is None."""
97
+ if self.versions is None: # pragma: no cover # guarded by performance_blocker
98
+ raise RuntimeError("predictions are not aligned")
99
+ return self.versions
100
+
101
+ def require_alpha(self) -> float:
102
+ """Level for this evaluation. Only call after ``performance_blocker`` is None."""
103
+ if self.alpha is None: # pragma: no cover # guarded by performance_blocker
104
+ raise RuntimeError("alpha budget exhausted")
105
+ return self.alpha
106
+
107
+ @cached_property
108
+ def aucs(self) -> AucCovariance:
109
+ """DeLong AUROC and covariance of candidate, previous and anchor, in that order."""
110
+ v = self.require_versions()
111
+ return delong(v.candidate.y_true, v.candidate.y_score, v.previous.y_score, v.anchor.y_score)
@@ -0,0 +1,265 @@
1
+ # SPDX-FileCopyrightText: 2026 Ahmed Hemedan
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """DATA checks: was the update trained and tested on the data the plan allows?"""
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ from collections.abc import Sequence
9
+
10
+ import numpy as np
11
+
12
+ from medagentrx.checks.context import Context
13
+ from medagentrx.checks.registry import Breaks, Family, Finding, Status, check
14
+ from medagentrx.io.predictions import ROW_ID
15
+ from medagentrx.plan.conditions import evaluate_columns
16
+ from medagentrx.stats.smd import SMD_WARNING, categorical_smd, numeric_smd
17
+
18
+ EXAMPLES = 5
19
+ MAX_LEVELS = 50
20
+ """Categorical columns with more levels are identifiers, not features, and are skipped."""
21
+
22
+
23
+ @check(
24
+ "DATA-001",
25
+ Family.DATA,
26
+ "Locked test set is unchanged",
27
+ Breaks.OUTSIDE_PLAN,
28
+ "Modification Protocol, data management",
29
+ )
30
+ def locked_test_set(ctx: Context) -> Finding:
31
+ """Fingerprint must match the plan, and all versions must share rows and labels."""
32
+ planned = ctx.plan.data_management.locked_test_set.sha256
33
+ details: dict[str, object] = {
34
+ "plan_fingerprint": planned,
35
+ "candidate_fingerprint": ctx.test_fingerprint,
36
+ "rows": ctx.candidate.n,
37
+ "mismatches": {name: m.describe() for name, m in ctx.mismatches.items()},
38
+ }
39
+ if not ctx.test_set_is_locked:
40
+ return Finding(
41
+ "DATA-001",
42
+ Status.FAIL,
43
+ "Test set fingerprint does not match the plan; rows or labels have changed",
44
+ details,
45
+ )
46
+ if ctx.mismatches:
47
+ names = ", ".join(sorted(ctx.mismatches))
48
+ return Finding(
49
+ "DATA-001",
50
+ Status.FAIL,
51
+ f"Prediction files for {names} do not have the same rows and labels as the candidate",
52
+ details,
53
+ )
54
+ return Finding(
55
+ "DATA-001", Status.PASS, f"Locked test set matches ({ctx.candidate.n} rows)", details
56
+ )
57
+
58
+
59
+ def _test_keys(ctx: Context, key: str) -> set[str]:
60
+ if key == ROW_ID:
61
+ return set(ctx.candidate.row_id.tolist())
62
+ return set(ctx.candidate.subgroups[key].tolist())
63
+
64
+
65
+ def _train_keys(ctx: Context, key: str) -> set[str]:
66
+ manifest = ctx.manifest
67
+ if key == ROW_ID:
68
+ return set(manifest.row_id)
69
+ if key == "patient_id":
70
+ return set(manifest.patient_id)
71
+ return {str(v) for v in manifest.columns[key] if v is not None}
72
+
73
+
74
+ @check(
75
+ "DATA-002",
76
+ Family.DATA,
77
+ "No overlap between training and test data",
78
+ Breaks.FAILED,
79
+ "Data management; test data sequestration",
80
+ )
81
+ def no_overlap(ctx: Context) -> Finding:
82
+ """No value of a ``forbid_overlap_on`` key may occur in both training and test data."""
83
+ overlaps: dict[str, dict[str, object]] = {}
84
+ for key in ctx.plan.data_management.forbid_overlap_on:
85
+ shared = sorted(_test_keys(ctx, key) & _train_keys(ctx, key))
86
+ if shared:
87
+ overlaps[key] = {"count": len(shared), "examples": shared[:EXAMPLES]}
88
+ details = {"keys": list(ctx.plan.data_management.forbid_overlap_on), "overlaps": overlaps}
89
+ if overlaps:
90
+ summary = "; ".join(f"{k}: {v['count']}" for k, v in overlaps.items())
91
+ return Finding(
92
+ "DATA-002", Status.FAIL, f"Training data overlaps the test set ({summary})", details
93
+ )
94
+ return Finding(
95
+ "DATA-002", Status.PASS, "No training row shares a key with the test set", details
96
+ )
97
+
98
+
99
+ @check(
100
+ "DATA-003",
101
+ Family.DATA,
102
+ "Every training row meets the inclusion criteria",
103
+ Breaks.OUTSIDE_PLAN,
104
+ "Data management; AI Act Art. 10",
105
+ )
106
+ def training_inclusion(ctx: Context) -> Finding:
107
+ """Every manifest row must satisfy ``training_inclusion``.
108
+
109
+ The manifest must also be the one named in the model metadata, or the
110
+ training data cannot be verified.
111
+ """
112
+ manifest = ctx.manifest
113
+ if ctx.manifest_sha256 != ctx.meta.training_manifest_sha256:
114
+ return Finding(
115
+ "DATA-003",
116
+ Status.FAIL,
117
+ "Training manifest does not match training_manifest_sha256 in the model metadata",
118
+ {"manifest_sha256": ctx.manifest_sha256, "metadata": ctx.meta.training_manifest_sha256},
119
+ )
120
+ condition = ctx.plan.data_management.training_inclusion
121
+ meets = evaluate_columns(condition, manifest.columns, manifest.n)
122
+ failing = [manifest.row_id[i] for i in np.flatnonzero(~meets)]
123
+ details = {"rows": manifest.n, "failing": len(failing), "examples": failing[:EXAMPLES]}
124
+ if failing:
125
+ return Finding(
126
+ "DATA-003",
127
+ Status.FAIL,
128
+ f"{len(failing)} of {manifest.n} training rows do not meet the inclusion criteria",
129
+ details,
130
+ )
131
+ return Finding(
132
+ "DATA-003",
133
+ Status.PASS,
134
+ f"All {manifest.n} training rows meet the inclusion criteria",
135
+ details,
136
+ )
137
+
138
+
139
+ @check(
140
+ "DATA-004",
141
+ Family.DATA,
142
+ "Test-set evaluation budget is not exceeded",
143
+ Breaks.OUTSIDE_PLAN,
144
+ "Test data sequestration; adaptive overfitting (Dwork et al. 2015)",
145
+ )
146
+ def evaluation_budget(ctx: Context) -> Finding:
147
+ """This look must fit inside ``max_evaluations``."""
148
+ limit = ctx.plan.data_management.locked_test_set.max_evaluations
149
+ used = ctx.history.looks_used
150
+ details = {"looks_used_before": used, "max_evaluations": limit, "this_look": used + 1}
151
+ if used >= limit:
152
+ return Finding(
153
+ "DATA-004",
154
+ Status.FAIL,
155
+ f"All {limit} looks at the locked test set have been used",
156
+ details,
157
+ )
158
+ return Finding(
159
+ "DATA-004", Status.PASS, f"Look {used + 1} of {limit} at the locked test set", details
160
+ )
161
+
162
+
163
+ def _numeric(values: Sequence[object]) -> list[float] | None:
164
+ out: list[float] = []
165
+ for value in values:
166
+ if isinstance(value, bool):
167
+ return None
168
+ if isinstance(value, int | float):
169
+ out.append(float(value))
170
+ continue
171
+ try:
172
+ number = float(str(value))
173
+ except ValueError:
174
+ return None
175
+ if not math.isfinite(number):
176
+ return None
177
+ out.append(number)
178
+ return out
179
+
180
+
181
+ @check(
182
+ "DATA-005",
183
+ Family.DATA,
184
+ "Population shift between training and test data",
185
+ Breaks.WARNING,
186
+ "Evidence-based principle",
187
+ )
188
+ def population_shift(ctx: Context) -> Finding:
189
+ """Warn when a shared feature has |SMD| above 0.1 between training and test data."""
190
+ if not ctx.test_features:
191
+ return Finding(
192
+ "DATA-005",
193
+ Status.NOT_APPLICABLE,
194
+ "No feature columns shared by training and test files",
195
+ )
196
+ smds: dict[str, float] = {}
197
+ too_many_levels: list[str] = []
198
+ for name, test_values in ctx.test_features.items():
199
+ train = [v for v in ctx.manifest.columns[name] if v is not None and v != ""]
200
+ test = [v for v in test_values if v != ""]
201
+ if len(train) < 2 or len(test) < 2:
202
+ continue
203
+ train_num, test_num = _numeric(train), _numeric(test)
204
+ if train_num is not None and test_num is not None:
205
+ smds[name] = numeric_smd(train_num, test_num)
206
+ else:
207
+ train_text, test_text = [str(v) for v in train], [str(v) for v in test]
208
+ if len(set(train_text) | set(test_text)) > MAX_LEVELS:
209
+ too_many_levels.append(name)
210
+ continue
211
+ smds[name] = categorical_smd(train_text, test_text)
212
+ shifted = {k: v for k, v in smds.items() if abs(v) > SMD_WARNING}
213
+ details = {
214
+ "smd": smds,
215
+ "threshold": SMD_WARNING,
216
+ "shifted": sorted(shifted),
217
+ "skipped_high_cardinality": too_many_levels,
218
+ }
219
+ if shifted:
220
+ listed = ", ".join(f"{k} ({v:+.2f})" for k, v in sorted(shifted.items()))
221
+ return Finding(
222
+ "DATA-005", Status.WARN, f"Population shift above {SMD_WARNING}: {listed}", details
223
+ )
224
+ return Finding(
225
+ "DATA-005", Status.PASS, f"No feature shifts by more than {SMD_WARNING} SMD", details
226
+ )
227
+
228
+
229
+ @check(
230
+ "DATA-006",
231
+ Family.DATA,
232
+ "Prediction files are the ones the models produced",
233
+ Breaks.OUTSIDE_PLAN,
234
+ "Traceability; Modification Protocol, update procedures",
235
+ )
236
+ def prediction_provenance(ctx: Context) -> Finding:
237
+ """Bind each prediction file to its model.
238
+
239
+ The candidate file must match ``predictions_sha256`` in its metadata. The
240
+ previous file must match the predictions recorded when that version was
241
+ released, if the ledger holds them. The anchor file must match
242
+ ``anchor_predictions_sha256`` in the plan, if the plan records it.
243
+ """
244
+ actual = ctx.prediction_sha256
245
+ expected: dict[str, str | None] = {
246
+ "candidate": ctx.meta.predictions_sha256,
247
+ "previous": ctx.history.released_predictions.get(ctx.meta.previous_version),
248
+ "anchor": ctx.plan.anchor_predictions_sha256,
249
+ }
250
+ mismatched = [
251
+ role for role, want in expected.items() if want is not None and actual[role] != want
252
+ ]
253
+ unchecked = [role for role, want in expected.items() if want is None]
254
+ details = {"expected": expected, "actual": actual, "not_bound": unchecked}
255
+ if mismatched:
256
+ return Finding(
257
+ "DATA-006",
258
+ Status.FAIL,
259
+ "Prediction file(s) differ from the recorded model output: " + ", ".join(mismatched),
260
+ details,
261
+ )
262
+ text = "Prediction files match the recorded model outputs"
263
+ if unchecked:
264
+ text += f" ({', '.join(unchecked)} not yet bound)"
265
+ return Finding("DATA-006", Status.PASS, text, details)