modelcertify 0.1.0__tar.gz → 0.2.0__tar.gz

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 (25) hide show
  1. {modelcertify-0.1.0/src/modelcertify.egg-info → modelcertify-0.2.0}/PKG-INFO +9 -1
  2. {modelcertify-0.1.0 → modelcertify-0.2.0}/README.md +8 -0
  3. {modelcertify-0.1.0 → modelcertify-0.2.0}/pyproject.toml +1 -1
  4. {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify/cli.py +72 -0
  5. modelcertify-0.2.0/src/modelcertify/eval.py +302 -0
  6. modelcertify-0.2.0/src/modelcertify/scoring.py +220 -0
  7. {modelcertify-0.1.0 → modelcertify-0.2.0/src/modelcertify.egg-info}/PKG-INFO +9 -1
  8. {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify.egg-info/SOURCES.txt +5 -1
  9. modelcertify-0.2.0/tests/test_eval.py +119 -0
  10. modelcertify-0.2.0/tests/test_scoring.py +76 -0
  11. {modelcertify-0.1.0 → modelcertify-0.2.0}/LICENSE +0 -0
  12. {modelcertify-0.1.0 → modelcertify-0.2.0}/setup.cfg +0 -0
  13. {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify/__init__.py +0 -0
  14. {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify/attestation.py +0 -0
  15. {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify/certify.py +0 -0
  16. {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify/proof.py +0 -0
  17. {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify/py.typed +0 -0
  18. {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify.egg-info/dependency_links.txt +0 -0
  19. {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify.egg-info/entry_points.txt +0 -0
  20. {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify.egg-info/requires.txt +0 -0
  21. {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify.egg-info/top_level.txt +0 -0
  22. {modelcertify-0.1.0 → modelcertify-0.2.0}/tests/test_attestation.py +0 -0
  23. {modelcertify-0.1.0 → modelcertify-0.2.0}/tests/test_certify.py +0 -0
  24. {modelcertify-0.1.0 → modelcertify-0.2.0}/tests/test_cli.py +0 -0
  25. {modelcertify-0.1.0 → modelcertify-0.2.0}/tests/test_proof.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modelcertify
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Supply-chain certification for trained models
5
5
  License: Apache-2.0
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -38,6 +38,13 @@ makes that run auditable and certifies it against a written policy.
38
38
  * A **certificate** is a policy evaluated against a run's evidence: it
39
39
  records every gate, how each one came out, the proof root it is bound to,
40
40
  and the issuer who signed it.
41
+ * An **evaluation suite** (P4.x) is a private set of scored cases. Running it
42
+ produces a report whose **suite digest** is committed to the proof, so a
43
+ policy can gate on exactly which suite was used without shipping the suite.
44
+ The runner supports two modes: `quick` (deterministic subsample, for
45
+ iteration) and `full` (every case, for the attestable result). Scoring is a
46
+ dependency-free port of the decision-index primitives (accuracy, macro/binary
47
+ F1, NDCG@10, MRR, chance baselines).
41
48
 
42
49
  The certificate never overstates. It carries the gate results, so a reader can
43
50
  see *why* something was certified, not just that it was.
@@ -70,6 +77,7 @@ modelcertify policy --file policy.json --json
70
77
  modelcertify certify --evidence evidence.json --policy policy.json \
71
78
  --issuer scai --key "$MODELCERTIFY_KEY" --out certificate.json
72
79
  modelcertify verify --certificate certificate.json --key "$MODELCERTIFY_KEY"
80
+ modelcertify eval --suite suite.json --mode full --run-id run-1 --out certificate.json
73
81
  ```
74
82
 
75
83
  Every command accepts `--json`.
@@ -13,6 +13,13 @@ makes that run auditable and certifies it against a written policy.
13
13
  * A **certificate** is a policy evaluated against a run's evidence: it
14
14
  records every gate, how each one came out, the proof root it is bound to,
15
15
  and the issuer who signed it.
16
+ * An **evaluation suite** (P4.x) is a private set of scored cases. Running it
17
+ produces a report whose **suite digest** is committed to the proof, so a
18
+ policy can gate on exactly which suite was used without shipping the suite.
19
+ The runner supports two modes: `quick` (deterministic subsample, for
20
+ iteration) and `full` (every case, for the attestable result). Scoring is a
21
+ dependency-free port of the decision-index primitives (accuracy, macro/binary
22
+ F1, NDCG@10, MRR, chance baselines).
16
23
 
17
24
  The certificate never overstates. It carries the gate results, so a reader can
18
25
  see *why* something was certified, not just that it was.
@@ -45,6 +52,7 @@ modelcertify policy --file policy.json --json
45
52
  modelcertify certify --evidence evidence.json --policy policy.json \
46
53
  --issuer scai --key "$MODELCERTIFY_KEY" --out certificate.json
47
54
  modelcertify verify --certificate certificate.json --key "$MODELCERTIFY_KEY"
55
+ modelcertify eval --suite suite.json --mode full --run-id run-1 --out certificate.json
48
56
  ```
49
57
 
50
58
  Every command accepts `--json`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "modelcertify"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Supply-chain certification for trained models"
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -6,6 +6,7 @@ Subcommands:
6
6
  ``policy`` show a certification policy
7
7
  ``certify`` evaluate a policy against a run's evidence
8
8
  ``verify`` verify a certificate's signature
9
+ ``eval`` run a private evaluation suite and certify the report
9
10
 
10
11
  Every command supports ``--json``.
11
12
  """
@@ -20,6 +21,7 @@ from pathlib import Path
20
21
  from typing import Any
21
22
 
22
23
  from .certify import Certificate, Policy, certify, load_evidence
24
+ from .eval import EvalRunner, load_suite
23
25
  from .proof import Artifact, RunProof
24
26
 
25
27
  __all__ = ["build_parser", "main"]
@@ -76,6 +78,28 @@ def build_parser() -> argparse.ArgumentParser:
76
78
  verify.add_argument("--key", help="signing key (hex)")
77
79
  verify.add_argument("--json", action="store_true", help="emit JSON on stdout")
78
80
 
81
+ eval_cmd = sub.add_parser("eval", help="run a suite and certify the report")
82
+ eval_cmd.add_argument("--suite", required=True, help="suite JSON file")
83
+ eval_cmd.add_argument(
84
+ "--mode",
85
+ default="full",
86
+ choices=["quick", "full"],
87
+ help="quick = deterministic subsample; full = every case",
88
+ )
89
+ eval_cmd.add_argument("--metric", default="accuracy", help="report metric")
90
+ eval_cmd.add_argument(
91
+ "--model",
92
+ default="identity",
93
+ choices=["identity", "always-yes"],
94
+ help="built-in model function (for CLI demos)",
95
+ )
96
+ eval_cmd.add_argument("--run-id", default="run", help="run id for the proof")
97
+ eval_cmd.add_argument("--issuer", default="modelcertify", help="certificate issuer")
98
+ eval_cmd.add_argument("--key", help="signing key (hex)")
99
+ eval_cmd.add_argument("--out", help="write the certificate here")
100
+ eval_cmd.add_argument("--report-out", help="write the eval report JSON here")
101
+ eval_cmd.add_argument("--json", action="store_true", help="emit JSON on stdout")
102
+
79
103
  return parser
80
104
 
81
105
 
@@ -166,6 +190,52 @@ def _cmd_verify(args: argparse.Namespace) -> int:
166
190
  return 0 if verified else 1
167
191
 
168
192
 
193
+ def _cmd_eval(args: argparse.Namespace) -> int:
194
+ suite = load_suite(args.suite)
195
+
196
+ def model_fn(question: dict[str, Any]) -> dict[str, Any]:
197
+ qtype = question.get("type", "choice")
198
+ if qtype == "noul":
199
+ return {"noul": 1.0 if args.model == "always-yes" else 0.0}
200
+ criteria = question.get("criteria") or {}
201
+ if qtype == "ranking":
202
+ return {"ranking": sorted(criteria, key=lambda k: str(k))}
203
+ first = sorted(criteria)[0] if criteria else "a"
204
+ return {"choice": first}
205
+
206
+ runner = EvalRunner(model_fn)
207
+ result = runner.run(suite, mode=args.mode, metric=args.metric)
208
+
209
+ if args.report_out:
210
+ Path(args.report_out).parent.mkdir(parents=True, exist_ok=True)
211
+ Path(args.report_out).write_text(json.dumps(result.to_dict(), indent=2) + "\n")
212
+
213
+ key = _key(args.key) if args.key or os.environ.get("MODELCERTIFY_KEY") else None
214
+ certificate = runner.certify(
215
+ suite,
216
+ result,
217
+ run_id=args.run_id,
218
+ issuer=args.issuer,
219
+ key=key,
220
+ )
221
+ if args.out:
222
+ certificate.write(args.out)
223
+
224
+ payload = {
225
+ "eval": result.to_dict(),
226
+ "certificate": certificate.to_dict(),
227
+ }
228
+ if args.json:
229
+ _emit_json(payload)
230
+ return 0 if certificate.certified else 1
231
+ print(f"suite: {suite.name} v{suite.version} digest={suite.digest[:16]}…")
232
+ print(f"mode: {result.mode} answered: {result.report.answered}/{result.report.total}")
233
+ print(f"metric: {result.report.metric} = {result.report.score:.4f} "
234
+ f"(chance {result.report.chance if result.report.chance is not None else 'n/a'})")
235
+ print(f"certified: {certificate.certified}")
236
+ return 0 if certificate.certified else 1
237
+
238
+
169
239
  def _emit_json(payload: Any) -> None:
170
240
  sys.stdout.write(json.dumps(payload, indent=2) + "\n")
171
241
 
@@ -182,6 +252,8 @@ def main(argv: Sequence[str] | None = None) -> int:
182
252
  return _cmd_certify(args)
183
253
  if args.command == "verify":
184
254
  return _cmd_verify(args)
255
+ if args.command == "eval":
256
+ return _cmd_eval(args)
185
257
  except (FileNotFoundError, ValueError, RuntimeError) as exc:
186
258
  if getattr(args, "json", False):
187
259
  _emit_json({"error": str(exc)})
@@ -0,0 +1,302 @@
1
+ """Evaluation suites and the two-mode runner (P4.2/P4.3).
2
+
3
+ A :class:`Suite` is a *private* set of evaluation cases: questions (with
4
+ gold answers) bundled into a checksummed manifest. The checksum lets a
5
+ runner prove *which* suite was used without shipping the suite itself — the
6
+ manifest digest becomes an artifact in the run proof, so a certificate's
7
+ artifact gates can require exactly the suite that was evaluated.
8
+
9
+ The :class:`EvalRunner` runs a suite in one of two modes:
10
+
11
+ * ``quick`` — a deterministic subsample of the suite (plus a mode field in
12
+ the report), for smoke checks and iteration.
13
+ * ``full`` — every case, for the attestable result.
14
+
15
+ Either way the report is JSON-serializable, can be attached to a
16
+ :class:`RunProof` as an artifact, and feeds the metric gates in
17
+ :mod:`modelcertify.certify`.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ from collections.abc import Callable
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from .proof import Artifact, RunProof
29
+ from .scoring import ScoreReport, score_answers
30
+
31
+ __all__ = [
32
+ "Case",
33
+ "Suite",
34
+ "EvalResult",
35
+ "EvalRunner",
36
+ "SuiteError",
37
+ "suite_manifest_digest",
38
+ "load_suite",
39
+ ]
40
+
41
+ QUICK_FRACTION = 0.25
42
+ QUICK_MIN = 2
43
+
44
+
45
+ class SuiteError(ValueError):
46
+ """Raised when a suite is malformed."""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Case:
51
+ """One evaluation case: a question plus its gold answer."""
52
+
53
+ field: str
54
+ question: dict[str, Any]
55
+ gold: Any
56
+ weight: float = 1.0
57
+
58
+ @classmethod
59
+ def from_dict(cls, raw: dict[str, Any]) -> Case:
60
+ return cls(
61
+ field=str(raw["field"]),
62
+ question=dict(raw["question"]),
63
+ gold=raw["gold"],
64
+ weight=float(raw.get("weight", 1.0)),
65
+ )
66
+
67
+ def to_dict(self) -> dict[str, Any]:
68
+ return {
69
+ "field": self.field,
70
+ "question": self.question,
71
+ "gold": self.gold,
72
+ "weight": self.weight,
73
+ }
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class Suite:
78
+ """A private evaluation suite."""
79
+
80
+ name: str
81
+ version: str = "1"
82
+ cases: tuple[Case, ...] = ()
83
+ metadata: dict[str, Any] = field(default_factory=dict)
84
+
85
+ @property
86
+ def digest(self) -> str:
87
+ """The manifest digest: proves which suite ran, without shipping it."""
88
+ return suite_manifest_digest(self)
89
+
90
+ @property
91
+ def questions(self) -> dict[str, dict[str, Any]]:
92
+ questions: dict[str, dict[str, Any]] = {}
93
+ for case in self.cases:
94
+ entry = dict(case.question)
95
+ entry["gold"] = case.gold
96
+ questions[case.field] = entry
97
+ return questions
98
+
99
+ @property
100
+ def golds(self) -> dict[str, Any]:
101
+ return {case.field: case.gold for case in self.cases}
102
+
103
+ def sample(self, fraction: float = QUICK_FRACTION, minimum: int = QUICK_MIN) -> Suite:
104
+ """A deterministic subsample for the ``quick`` mode."""
105
+ cases = self.cases
106
+ if not cases:
107
+ return self
108
+ size = max(minimum, int(len(cases) * fraction))
109
+ size = min(size, len(cases))
110
+ step = max(1, len(cases) // size)
111
+ chosen = cases[::step][:size]
112
+ return Suite(
113
+ name=self.name,
114
+ version=self.version,
115
+ cases=chosen,
116
+ metadata={**self.metadata, "sampled": True, "sample_size": len(chosen)},
117
+ )
118
+
119
+ def to_dict(self) -> dict[str, Any]:
120
+ return {
121
+ "name": self.name,
122
+ "version": self.version,
123
+ "digest": self.digest,
124
+ "cases": [case.to_dict() for case in self.cases],
125
+ "metadata": dict(self.metadata),
126
+ }
127
+
128
+ @classmethod
129
+ def from_dict(cls, raw: dict[str, Any]) -> Suite:
130
+ return cls(
131
+ name=str(raw["name"]),
132
+ version=str(raw.get("version", "1")),
133
+ cases=tuple(Case.from_dict(entry) for entry in raw.get("cases", [])),
134
+ metadata=dict(raw.get("metadata") or {}),
135
+ )
136
+
137
+ @classmethod
138
+ def read(cls, path: str | Path) -> Suite:
139
+ raw = json.loads(Path(path).read_text())
140
+ return cls.from_dict(raw)
141
+
142
+
143
+ def suite_manifest_digest(suite: Suite) -> str:
144
+ """A digest over the suite's cases (not its metadata)."""
145
+ payload = {
146
+ "name": suite.name,
147
+ "version": suite.version,
148
+ "cases": [case.to_dict() for case in suite.cases],
149
+ }
150
+ body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
151
+ return hashlib.sha256(body).hexdigest()
152
+
153
+
154
+ def load_suite(path: str | Path) -> Suite:
155
+ return Suite.read(path)
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class EvalResult:
160
+ """The outcome of running a suite in one mode."""
161
+
162
+ suite_name: str
163
+ suite_version: str
164
+ suite_digest: str
165
+ mode: str
166
+ report: ScoreReport
167
+ answered: int = 0
168
+ started_at: str = ""
169
+ finished_at: str = ""
170
+
171
+ def to_dict(self) -> dict[str, Any]:
172
+ return {
173
+ "suite": self.suite_name,
174
+ "suite_version": self.suite_version,
175
+ "suite_digest": self.suite_digest,
176
+ "mode": self.mode,
177
+ "answered": self.answered,
178
+ "started_at": self.started_at,
179
+ "finished_at": self.finished_at,
180
+ "report": self.report.to_dict(),
181
+ }
182
+
183
+ def as_artifact(self, name: str = "eval-report.json") -> Artifact:
184
+ return Artifact.from_bytes(
185
+ name,
186
+ json.dumps(self.to_dict()).encode(),
187
+ media_type="application/json",
188
+ )
189
+
190
+
191
+ class EvalRunner:
192
+ """Runs a suite in ``quick`` or ``full`` mode."""
193
+
194
+ def __init__(
195
+ self,
196
+ model_fn: Callable[[dict[str, Any]], dict[str, Any]],
197
+ *,
198
+ quick_fraction: float = QUICK_FRACTION,
199
+ quick_min: int = QUICK_MIN,
200
+ ) -> None:
201
+ self.model_fn = model_fn
202
+ self.quick_fraction = quick_fraction
203
+ self.quick_min = quick_min
204
+
205
+ def run(self, suite: Suite, mode: str = "full", metric: str = "accuracy") -> EvalResult:
206
+ import datetime as _dt
207
+
208
+ if mode not in ("quick", "full"):
209
+ raise ValueError("mode must be 'quick' or 'full'")
210
+ chosen = suite.sample(self.quick_fraction, self.quick_min) if mode == "quick" else suite
211
+ started = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
212
+ answers: dict[str, dict[str, Any]] = {}
213
+ answered = 0
214
+ for case in chosen.cases:
215
+ try:
216
+ answers[case.field] = dict(self.model_fn(dict(case.question)))
217
+ answered += 1
218
+ except Exception:
219
+ # A failed case is recorded as unanswered; it does not count
220
+ # as correct, so it still lowers the score.
221
+ continue
222
+ report = score_answers(chosen.questions, answers, metric=metric)
223
+ finished = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
224
+ return EvalResult(
225
+ suite_name=suite.name,
226
+ suite_version=suite.version,
227
+ suite_digest=suite.digest,
228
+ mode=mode,
229
+ report=report,
230
+ answered=answered,
231
+ started_at=started,
232
+ finished_at=finished,
233
+ )
234
+
235
+ def certify(
236
+ self,
237
+ suite: Suite,
238
+ result: EvalResult,
239
+ *,
240
+ run_id: str,
241
+ issuer: str,
242
+ key: bytes | None = None,
243
+ policy: Any | None = None,
244
+ extra_artifacts: tuple[Artifact, ...] = (),
245
+ ) -> Any:
246
+ """Attach an eval result to a run proof and certify it.
247
+
248
+ The suite digest is recorded as an artifact too, so a policy can gate
249
+ on the exact suite used. Returns the certificate.
250
+ """
251
+ from .certify import certify as _certify
252
+
253
+ artifacts = list(extra_artifacts) + [
254
+ result.as_artifact(),
255
+ Artifact.from_bytes(
256
+ "suite-manifest.json",
257
+ json.dumps(
258
+ {"name": suite.name, "version": suite.version, "digest": suite.digest}
259
+ ).encode(),
260
+ media_type="application/json",
261
+ ),
262
+ ]
263
+ proof = RunProof.build(run_id, artifacts)
264
+ from .certify import Evidence
265
+
266
+ evidence = Evidence(
267
+ proof=proof,
268
+ metrics={
269
+ "score": result.report.score,
270
+ "metric": result.report.metric,
271
+ "coverage": result.report.coverage,
272
+ "answered": result.report.answered,
273
+ "mode": result.mode,
274
+ },
275
+ provenance={
276
+ "suite": suite.name,
277
+ "suite_version": suite.version,
278
+ "suite_digest": suite.digest,
279
+ },
280
+ )
281
+ policy = policy or _default_policy(suite)
282
+ return _certify(policy, evidence, issuer=issuer, subject=run_id, key=key)
283
+
284
+
285
+ def _default_policy(suite: Suite) -> Any:
286
+ from .certify import Gate, Policy
287
+
288
+ return Policy(
289
+ policy_id="eval-baseline",
290
+ name="Evaluation baseline policy",
291
+ version="1",
292
+ required_artifacts=("eval-report.json", "suite-manifest.json"),
293
+ gates=(
294
+ Gate(name="score_above_chance", kind="metric", field="score", op=">", threshold=0.5),
295
+ Gate(
296
+ name="suite_digest_present",
297
+ kind="provenance",
298
+ field="suite_digest",
299
+ threshold=None,
300
+ ),
301
+ ),
302
+ )
@@ -0,0 +1,220 @@
1
+ """Scoring primitives for model evaluation.
2
+
3
+ This module is a dependency-free port of the scoring core used by the
4
+ decision-index benchmarks (accuracy, macro/binary F1, NDCG@10, MRR, chance
5
+ baselines). It is deliberately generic: a scorer looks at a question's
6
+ accepted answers (gold) and a model's answer and produces a per-field score,
7
+ and an aggregate report is computed over a whole run. The report is what a
8
+ certificate's metric gates can read.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import math
14
+ import statistics
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ __all__ = [
19
+ "ScoreReport",
20
+ "score_answers",
21
+ "score_choice",
22
+ "score_noul",
23
+ "score_ranking",
24
+ "macro_f1",
25
+ "binary_f1",
26
+ "chance_for",
27
+ "mean",
28
+ "avg",
29
+ ]
30
+
31
+
32
+ def mean(values: list[float]) -> float | None:
33
+ return statistics.mean(values) if values else None
34
+
35
+
36
+ def avg(values: list[float]) -> float:
37
+ return statistics.mean(values) if values else 0.0
38
+
39
+
40
+ def macro_f1(pairs: list[tuple[str, str]]) -> float | None:
41
+ """Macro-averaged F1 over the class labels seen in ``pairs``."""
42
+ labels: set[str] = set()
43
+ for gold, pred in pairs:
44
+ labels.add(gold)
45
+ labels.add(pred)
46
+ per_label: list[float] = []
47
+ for label in sorted(labels):
48
+ tp = sum(g == label and p == label for g, p in pairs)
49
+ fp = sum(g != label and p == label for g, p in pairs)
50
+ fn = sum(g == label and p != label for g, p in pairs)
51
+ denom = 2 * tp + fp + fn
52
+ per_label.append(2 * tp / denom if denom else 0.0)
53
+ return mean(per_label)
54
+
55
+
56
+ def binary_f1(pairs: list[tuple[str, str]], positive: str = "yes") -> float:
57
+ tp = sum(g == positive and p == positive for g, p in pairs)
58
+ fp = sum(g != positive and p == positive for g, p in pairs)
59
+ fn = sum(g == positive and p != positive for g, p in pairs)
60
+ denom = 2 * tp + fp + fn
61
+ return 2 * tp / denom if denom else 0.0
62
+
63
+
64
+ def chance_for(question: dict[str, Any]) -> float:
65
+ """The expected score of a random guess for a question."""
66
+ qtype = question.get("type", "choice")
67
+ if qtype == "noul":
68
+ return 0.5
69
+ criteria = question.get("criteria") or {}
70
+ n = len(criteria)
71
+ return 1.0 / n if n else 0.0
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class ScoreReport:
76
+ """Aggregate scores over one evaluation run."""
77
+
78
+ metric: str = "accuracy"
79
+ score: float = 0.0
80
+ chance: float | None = None
81
+ answered: int = 0
82
+ total: int = 0
83
+ per_field: dict[str, dict[str, Any]] = field(default_factory=dict)
84
+ details: list[dict[str, Any]] = field(default_factory=list)
85
+
86
+ @property
87
+ def coverage(self) -> float:
88
+ return self.answered / self.total if self.total else 1.0
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ return {
92
+ "metric": self.metric,
93
+ "score": self.score,
94
+ "chance": self.chance,
95
+ "answered": self.answered,
96
+ "total": self.total,
97
+ "coverage": self.coverage,
98
+ "per_field": dict(self.per_field),
99
+ "details": list(self.details),
100
+ }
101
+
102
+
103
+ def score_choice(question: dict[str, Any], answer: dict[str, Any], gold: Any) -> dict[str, Any]:
104
+ """Score a discrete-choice question."""
105
+ pred = answer.get("choice")
106
+ correct = pred == gold
107
+ return {
108
+ "correct": correct,
109
+ "pred": pred,
110
+ "gold": gold,
111
+ "chance": chance_for(question),
112
+ }
113
+
114
+
115
+ def score_noul(question: dict[str, Any], answer: dict[str, Any], gold: Any) -> dict[str, Any]:
116
+ """Score a "no-unsupported-label" (boolean) question."""
117
+ value = answer.get("noul", 0.0)
118
+ pred = value >= 0.5
119
+ return {
120
+ "correct": pred == gold,
121
+ "pred": pred,
122
+ "gold": gold,
123
+ "confidence": max(float(value), 1.0 - float(value)),
124
+ "chance": 0.5,
125
+ }
126
+
127
+
128
+ def score_ranking(question: dict[str, Any], answer: dict[str, Any], gold: Any) -> dict[str, Any]:
129
+ """Score a ranking question with NDCG@10 and MRR.
130
+
131
+ ``answer["ranking"]`` is a list of candidate ids in the model's order,
132
+ most relevant first. ``gold`` maps candidate id -> relevance grade.
133
+ """
134
+ ranking = answer.get("ranking") or []
135
+ qrels = dict(gold)
136
+ graded = [qrels.get(d, 0) for d in ranking]
137
+ ideal = sorted(qrels.values(), reverse=True)[:10]
138
+ dcg = sum((2**v - 1) / math.log2(i + 2) for i, v in enumerate(graded[:10]))
139
+ idcg = sum((2**v - 1) / math.log2(i + 2) for i, v in enumerate(ideal))
140
+ positives = sum(v > 0 for v in qrels.values())
141
+ mrr = next((1 / (i + 1) for i, v in enumerate(graded) if v > 0), 0.0)
142
+ recall10 = sum(v > 0 for v in graded[:10]) / positives if positives else 0.0
143
+ return {
144
+ "ndcg_at_10": dcg / idcg if idcg else 0.0,
145
+ "mrr": mrr,
146
+ "recall_at_10": recall10,
147
+ "chance": 0.0,
148
+ "correct": mrr > 0,
149
+ }
150
+
151
+
152
+ _SCORERS = {
153
+ "choice": score_choice,
154
+ "noul": score_noul,
155
+ "ranking": score_ranking,
156
+ }
157
+
158
+
159
+ def score_answers(
160
+ questions: dict[str, dict[str, Any]],
161
+ answers: dict[str, dict[str, Any]],
162
+ *,
163
+ metric: str = "accuracy",
164
+ ) -> ScoreReport:
165
+ """Score a model's answers against gold for a set of questions.
166
+
167
+ ``questions`` maps field key -> question dict (with ``type`` and, for
168
+ choice questions, ``criteria``). ``answers`` maps field key -> the model's
169
+ answer: ``{"choice": ...}``, ``{"noul": <probability>}`` or
170
+ ``{"ranking": [...]}``. Gold truth lives in ``question["gold"]``.
171
+ """
172
+ correct = 0
173
+ answered = 0
174
+ per_field: dict[str, dict[str, Any]] = {}
175
+ details: list[dict[str, Any]] = []
176
+ for key, question in questions.items():
177
+ if key not in answers:
178
+ per_field[key] = {"answered": False, "correct": False, "chance": chance_for(question)}
179
+ continue
180
+ qtype = question.get("type", "choice")
181
+ scorer = _SCORERS.get(qtype, score_choice)
182
+ gold = question.get("gold")
183
+ scored = scorer(question, answers[key], gold)
184
+ answered += 1
185
+ correct += 1 if scored.get("correct") else 0
186
+ per_field[key] = {"answered": True, **scored}
187
+ details.append({"field": key, **scored})
188
+ chance = mean([chance_for(question) for question in questions.values()]) if questions else None
189
+ score = correct / answered if answered else 0.0
190
+ report = ScoreReport(
191
+ metric=metric,
192
+ score=score,
193
+ chance=chance,
194
+ answered=answered,
195
+ total=len(questions),
196
+ per_field=per_field,
197
+ details=details,
198
+ )
199
+ if metric == "macro_f1":
200
+ pairs = [
201
+ (str(d["gold"]), str(d["pred"]))
202
+ for d in details
203
+ if "pred" in d and "gold" in d
204
+ ]
205
+ f1 = macro_f1(pairs)
206
+ if f1 is not None:
207
+ report = ScoreReport(
208
+ metric=metric,
209
+ score=f1,
210
+ chance=chance,
211
+ answered=answered,
212
+ total=len(questions),
213
+ per_field=per_field,
214
+ details=details,
215
+ )
216
+ return report
217
+
218
+
219
+ def report_to_json(report: ScoreReport) -> str:
220
+ return json.dumps(report.to_dict(), indent=2, sort_keys=True) + "\n"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modelcertify
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Supply-chain certification for trained models
5
5
  License: Apache-2.0
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -38,6 +38,13 @@ makes that run auditable and certifies it against a written policy.
38
38
  * A **certificate** is a policy evaluated against a run's evidence: it
39
39
  records every gate, how each one came out, the proof root it is bound to,
40
40
  and the issuer who signed it.
41
+ * An **evaluation suite** (P4.x) is a private set of scored cases. Running it
42
+ produces a report whose **suite digest** is committed to the proof, so a
43
+ policy can gate on exactly which suite was used without shipping the suite.
44
+ The runner supports two modes: `quick` (deterministic subsample, for
45
+ iteration) and `full` (every case, for the attestable result). Scoring is a
46
+ dependency-free port of the decision-index primitives (accuracy, macro/binary
47
+ F1, NDCG@10, MRR, chance baselines).
41
48
 
42
49
  The certificate never overstates. It carries the gate results, so a reader can
43
50
  see *why* something was certified, not just that it was.
@@ -70,6 +77,7 @@ modelcertify policy --file policy.json --json
70
77
  modelcertify certify --evidence evidence.json --policy policy.json \
71
78
  --issuer scai --key "$MODELCERTIFY_KEY" --out certificate.json
72
79
  modelcertify verify --certificate certificate.json --key "$MODELCERTIFY_KEY"
80
+ modelcertify eval --suite suite.json --mode full --run-id run-1 --out certificate.json
73
81
  ```
74
82
 
75
83
  Every command accepts `--json`.
@@ -5,8 +5,10 @@ src/modelcertify/__init__.py
5
5
  src/modelcertify/attestation.py
6
6
  src/modelcertify/certify.py
7
7
  src/modelcertify/cli.py
8
+ src/modelcertify/eval.py
8
9
  src/modelcertify/proof.py
9
10
  src/modelcertify/py.typed
11
+ src/modelcertify/scoring.py
10
12
  src/modelcertify.egg-info/PKG-INFO
11
13
  src/modelcertify.egg-info/SOURCES.txt
12
14
  src/modelcertify.egg-info/dependency_links.txt
@@ -16,4 +18,6 @@ src/modelcertify.egg-info/top_level.txt
16
18
  tests/test_attestation.py
17
19
  tests/test_certify.py
18
20
  tests/test_cli.py
19
- tests/test_proof.py
21
+ tests/test_eval.py
22
+ tests/test_proof.py
23
+ tests/test_scoring.py
@@ -0,0 +1,119 @@
1
+ """Tests for evaluation suites and the two-mode runner (P4.2/P4.3)."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from modelcertify.eval import Case, EvalResult, EvalRunner, Suite, load_suite, suite_manifest_digest
7
+
8
+
9
+ def make_suite(name: str = "toy", count: int = 8, gold: str = "a") -> Suite:
10
+ cases = []
11
+ for i in range(count):
12
+ cases.append(
13
+ Case(
14
+ field=f"q{i}",
15
+ question={"type": "choice", "criteria": {"a": "A", "b": "B"}},
16
+ gold=gold,
17
+ )
18
+ )
19
+ return Suite(name=name, version="1", cases=tuple(cases))
20
+
21
+
22
+ def test_suite_digest_stable():
23
+ a, b = make_suite(), make_suite()
24
+ assert a.digest == b.digest
25
+ assert suite_manifest_digest(a) == a.digest
26
+ changed = Suite(name=a.name, cases=a.cases[:1])
27
+ assert changed.digest != a.digest
28
+
29
+
30
+ def test_suite_roundtrip(tmp_path):
31
+ suite = make_suite("roundtrip")
32
+ path = tmp_path / "suite.json"
33
+ path.write_text(__import__("json").dumps(suite.to_dict(), indent=2))
34
+ loaded = load_suite(path)
35
+ assert loaded.name == suite.name
36
+ assert loaded.digest == suite.digest
37
+ assert len(loaded.cases) == len(suite.cases)
38
+
39
+
40
+ def test_quick_mode_subsamples():
41
+ suite = make_suite(count=40)
42
+ quick = suite.sample()
43
+ assert len(quick.cases) < len(suite.cases)
44
+ assert quick.metadata.get("sampled") is True
45
+ # Deterministic: same suite samples the same way.
46
+ assert suite.sample().cases == suite.sample().cases
47
+
48
+
49
+ def _always_first(question: dict) -> dict:
50
+ if question.get("type") == "noul":
51
+ return {"noul": 1.0}
52
+ return {"choice": "a"}
53
+
54
+
55
+ def test_runner_full_mode_perfect_score():
56
+ suite = make_suite(count=6, gold="a")
57
+ result = EvalRunner(_always_first).run(suite, mode="full")
58
+ assert isinstance(result, EvalResult)
59
+ assert result.mode == "full"
60
+ assert result.report.answered == 6
61
+ assert result.report.score == 1.0
62
+
63
+
64
+ def test_runner_quick_mode_records_mode():
65
+ suite = make_suite(count=40)
66
+ result = EvalRunner(_always_first).run(suite, mode="quick")
67
+ assert result.mode == "quick"
68
+ assert result.report.answered < 40
69
+ assert result.suite_digest == suite.digest
70
+
71
+
72
+ def test_runner_unknown_mode_rejected():
73
+ with pytest.raises(ValueError):
74
+ EvalRunner(_always_first).run(make_suite(), mode="slow")
75
+
76
+
77
+ def test_runner_failed_cases_lower_score():
78
+ suite = make_suite(count=4, gold="a")
79
+
80
+ def flaky_on_q0(question: dict) -> dict:
81
+ raise RuntimeError("boom")
82
+
83
+ result = EvalRunner(flaky_on_q0).run(suite, mode="full")
84
+ assert result.report.answered == 0
85
+ assert result.report.score == 0.0
86
+
87
+
88
+ def test_eval_result_artifact_and_certify():
89
+ from modelcertify.certify import Certificate
90
+
91
+ suite = make_suite(count=4, gold="a")
92
+ runner = EvalRunner(_always_first)
93
+ result = runner.run(suite, mode="full")
94
+ artifact = result.as_artifact()
95
+ assert artifact.name == "eval-report.json"
96
+ assert artifact.size_bytes > 0
97
+ certificate = runner.certify(
98
+ suite, result, run_id="run-1", issuer="acme", key=b"k" * 32
99
+ )
100
+ assert isinstance(certificate, Certificate)
101
+ assert certificate.certified
102
+ assert certificate.attestation.verify_signature(b"k" * 32)
103
+ # The suite digest is committed to the proof provenance.
104
+ policy_artifacts = certificate.policy.required_artifacts
105
+ assert "eval-report.json" in policy_artifacts
106
+ assert "suite-manifest.json" in policy_artifacts
107
+
108
+
109
+ def test_certify_fails_below_chance():
110
+ # The baseline policy requires score > 0.5; an all-wrong model fails.
111
+ suite = make_suite(count=4, gold="a")
112
+
113
+ def wrong(question: dict) -> dict:
114
+ return {"choice": "b"}
115
+
116
+ runner = EvalRunner(wrong)
117
+ result = runner.run(suite, mode="full")
118
+ certificate = runner.certify(suite, result, run_id="run-2", issuer="acme")
119
+ assert not certificate.certified
@@ -0,0 +1,76 @@
1
+ """Tests for the scoring primitives (P4.1 port)."""
2
+ from __future__ import annotations
3
+
4
+ from modelcertify.scoring import binary_f1, macro_f1, score_answers, score_ranking
5
+
6
+
7
+ def test_macro_f1():
8
+ pairs = [
9
+ ("a", "a"),
10
+ ("a", "b"),
11
+ ("b", "b"),
12
+ ("c", "c"),
13
+ ("c", "d"),
14
+ ]
15
+ f1 = macro_f1(pairs)
16
+ assert f1 is not None
17
+ assert 0.0 < f1 < 1.0
18
+ # Perfect predictions give 1.0.
19
+ assert macro_f1([("a", "a"), ("b", "b")]) == 1.0
20
+ assert macro_f1([]) is None
21
+
22
+
23
+ def test_binary_f1():
24
+ pairs = [("yes", "yes"), ("yes", "no"), ("no", "no")]
25
+ assert binary_f1(pairs, positive="yes") == 2 / 3
26
+ assert binary_f1([("no", "no")], positive="yes") == 0.0
27
+
28
+
29
+ def test_score_answers_accuracy():
30
+ questions = {
31
+ "q1": {"type": "choice", "criteria": {"a": "A", "b": "B"}, "gold": "a"},
32
+ "q2": {"type": "choice", "criteria": {"a": "A", "b": "B"}, "gold": "b"},
33
+ "q3": {"type": "noul", "gold": True},
34
+ }
35
+ answers = {
36
+ "q1": {"choice": "a"},
37
+ "q2": {"choice": "a"}, # wrong
38
+ "q3": {"noul": 0.9},
39
+ }
40
+ report = score_answers(questions, answers)
41
+ assert report.answered == 3
42
+ assert report.total == 3
43
+ assert report.score == 2 / 3
44
+ assert report.per_field["q1"]["correct"] is True
45
+ assert report.per_field["q2"]["correct"] is False
46
+
47
+
48
+ def test_score_answers_macro_f1():
49
+ questions = {
50
+ "q1": {"type": "choice", "criteria": {"a": "A", "b": "B"}, "gold": "a"},
51
+ "q2": {"type": "choice", "criteria": {"a": "A", "b": "B"}, "gold": "b"},
52
+ }
53
+ answers = {"q1": {"choice": "a"}, "q2": {"choice": "b"}}
54
+ report = score_answers(questions, answers, metric="macro_f1")
55
+ assert report.metric == "macro_f1"
56
+ assert report.score == 1.0
57
+
58
+
59
+ def test_score_answers_unanswered_field():
60
+ questions = {"q1": {"type": "choice", "criteria": {"a": "A"}, "gold": "a"}}
61
+ report = score_answers(questions, {})
62
+ assert report.answered == 0
63
+ assert report.score == 0.0
64
+ assert report.per_field["q1"]["answered"] is False
65
+
66
+
67
+ def test_score_ranking_ndcg():
68
+ question = {
69
+ "type": "ranking",
70
+ "gold": {"d1": 2, "d2": 1, "d3": 0},
71
+ }
72
+ answer = {"ranking": ["d1", "d2", "d3"]}
73
+ scored = score_ranking(question, answer, question["gold"])
74
+ assert scored["ndcg_at_10"] == 1.0
75
+ assert scored["mrr"] == 1.0
76
+ assert scored["correct"] is True
File without changes
File without changes