ceteris 0.2.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 (46) hide show
  1. ceteris/__init__.py +47 -0
  2. ceteris/__main__.py +3 -0
  3. ceteris/adapters/__init__.py +315 -0
  4. ceteris/capture.py +62 -0
  5. ceteris/certificate.py +98 -0
  6. ceteris/cli.py +360 -0
  7. ceteris/collectors/__init__.py +59 -0
  8. ceteris/collectors/_container.py +55 -0
  9. ceteris/collectors/_run.py +87 -0
  10. ceteris/collectors/build.py +137 -0
  11. ceteris/collectors/deps.py +117 -0
  12. ceteris/collectors/hardware.py +260 -0
  13. ceteris/collectors/parallelism.py +99 -0
  14. ceteris/collectors/runtime.py +106 -0
  15. ceteris/collectors/scheduler.py +112 -0
  16. ceteris/collectors/source.py +89 -0
  17. ceteris/collectors/system.py +188 -0
  18. ceteris/comparators.py +114 -0
  19. ceteris/compare.py +364 -0
  20. ceteris/config.py +134 -0
  21. ceteris/defaults.json +76 -0
  22. ceteris/doctor.py +168 -0
  23. ceteris/execution.py +120 -0
  24. ceteris/metrics.py +67 -0
  25. ceteris/model.py +181 -0
  26. ceteris/nodes.py +203 -0
  27. ceteris/packs/__init__.py +55 -0
  28. ceteris/packs/cuda.json +47 -0
  29. ceteris/packs/go.json +31 -0
  30. ceteris/packs/hpc.json +46 -0
  31. ceteris/packs/jvm.json +33 -0
  32. ceteris/packs/node.json +29 -0
  33. ceteris/packs/python.json +43 -0
  34. ceteris/packs/rocm.json +48 -0
  35. ceteris/packs/rust.json +34 -0
  36. ceteris/pytest_plugin.py +90 -0
  37. ceteris/render.py +311 -0
  38. ceteris/runner.py +192 -0
  39. ceteris/stats.py +138 -0
  40. ceteris/store.py +94 -0
  41. ceteris-0.2.0.dist-info/METADATA +684 -0
  42. ceteris-0.2.0.dist-info/RECORD +46 -0
  43. ceteris-0.2.0.dist-info/WHEEL +5 -0
  44. ceteris-0.2.0.dist-info/entry_points.txt +5 -0
  45. ceteris-0.2.0.dist-info/licenses/LICENSE +21 -0
  46. ceteris-0.2.0.dist-info/top_level.txt +1 -0
ceteris/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """ceteris -- capture benchmark run identity, then gate comparisons between runs.
2
+
3
+ Named for *ceteris paribus*: all other things being equal. That is precisely
4
+ the claim a benchmark comparison makes, and precisely the claim this tool
5
+ checks.
6
+
7
+ Library use mirrors the CLI:
8
+
9
+ from ceteris import capture, compare, Config
10
+
11
+ a = capture(repo="~/codes/hpx")
12
+ b = capture(repo="~/codes/hpx")
13
+ report = compare([a, b], vary=["runtime.env.LCI_ATTR_PACKET_SIZE"])
14
+ assert report.exit_code == 0
15
+
16
+ `capture()` returning a plain Fingerprint (rather than writing a file) is what
17
+ would let a future `ceteris run -- mpirun ...` wrapper call it before and after
18
+ a job to detect mid-run drift. Nothing in v1 uses that, but the shape is cheap
19
+ to preserve now and expensive to retrofit.
20
+ """
21
+
22
+ from .compare import Report, compare
23
+ from .config import Config
24
+ from .model import Field, Fingerprint, State
25
+
26
+ __version__ = "0.2.0"
27
+
28
+ def __getattr__(name: str):
29
+ # capture is resolved lazily so that `from ceteris import compare` does not
30
+ # pull in the subprocess-running collectors.
31
+ if name == "capture":
32
+ from .capture import capture
33
+
34
+ return capture
35
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
36
+
37
+
38
+ __all__ = [
39
+ "capture",
40
+ "compare",
41
+ "Config",
42
+ "Field",
43
+ "Fingerprint",
44
+ "Report",
45
+ "State",
46
+ "__version__",
47
+ ]
ceteris/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,315 @@
1
+ """Harness adapters: read the numbers the benchmark already produces.
2
+
3
+ `ceteris run -- hyperfine ...` should need no --metric. Each adapter knows
4
+ one harness: how to recognise it on the command line, how to make it write
5
+ machine-readable output if it was not going to, and how to turn that output
6
+ into metrics. The metric values are the harness's own statistics; ceteris
7
+ adds nothing to them.
8
+
9
+ The harness stays in charge of measurement. ceteris only asks where the
10
+ result went.
11
+
12
+ Three fixture formats (hyperfine, Google Benchmark, pytest-benchmark) are
13
+ recorded from real runs. The others were reconstructed from documentation
14
+ and are labelled as such in their tests; a real file from any of them is a
15
+ welcome contribution.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import glob
21
+ import json
22
+ import os
23
+ import re
24
+ import tempfile
25
+ import time
26
+ from dataclasses import dataclass
27
+ from typing import Any, Callable
28
+
29
+ from ..model import Field, unknown, value
30
+
31
+
32
+ def _slug(text: str) -> str:
33
+ return re.sub(r"[^A-Za-z0-9._/-]+", "_", text).strip("_")[:60]
34
+
35
+
36
+ def _arg_value(argv: list[str], *names: str) -> str | None:
37
+ for i, a in enumerate(argv):
38
+ for n in names:
39
+ if a == n and i + 1 < len(argv):
40
+ return argv[i + 1]
41
+ if a.startswith(n + "="):
42
+ return a[len(n) + 1 :]
43
+ return None
44
+
45
+
46
+ def _num(x: Any) -> Any:
47
+ try:
48
+ return float(x)
49
+ except (TypeError, ValueError):
50
+ return x
51
+
52
+
53
+ @dataclass
54
+ class Plan:
55
+ """What the adapter decided before the run."""
56
+
57
+ adapter: str
58
+ argv: list[str] # possibly augmented command to actually run
59
+ output: str | None = None # file to read afterwards, if any
60
+ added_output: bool = False # we injected the export flag ourselves
61
+
62
+
63
+ class Adapter:
64
+ name = "base"
65
+
66
+ def detect(self, argv: list[str]) -> bool: # pragma: no cover - interface
67
+ return False
68
+
69
+ def plan(self, argv: list[str], cwd: str) -> Plan:
70
+ return Plan(self.name, list(argv))
71
+
72
+ def collect(self, plan: Plan, stdout: str, cwd: str, started: float) -> dict[str, Field]: # pragma: no cover
73
+ return {}
74
+
75
+ # -- helpers -------------------------------------------------------------
76
+ def _read_json(self, path: str | None) -> tuple[Any, str | None]:
77
+ if not path:
78
+ return None, "no output file"
79
+ try:
80
+ with open(path, encoding="utf-8") as h:
81
+ return json.load(h), None
82
+ except OSError as exc:
83
+ return None, f"cannot read {path}: {exc.strerror or exc}"
84
+ except ValueError as exc:
85
+ return None, f"{path} is not valid JSON: {exc}"
86
+
87
+ def _failed(self, why: str) -> dict[str, Field]:
88
+ return {f"{self.name}._adapter": unknown(why, provenance=self.name)}
89
+
90
+
91
+ def _basename(argv: list[str]) -> str:
92
+ return os.path.basename(argv[0]) if argv else ""
93
+
94
+
95
+ class Hyperfine(Adapter):
96
+ name = "hyperfine"
97
+
98
+ def detect(self, argv):
99
+ return _basename(argv) == "hyperfine"
100
+
101
+ def plan(self, argv, cwd):
102
+ out = _arg_value(argv, "--export-json")
103
+ if out:
104
+ return Plan(self.name, list(argv), os.path.join(cwd, out))
105
+ path = tempfile.mktemp(prefix="ceteris-hyperfine-", suffix=".json", dir=cwd)
106
+ return Plan(self.name, list(argv) + ["--export-json", path], path, added_output=True)
107
+
108
+ def collect(self, plan, stdout, cwd, started):
109
+ data, err = self._read_json(plan.output)
110
+ if err:
111
+ return self._failed(err)
112
+ out: dict[str, Field] = {}
113
+ prov = f"hyperfine --export-json ({'injected' if plan.added_output else 'given'})"
114
+ results = data.get("results", [])
115
+ # Metric names must be stable across configurations, or the noise
116
+ # floor cannot compare them. The command is the thing that varies
117
+ # between configurations, so it must not be in the name; a single
118
+ # command is 'hyperfine.median_s', several are numbered in order.
119
+ for i, r in enumerate(results, 1):
120
+ key = "hyperfine" if len(results) == 1 else f"hyperfine.{i}"
121
+ for stat in ("median", "min"):
122
+ if stat in r:
123
+ out[f"{key}.{stat}_s"] = value(_num(r[stat]), provenance=f"{prov}; command: {r.get('command', '?')}")
124
+ return out or self._failed("no results in export")
125
+
126
+
127
+ class GoogleBenchmark(Adapter):
128
+ name = "gbench"
129
+
130
+ def detect(self, argv):
131
+ return any(a.startswith("--benchmark_") for a in argv[1:])
132
+
133
+ def plan(self, argv, cwd):
134
+ out = _arg_value(argv, "--benchmark_out")
135
+ if out:
136
+ return Plan(self.name, list(argv), os.path.join(cwd, out))
137
+ path = tempfile.mktemp(prefix="ceteris-gbench-", suffix=".json", dir=cwd)
138
+ return Plan(self.name, list(argv) + [f"--benchmark_out={path}", "--benchmark_out_format=json"], path, True)
139
+
140
+ def collect(self, plan, stdout, cwd, started):
141
+ data, err = self._read_json(plan.output)
142
+ if err:
143
+ return self._failed(err)
144
+ out: dict[str, Field] = {}
145
+ for b in data.get("benchmarks", []):
146
+ if b.get("run_type") == "aggregate":
147
+ continue
148
+ unit = b.get("time_unit", "ns")
149
+ out[f"gbench.{b.get('name', '?')}.real_time_{unit}"] = value(_num(b.get("real_time")), provenance="--benchmark_out json")
150
+ return out or self._failed("no benchmarks in output")
151
+
152
+
153
+ class PytestBenchmark(Adapter):
154
+ name = "pytest"
155
+
156
+ def detect(self, argv):
157
+ return any(a.startswith("--benchmark") for a in argv[1:]) and (
158
+ _basename(argv) in ("pytest", "py.test") or "pytest" in argv[:3]
159
+ )
160
+
161
+ def plan(self, argv, cwd):
162
+ out = _arg_value(argv, "--benchmark-json")
163
+ if out:
164
+ return Plan(self.name, list(argv), os.path.join(cwd, out))
165
+ path = tempfile.mktemp(prefix="ceteris-pytest-", suffix=".json", dir=cwd)
166
+ return Plan(self.name, list(argv) + [f"--benchmark-json={path}"], path, True)
167
+
168
+ def collect(self, plan, stdout, cwd, started):
169
+ data, err = self._read_json(plan.output)
170
+ if err:
171
+ return self._failed(err)
172
+ out: dict[str, Field] = {}
173
+ for b in data.get("benchmarks", []):
174
+ st = b.get("stats", {})
175
+ out[f"pytest.{b.get('name', '?')}.median_s"] = value(_num(st.get("median")), provenance="--benchmark-json")
176
+ return out or self._failed("no benchmarks in output")
177
+
178
+
179
+ class JMH(Adapter):
180
+ name = "jmh"
181
+
182
+ def detect(self, argv):
183
+ return "-rf" in argv and _arg_value(argv, "-rf") == "json"
184
+
185
+ def plan(self, argv, cwd):
186
+ out = _arg_value(argv, "-rff") or "jmh-result.json"
187
+ return Plan(self.name, list(argv), os.path.join(cwd, out))
188
+
189
+ def collect(self, plan, stdout, cwd, started):
190
+ data, err = self._read_json(plan.output)
191
+ if err:
192
+ return self._failed(err)
193
+ out: dict[str, Field] = {}
194
+ for b in data if isinstance(data, list) else []:
195
+ name = b.get("benchmark", "?").split(".")[-2:]
196
+ pm = b.get("primaryMetric", {})
197
+ unit = _slug(pm.get("scoreUnit", "")).replace("/", "_")
198
+ out[f"jmh.{'.'.join(name)}.{b.get('mode', 'score')}_{unit}"] = value(_num(pm.get("score")), provenance="jmh -rf json")
199
+ return out or self._failed("no benchmarks in output")
200
+
201
+
202
+ class Criterion(Adapter):
203
+ name = "criterion"
204
+
205
+ def detect(self, argv):
206
+ return _basename(argv) == "cargo" and "bench" in argv[1:3]
207
+
208
+ def collect(self, plan, stdout, cwd, started):
209
+ out: dict[str, Field] = {}
210
+ for path in glob.glob(os.path.join(cwd, "target", "criterion", "**", "new", "estimates.json"), recursive=True):
211
+ if os.path.getmtime(path) < started - 1:
212
+ continue
213
+ data, err = self._read_json(path)
214
+ if err:
215
+ continue
216
+ bench = os.path.relpath(os.path.dirname(os.path.dirname(path)), os.path.join(cwd, "target", "criterion"))
217
+ out[f"criterion.{_slug(bench)}.median_ns"] = value(_num(data.get("median", {}).get("point_estimate")), provenance=path)
218
+ return out or self._failed("no fresh target/criterion/*/new/estimates.json")
219
+
220
+
221
+ class OSU(Adapter):
222
+ name = "osu"
223
+
224
+ def detect(self, argv):
225
+ return any(os.path.basename(a).startswith("osu_") for a in argv)
226
+
227
+ def collect(self, plan, stdout, cwd, started):
228
+ out: dict[str, Field] = {}
229
+ unit = "value"
230
+ for line in stdout.splitlines():
231
+ m = re.match(r"^#\s*Size\s+(.+?)\s*$", line)
232
+ if m:
233
+ unit = re.sub(r"[^A-Za-z0-9]+", "_", m.group(1)).strip("_")
234
+ continue
235
+ m = re.match(r"^\s*(\d+)\s+([0-9.]+)\s*$", line)
236
+ if m:
237
+ out[f"osu.{unit}.{m.group(1)}B"] = value(float(m.group(2)), provenance="osu stdout table")
238
+ return out or self._failed("no size/value rows in stdout")
239
+
240
+
241
+ class NCCLTests(Adapter):
242
+ name = "nccl"
243
+
244
+ def detect(self, argv):
245
+ return any(os.path.basename(a).endswith("_perf") for a in argv)
246
+
247
+ def collect(self, plan, stdout, cwd, started):
248
+ out: dict[str, Field] = {}
249
+ for line in stdout.splitlines():
250
+ m = re.match(r"^\s*(\d+)\s+\d+\s+\S+\s+\S+\s+\S+\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)", line)
251
+ if m:
252
+ out[f"nccl.busbw_GBps.{m.group(1)}B"] = value(float(m.group(4)), provenance="nccl-tests stdout (out-of-place busbw)")
253
+ m = re.search(r"Avg bus bandwidth\s*:\s*([0-9.]+)", stdout)
254
+ if m:
255
+ out["nccl.avg_busbw_GBps"] = value(float(m.group(1)), provenance="nccl-tests stdout")
256
+ return out or self._failed("no result rows in stdout")
257
+
258
+
259
+ class MLPerf(Adapter):
260
+ name = "mlperf"
261
+
262
+ def detect(self, argv):
263
+ return any("mlperf" in a.lower() or "loadgen" in a.lower() for a in argv)
264
+
265
+ def collect(self, plan, stdout, cwd, started):
266
+ candidates = [p for p in glob.glob(os.path.join(cwd, "**", "mlperf_log_summary.txt"), recursive=True)
267
+ if os.path.getmtime(p) >= started - 1]
268
+ if not candidates:
269
+ return self._failed("no fresh mlperf_log_summary.txt under the working directory")
270
+ path = max(candidates, key=os.path.getmtime)
271
+ try:
272
+ text = open(path, encoding="utf-8").read()
273
+ except OSError as exc:
274
+ return self._failed(str(exc))
275
+ out: dict[str, Field] = {}
276
+ for label, key in (("Samples per second", "samples_per_second"), ("QPS w/ loadgen overhead", "qps"),
277
+ ("90th percentile latency \\(ns\\)", "p90_latency_ns"), ("Completed samples per second", "completed_samples_per_second")):
278
+ m = re.search(rf"{label}\s*:\s*([0-9.]+)", text)
279
+ if m:
280
+ out[f"mlperf.{key}"] = value(float(m.group(1)), provenance=path)
281
+ m = re.search(r"Result is\s*:\s*(\w+)", text)
282
+ if m:
283
+ out["mlperf.result"] = value(m.group(1), provenance=path)
284
+ return out or self._failed(f"no recognised fields in {path}")
285
+
286
+
287
+ ADAPTERS: list[Adapter] = [Hyperfine(), GoogleBenchmark(), PytestBenchmark(), JMH(), Criterion(), OSU(), NCCLTests(), MLPerf()]
288
+ BY_NAME = {a.name: a for a in ADAPTERS}
289
+
290
+
291
+ def detect(argv: list[str]) -> Adapter | None:
292
+ for adapter in ADAPTERS:
293
+ if adapter.detect(argv):
294
+ return adapter
295
+ return None
296
+
297
+
298
+ def ingest(path: str, fmt: str | None = None) -> dict[str, Field]:
299
+ """Explicit --ingest FILE[:format]: parse an output file without running."""
300
+ if fmt is None:
301
+ base = os.path.basename(path).lower()
302
+ fmt = "mlperf" if "mlperf" in base else "jmh" if "jmh" in base else None
303
+ if fmt is None:
304
+ data, err = Adapter()._read_json(path)
305
+ if isinstance(data, dict) and "results" in data:
306
+ fmt = "hyperfine"
307
+ elif isinstance(data, dict) and "benchmarks" in data:
308
+ fmt = "gbench" if data["benchmarks"] and "real_time" in data["benchmarks"][0] else "pytest"
309
+ elif isinstance(data, list):
310
+ fmt = "jmh"
311
+ adapter = BY_NAME.get(fmt or "")
312
+ if adapter is None:
313
+ return {"ingest._adapter": unknown(f"cannot determine format of {path}; pass FILE:format", provenance="--ingest")}
314
+ plan = Plan(adapter.name, [], path)
315
+ return adapter.collect(plan, "", os.path.dirname(os.path.abspath(path)), 0)
ceteris/capture.py ADDED
@@ -0,0 +1,62 @@
1
+ """Capture orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _dt
6
+ import platform
7
+
8
+ from . import nodes
9
+ from .collectors import Context, run_all
10
+ from .config import Config
11
+ from .model import Fingerprint
12
+
13
+
14
+ def capture(
15
+ repo: str | None = None,
16
+ cmake_cache: str | None = None,
17
+ compiler: str | None = None,
18
+ cxx_flags: str | None = None,
19
+ build_type: str | None = None,
20
+ label: str | None = None,
21
+ cfg: Config | None = None,
22
+ ) -> Fingerprint:
23
+ """Collect a fingerprint of the current environment.
24
+
25
+ Returns a Fingerprint rather than writing a file so the same call is usable
26
+ from a script, a test, or a future wrapper that captures either side of a
27
+ job.
28
+ """
29
+ from . import __version__
30
+
31
+ cfg = cfg or Config.load()
32
+ ctx = Context(
33
+ cfg=cfg,
34
+ repo=repo,
35
+ cmake_cache=cmake_cache,
36
+ compiler=compiler,
37
+ cxx_flags=cxx_flags,
38
+ build_type=build_type,
39
+ )
40
+ fields = run_all(ctx)
41
+ capture_args = []
42
+ for flag, val in (
43
+ ("--repo", repo), ("--cmake-cache", cmake_cache), ("--compiler", compiler),
44
+ ("--cxx-flags", cxx_flags), ("--build-type", build_type),
45
+ ):
46
+ if val is not None:
47
+ capture_args += [f"{flag}={val}"]
48
+ meta = {
49
+ "label": label or platform.uname().node,
50
+ # captured_at lives in meta and never in the comparable body, so two
51
+ # captures of an identical environment produce an identical hash.
52
+ "captured_at": _dt.datetime.now(_dt.timezone.utc).isoformat(
53
+ timespec="seconds"
54
+ ),
55
+ "tool": "ceteris",
56
+ "tool_version": __version__,
57
+ }
58
+ fingerprint = Fingerprint(fields=fields, meta=meta)
59
+ # Under a multi-node allocation this fans out one capture per node and
60
+ # merges the node-local fields; on a single host it only normalises the
61
+ # hostname fields so single- and multi-node records share a schema.
62
+ return nodes.apply(fingerprint, capture_args)
ceteris/certificate.py ADDED
@@ -0,0 +1,98 @@
1
+ """The certificate: one line that says a comparison was valid, and enough to
2
+ recompute that claim from the records.
3
+
4
+ ceteris-certified v1 configs=2 n=5,5 vary=build.cxx_flags waive= verdict=ok noise=15% sha256:<h>
5
+
6
+ The hash covers the records' content hashes, the declarations, and the
7
+ verdict. `ceteris verify LINE FILES...` re-runs the comparison with the
8
+ declarations parsed out of the line and checks the hash. A record edited
9
+ after the fact, a declaration quietly widened, or a different set of files
10
+ all fail verification.
11
+
12
+ This is the thing that gets pasted into a README, a PR, or a paper's
13
+ artifact appendix, which is how a check turns into a norm.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import re
21
+ from dataclasses import dataclass
22
+ from typing import Sequence
23
+
24
+ from .compare import Report
25
+
26
+ VERSION = 1
27
+
28
+
29
+ def _hash(report: Report) -> str:
30
+ payload = {
31
+ "records": sorted(g.content_hash for g in report.configs for _ in g.members),
32
+ "vary": sorted(report.declared),
33
+ "waive": sorted(report.waived.items()),
34
+ "strict": report.strict,
35
+ "exit_code": report.exit_code,
36
+ "noise": [(v.metric, v.assessed, v.within_noise) for v in report.noise],
37
+ }
38
+ return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
39
+
40
+
41
+ def _noise_summary(report: Report) -> str:
42
+ assessed = [v for v in report.noise if v.assessed]
43
+ if not assessed:
44
+ return "unassessed"
45
+ return f"{max(v.noise for v in assessed):.0%}"
46
+
47
+
48
+ def issue(report: Report) -> str:
49
+ verdict = {0: "ok", 1: "confounded", 2: "indeterminate", 4: "within-noise"}.get(report.exit_code, "invalid")
50
+ n = ",".join(str(g.n) for g in report.configs)
51
+ vary = ",".join(report.declared)
52
+ waive = ";".join(f"{k}:{v}" for k, v in report.waived.items()).replace(" ", "_")
53
+ return (
54
+ f"ceteris-certified v{VERSION} configs={len(report.configs)} n={n} "
55
+ f"vary={vary} waive={waive} strict={'1' if report.strict else '0'} "
56
+ f"verdict={verdict} noise={_noise_summary(report)} sha256:{_hash(report)}"
57
+ )
58
+
59
+
60
+ @dataclass
61
+ class Parsed:
62
+ vary: list[str]
63
+ waive: dict[str, str]
64
+ strict: bool
65
+ verdict: str
66
+ digest: str
67
+
68
+
69
+ def parse(line: str) -> Parsed:
70
+ m = re.match(
71
+ r"ceteris-certified v(\d+) configs=\d+ n=[\d,]* vary=(\S*) waive=(\S*) strict=([01]) "
72
+ r"verdict=(\S+) noise=\S+ sha256:([0-9a-f]{64})\s*$",
73
+ line.strip(),
74
+ )
75
+ if not m:
76
+ raise ValueError("not a ceteris certificate line")
77
+ if int(m.group(1)) != VERSION:
78
+ raise ValueError(f"certificate version {m.group(1)} not supported")
79
+ waive = {}
80
+ if m.group(3):
81
+ for item in m.group(3).split(";"):
82
+ k, _, v = item.partition(":")
83
+ waive[k] = v.replace("_", " ")
84
+ return Parsed(
85
+ vary=[v for v in m.group(2).split(",") if v],
86
+ waive=waive,
87
+ strict=m.group(4) == "1",
88
+ verdict=m.group(5),
89
+ digest=m.group(6),
90
+ )
91
+
92
+
93
+ def verify(line: str, report: Report) -> tuple[bool, str]:
94
+ parsed = parse(line)
95
+ actual = _hash(report)
96
+ if actual != parsed.digest:
97
+ return False, "hash mismatch: the records, declarations or verdict differ from what was certified"
98
+ return True, f"verified: {parsed.verdict}"