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.
- {modelcertify-0.1.0/src/modelcertify.egg-info → modelcertify-0.2.0}/PKG-INFO +9 -1
- {modelcertify-0.1.0 → modelcertify-0.2.0}/README.md +8 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/pyproject.toml +1 -1
- {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify/cli.py +72 -0
- modelcertify-0.2.0/src/modelcertify/eval.py +302 -0
- modelcertify-0.2.0/src/modelcertify/scoring.py +220 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0/src/modelcertify.egg-info}/PKG-INFO +9 -1
- {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify.egg-info/SOURCES.txt +5 -1
- modelcertify-0.2.0/tests/test_eval.py +119 -0
- modelcertify-0.2.0/tests/test_scoring.py +76 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/LICENSE +0 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/setup.cfg +0 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify/__init__.py +0 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify/attestation.py +0 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify/certify.py +0 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify/proof.py +0 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify/py.typed +0 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify.egg-info/dependency_links.txt +0 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify.egg-info/entry_points.txt +0 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify.egg-info/requires.txt +0 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/src/modelcertify.egg-info/top_level.txt +0 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/tests/test_attestation.py +0 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/tests/test_certify.py +0 -0
- {modelcertify-0.1.0 → modelcertify-0.2.0}/tests/test_cli.py +0 -0
- {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.
|
|
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`.
|
|
@@ -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.
|
|
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/
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|