master-skill 0.11.0 → 0.12.0

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 (69) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/README.md +48 -55
  5. package/README_EN.md +72 -59
  6. package/bin/cli.mjs +12 -7
  7. package/gemini-extension.json +1 -1
  8. package/hooks/session-start +68 -77
  9. package/hooks/session_start.py +152 -0
  10. package/package.json +5 -2
  11. package/prebuilt/compare-masters/SKILL.md +21 -2
  12. package/prebuilt/master-ajahn-chah/meta.json +6 -0
  13. package/prebuilt/master-ajahn-chah/tests/fidelity.jsonl +6 -6
  14. package/prebuilt/master-atisha/tests/fidelity.jsonl +4 -4
  15. package/prebuilt/master-curriculum/references/tiantai.md +1 -1
  16. package/prebuilt/master-debate/SKILL.md +14 -2
  17. package/prebuilt/master-fazang/tests/fidelity.jsonl +2 -2
  18. package/prebuilt/master-help/SKILL.md +9 -1
  19. package/prebuilt/master-huineng/tests/fidelity.jsonl +4 -4
  20. package/prebuilt/master-kumarajiva/tests/fidelity.jsonl +3 -3
  21. package/prebuilt/master-mahasi-sayadaw/tests/fidelity.jsonl +4 -4
  22. package/prebuilt/master-milarepa/tests/fidelity.jsonl +3 -3
  23. package/prebuilt/master-nagarjuna/tests/fidelity.jsonl +6 -6
  24. package/prebuilt/master-ouyi/meta.json +5 -0
  25. package/prebuilt/master-ouyi/references/teaching.md +3 -3
  26. package/prebuilt/master-ouyi/tests/fidelity.jsonl +3 -3
  27. package/prebuilt/master-tsongkhapa/meta.json +6 -0
  28. package/prebuilt/master-tsongkhapa/tests/fidelity.jsonl +2 -2
  29. package/prebuilt/master-xuanzang/tests/fidelity.jsonl +3 -3
  30. package/prebuilt/master-xuyun/tests/fidelity.jsonl +6 -6
  31. package/prebuilt/master-zhiyi/meta.json +2 -2
  32. package/prebuilt/master-zhiyi/tests/fidelity.jsonl +2 -2
  33. package/scripts/check-audit-ignores.py +105 -0
  34. package/scripts/check-eval-sdk-surface.py +142 -0
  35. package/scripts/check-gate-liveness.py +205 -6
  36. package/scripts/reaudit-report.py +163 -0
  37. package/scripts/regrade-report.py +157 -0
  38. package/scripts/smoke-eval-sdk.py +174 -0
  39. package/scripts/test-fidelity.py +684 -52
  40. package/scripts/validate-citation-references.py +150 -0
  41. package/scripts/validate-citation-templates.py +176 -0
  42. package/scripts/validate-fixture-terms.py +127 -0
  43. package/scripts/verify-adjudication.py +316 -0
  44. package/scripts/verify_citations.py +739 -39
  45. package/tools/cross_reference.py +44 -10
  46. package/tools/fojin-known-absent.json +14 -0
  47. package/tools/fojin_bridge.py +138 -8
  48. package/tools/rag_query.py +45 -2
  49. package/tools/skill_writer.py +50 -7
  50. package/tools/verify_sources.py +240 -15
  51. package/hooks/tests/test_run_hook.sh +0 -114
  52. package/hooks/tests/test_run_hook_cmd.sh +0 -94
  53. package/hooks/tests/test_session_start.sh +0 -149
  54. package/scripts/tests/test_check_gate_liveness.py +0 -232
  55. package/scripts/tests/test_check_manifest_versions.py +0 -217
  56. package/scripts/tests/test_check_response.py +0 -190
  57. package/scripts/tests/test_debate_protocol.py +0 -159
  58. package/scripts/tests/test_fidelity_providers.py +0 -202
  59. package/scripts/tests/test_injection_hardening.py +0 -174
  60. package/scripts/tests/test_select_fidelity_smoke.py +0 -142
  61. package/scripts/tests/test_validate.py +0 -145
  62. package/scripts/tests/test_validate_citation_contract.py +0 -408
  63. package/scripts/tests/test_validate_cross_critique.py +0 -149
  64. package/scripts/tests/test_validate_curriculum_sources.py +0 -144
  65. package/scripts/tests/test_validate_fidelity.py +0 -59
  66. package/scripts/tests/test_validate_lore_triggers_content.py +0 -372
  67. package/scripts/tests/test_validate_persona_fidelity.py +0 -317
  68. package/scripts/tests/test_validate_promptfoo_configs.py +0 -386
  69. package/scripts/tests/test_validate_workflow.py +0 -284
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env python3
2
+ """Gate: a persona must not instruct a citation its own contract forbids.
3
+
4
+ `validate-citation-contract.py` checks that `meta.json` declares the right
5
+ *fields*. Nothing checked whether the persona's own SKILL.md, sources/ and
6
+ references/ tell it to cite sources that `meta.json` never declares — and that
7
+ is a shipped defect, not a hypothetical: `master-tsongkhapa`'s SKILL.md gives
8
+
9
+ 印度大乘论典所引:`【月称《入中论》§第六章】(Toh 3861)`
10
+
11
+ as the prescribed format while `meta.json` declares five sources, none of them
12
+ `Toh:3861`. Every use of that instruction violates the B1 citation rule.
13
+
14
+ That was found by a ¥3.89 graded run over 211 fixtures, which caught it only
15
+ because one fixture happened to trigger it. This finds every instance of the
16
+ class deterministically, for free, on every PR.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ import sys
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
27
+ from verify_citations import audit_answer, load_declared_ids, load_member_aliases # noqa: E402
28
+
29
+ PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
30
+
31
+ _BLOCK = re.compile(r"【([^】]*)】")
32
+ # Same attribution region verify_citations uses, so a tag documented after the
33
+ # block ((BDRC: Wxxxxx)) is judged together with the block it belongs to.
34
+ _LINK_WINDOW = 120
35
+
36
+ # Documentation showing the citation *format* is not a citation. Every marker
37
+ # here appears in shipped persona docs; none can occur in a real reference.
38
+ _TEMPLATE_MARKERS = (
39
+ re.compile(r"\{"), # 【《{title}》,{source_id}】
40
+ re.compile(r"[A-Za-z][xX]{3,}"), # (BDRC: Wxxxxx) / Txxnxxxx
41
+ re.compile(r"卷N"), # 【《法華玄義》卷N,T1716】
42
+ re.compile(r"典籍名|章节名"), # 【《典籍名》§章节】
43
+ )
44
+
45
+ # Open findings, each awaiting a maintainer decision. This is a ratchet, not an
46
+ # allowlist: an entry here is a defect that has been *seen*, not one that has
47
+ # been permitted. Do not add to it to turn a red build green — that is exactly
48
+ # the failure this gate exists to prevent.
49
+ #
50
+ # Empty as of 2026-09-03. Both findings this gate ever recorded were resolved
51
+ # by declaring the source: `Toh:3861` in master-tsongkhapa/meta.json (月称《入
52
+ # 中论》is a real Tengyur text Tsongkhapa's tradition treats as its own
53
+ # foundation) and `J36nB348` in master-ouyi/meta.json (《灵峰宗论》is Ouyi's own
54
+ # collected works). Neither needed a B1 contract change — both simply belonged
55
+ # in the declared set. See CHANGELOG.md for the maintainer decision.
56
+ KNOWN_UNDECLARED: dict[tuple[str, str], str] = {}
57
+
58
+
59
+ def is_template_block(text: str) -> bool:
60
+ """Is this citation documenting the format rather than citing a source?"""
61
+ return any(marker.search(text) for marker in _TEMPLATE_MARKERS)
62
+
63
+
64
+ def _strip_template_citations(text: str) -> str:
65
+ """Drop format-documentation citations, with their attribution regions."""
66
+ blocks = list(_BLOCK.finditer(text))
67
+ out: list[str] = []
68
+ prev = 0
69
+ for index, match in enumerate(blocks):
70
+ next_start = blocks[index + 1].start() if index + 1 < len(blocks) else len(text)
71
+ region_end = min(next_start, match.end() + _LINK_WINDOW)
72
+ context = match.group(1) + text[match.end():region_end]
73
+ if is_template_block(context):
74
+ out.append(text[prev:match.start()])
75
+ prev = region_end
76
+ out.append(text[prev:])
77
+ return "".join(out)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Finding:
82
+ master: str
83
+ citation: str
84
+ path: str
85
+
86
+
87
+ def find_undeclared(prebuilt_dir: Path) -> list[Finding]:
88
+ """Every citation the personas' own material makes that meta.json omits."""
89
+ findings: list[Finding] = []
90
+ for persona in sorted(Path(prebuilt_dir).iterdir()):
91
+ meta_path = persona / "meta.json"
92
+ if not persona.is_dir() or not meta_path.is_file():
93
+ continue
94
+ # Found by an independent code-review pass (2026-09-03): this used to
95
+ # reimplement its own meta.json parsing instead of calling
96
+ # verify_citations.py's own loader — a real drift risk, since a future
97
+ # change to how sources/notes are parsed there would silently stop
98
+ # applying here. `persona.name` round-trips through resolve_master_dir
99
+ # (it already accepts the full `master-<slug>` form).
100
+ try:
101
+ declared = load_declared_ids(persona.name, base=str(prebuilt_dir))
102
+ aliases = load_member_aliases(persona.name, base=str(prebuilt_dir))
103
+ except (FileNotFoundError, ValueError):
104
+ continue # meta.json exists (meta_path.is_file() above) but is unreadable
105
+ if not declared:
106
+ continue # nothing to audit against; see master-debate
107
+ docs = [persona / "SKILL.md"]
108
+ docs += sorted(persona.glob("sources/*.md"))
109
+ docs += sorted(persona.glob("references/*.md"))
110
+ for doc in docs:
111
+ if not doc.is_file():
112
+ continue
113
+ text = _strip_template_citations(doc.read_text(encoding="utf-8"))
114
+ for citation in dict.fromkeys(audit_answer(declared, text, aliases)["fabricated"]):
115
+ findings.append(
116
+ Finding(persona.name, citation, str(doc.relative_to(prebuilt_dir.parent)))
117
+ )
118
+ return findings
119
+
120
+
121
+ def main() -> int:
122
+ findings = find_undeclared(PREBUILT_DIR)
123
+ known, new = [], []
124
+ for f in findings:
125
+ (known if (f.master, f.citation) in KNOWN_UNDECLARED else new).append(f)
126
+
127
+ if known:
128
+ print(f"Known undeclared citations ({len(known)}) — open findings, not permissions:")
129
+ for f in known:
130
+ print(f" {f.master}: {f.citation} ({f.path})")
131
+ for key in dict.fromkeys((f.master, f.citation) for f in known):
132
+ print(f"\n {key[0]} / {key[1]}:\n {KNOWN_UNDECLARED[key]}")
133
+
134
+ if new:
135
+ print(f"\nFAIL: {len(new)} citation(s) not declared in the persona's meta.json:")
136
+ for f in new:
137
+ print(f" {f.master}: {f.citation} ({f.path})")
138
+ print(
139
+ "\nThe persona's own material instructs a citation its citation contract "
140
+ "forbids.\nDeclare the source in meta.json, or stop citing it. Adding it to "
141
+ "KNOWN_UNDECLARED\nwithout a maintainer decision defeats the point of this gate."
142
+ )
143
+ return 1
144
+
145
+ print(f"\nOK: no new undeclared citations across {len(list(PREBUILT_DIR.iterdir()))} skills.")
146
+ return 0
147
+
148
+
149
+ if __name__ == "__main__":
150
+ sys.exit(main())
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env python3
2
+ """Gate: a skill must not demand verifiable citations and then show a template
3
+ that cannot carry one.
4
+
5
+ `compare-masters/SKILL.md` did exactly that. Line 223 required every doctrinal
6
+ claim to "解析到所选 persona 的 meta.json.sources[]" and line 259 required a
7
+ "可核验的来源引用" — while the output template two hundred lines earlier read
8
+
9
+ > 出处:【《经名》卷N】→ fojin.app 链接
10
+
11
+ with no identifier in it. The model followed the template, and the 2026-09-12
12
+ sweep measured the result: 18 answers, 49 citations, **zero checkable**, audit
13
+ coverage 0%. The rule and the example were in the same file and contradicted
14
+ each other; nothing was comparing them.
15
+
16
+ The check is deliberately narrow. It does NOT require every citation format to
17
+ carry an identifier — `master-buddhaghosa`, `master-mahasi-sayadaw` and
18
+ `master-tsongkhapa` declare formats without one because PTS / SuttaCentral /
19
+ BDRC are corpus-level references with no per-passage id, which the citation
20
+ contract documents as a known boundary. Requiring an id there would force a
21
+ fabricated one, which is worse than an unverifiable citation.
22
+
23
+ What it requires is consistency: a skill that quotes *other* personas must
24
+ defer to their declared `citation_format` rather than inventing a weaker shape
25
+ of its own. Deferring is also what makes the boundary above work — quoting
26
+ buddhaghosa inherits buddhaghosa's format, id-less and honest.
27
+
28
+ The second rule was added after the first one turned out to examine 1 of the 3
29
+ meta-skills and return early on the other two. `master-debate` requires "至少 1
30
+ 个本宗 citation" four times over and never says what one looks like; the same
31
+ sweep shows it wrote 24 real sutra ids as `《坛经》(T48n2008)`. The auditor's
32
+ `_CITATION_BLOCK` parses `【…】` only, so all 24 were invisible and the report
33
+ read `audit_coverage: N/A` — the same cell `master-help`, which legitimately
34
+ never cites, prints. Requiring citations and not naming an auditable shape
35
+ produces citations nobody checks, which reads as clean.
36
+
37
+ Usage:
38
+ python3 scripts/validate-citation-templates.py
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import re
44
+ import sys
45
+ from pathlib import Path
46
+
47
+ PREBUILT = Path(__file__).resolve().parent.parent / "prebuilt"
48
+
49
+ # A literal citation example: 【…】 with no {placeholder} inside, i.e. a shape
50
+ # the writer intended to be copied rather than filled from a declared format.
51
+ _LITERAL_CITATION = re.compile(r"【《[^》{]*》[^】{]*】")
52
+
53
+ # Phrases by which a file asserts its citations are checkable. Kept to the ones
54
+ # this repo actually uses, so the check cannot drift into policing prose.
55
+ _VERIFIABILITY_CLAIMS = ("可核验", "必须解析到", "可核对")
56
+
57
+ # Skills that quote other personas rather than speaking as one.
58
+ _META_SKILLS = ("compare-masters", "master-debate", "master-curriculum")
59
+
60
+ # The only shape `verify_citations._CITATION_BLOCK` parses. A citation written
61
+ # any other way is served to the reader and audited by nothing.
62
+ _AUDITABLE_BLOCK = re.compile(r"【[^】]*】")
63
+
64
+ # A file demanding citations of its output. Narrow on purpose: `master-help`
65
+ # mentions the word without requiring any, and must not be caught.
66
+ _REQUIRES_CITATION = (
67
+ re.compile(r"至少\s*\d+\s*(?:个|条)[^。\n]{0,12}citation"),
68
+ re.compile(r"引用必须"),
69
+ re.compile(r"必须附[^。\n]{0,12}引用"),
70
+ re.compile(r"引经必经查证"),
71
+ )
72
+
73
+
74
+ def _strip_html_comments(text: str) -> str:
75
+ """Drop <!-- … --> before scanning.
76
+
77
+ The first run of this check flagged the comment that explains what the bad
78
+ template used to look like — reading the note about the defect as the
79
+ defect. That is the same "the test matched the docstring" shape this repo
80
+ keeps producing, one level up. A comment is not an instruction to the
81
+ model: SKILL.md is rendered as markdown, and an HTML comment does not
82
+ appear in what the model is shown.
83
+
84
+ The newlines are kept, so a reported line number still points at the line a
85
+ maintainer will open. Deleting them outright shifts every later line and
86
+ sends the reader to the wrong place — a small lie in the same family.
87
+ """
88
+ return re.sub(
89
+ r"<!--.*?-->",
90
+ lambda m: "\n" * m.group().count("\n"),
91
+ text,
92
+ flags=re.S,
93
+ )
94
+
95
+
96
+ def check_skill(path: Path) -> tuple[list[str], list[str]]:
97
+ """Return (problems, rules that actually examined this file).
98
+
99
+ The second element exists so `main` can print what was looked at. The
100
+ first version of this check returned early on two of the three meta-skills
101
+ and printed a clean line anyway; a gate that says nothing about its own
102
+ reach is how this repo has repeatedly shipped an inert one.
103
+ """
104
+ text = _strip_html_comments(path.read_text(encoding="utf-8"))
105
+ name = path.parent.name
106
+ problems: list[str] = []
107
+ examined: list[str] = []
108
+
109
+ declares_own_format = re.search(r"^citation_format:", text, re.M) is not None
110
+
111
+ # Rule 1 — a verifiability claim must not sit next to an id-less template.
112
+ claims = [c for c in _VERIFIABILITY_CLAIMS if c in text]
113
+ if claims:
114
+ examined.append("claims-vs-template")
115
+ for match in _LITERAL_CITATION.finditer(text):
116
+ # A persona with its own declared format may show an example of it.
117
+ if declares_own_format and name not in _META_SKILLS:
118
+ continue
119
+ line = text[: match.start()].count("\n") + 1
120
+ problems.append(
121
+ f"{name}/SKILL.md:{line}: the file claims citations are "
122
+ f"{claims[0]} but shows the literal template {match.group()!r}, "
123
+ "which carries no identifier. A skill that quotes personas must "
124
+ "defer to their declared `citation_format`."
125
+ )
126
+
127
+ # Rule 2 — requiring citations without naming an auditable shape produces
128
+ # citations the auditor cannot see, which reports as `N/A`, not as a miss.
129
+ demand = next((p for p in _REQUIRES_CITATION if p.search(text)), None)
130
+ if demand is not None:
131
+ examined.append("demand-vs-auditable-shape")
132
+ if not _AUDITABLE_BLOCK.search(text):
133
+ line = text[: demand.search(text).start()].count("\n") + 1
134
+ problems.append(
135
+ f"{name}/SKILL.md:{line}: the file requires citations "
136
+ f"(matched {demand.pattern!r}) but never shows the 【…】 form. "
137
+ "`verify_citations._CITATION_BLOCK` parses that shape and no "
138
+ "other, so whatever the model writes instead is served "
139
+ "unaudited and reports as `audit_coverage: N/A`."
140
+ )
141
+
142
+ return problems, examined
143
+
144
+
145
+ def main() -> int:
146
+ skills = sorted(p for p in PREBUILT.glob("*/SKILL.md"))
147
+ if not skills:
148
+ print("no SKILL.md found under prebuilt/ — this check would pass vacuously")
149
+ return 1
150
+
151
+ problems: list[str] = []
152
+ reach: dict[str, list[str]] = {}
153
+ for path in skills:
154
+ found, examined = check_skill(path)
155
+ problems += found
156
+ for rule in examined:
157
+ reach.setdefault(rule, []).append(path.parent.name)
158
+
159
+ if problems:
160
+ print(f"✗ {len(problems)} citation-template problem(s):\n")
161
+ for problem in problems:
162
+ print(f" - {problem}")
163
+ return 1
164
+
165
+ print(f"✓ citation templates ok — {len(skills)} skills scanned")
166
+ for rule in ("claims-vs-template", "demand-vs-auditable-shape"):
167
+ names = reach.get(rule, [])
168
+ if not names:
169
+ print(f" ✗ {rule}: examined 0 skills — this rule decided nothing")
170
+ return 1
171
+ print(f" {rule}: {len(names)} skill(s) — {', '.join(sorted(names))}")
172
+ return 0
173
+
174
+
175
+ if __name__ == "__main__":
176
+ sys.exit(main())
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """Gate: a requirement may only be declared undecidable with evidence behind it.
3
+
4
+ `must_convey` is how a fixture says "the substring matcher cannot decide this" —
5
+ the honest answer for 方便 when the answer says 「应病与药」, or for 不是虚无 when
6
+ it says 「空非虚无」. It is also the easiest possible way to launder a failure:
7
+ move the inconvenient requirement there and the build goes green while looking
8
+ more rigorous than before.
9
+
10
+ So a term may sit in `must_convey` only if a committed adjudication under
11
+ `eval/reports/` ruled it an instrument artifact, on a quote that
12
+ `verify-adjudication.py` proved is still in the answer it judges. A term the
13
+ adjudication ruled `upheld` — a real failure — can never be moved there.
14
+
15
+ Same ratchet as `KNOWN_UNDECLARED` in validate-citation-references.py: an entry
16
+ records a decision that was made and shown, not a permission anyone can take.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ PREBUILT = ROOT / "prebuilt"
26
+ REPORTS = ROOT / "eval" / "reports"
27
+
28
+ # Verdicts that license moving a requirement out of hard grading.
29
+ PERMITTING = {"instrument", "fixture"}
30
+
31
+
32
+ def load_fixtures() -> dict[str, list[dict]]:
33
+ fixtures: dict[str, list[dict]] = {}
34
+ for path in sorted(PREBUILT.glob("*/tests/fidelity.jsonl")):
35
+ master = path.parent.parent.name
36
+ fixtures[master] = [
37
+ json.loads(line) for line in path.read_text().splitlines() if line.strip()
38
+ ]
39
+ return fixtures
40
+
41
+
42
+ def load_adjudications() -> list[dict]:
43
+ return [json.loads(p.read_text()) for p in sorted(REPORTS.glob("adjudication-*.json"))]
44
+
45
+
46
+ def _permitted(adjudications: list[dict]) -> set[tuple[str, int, str]]:
47
+ """(master, index, term) triples an adjudication ruled an instrument artifact.
48
+
49
+ Keyed by fixture index, not just (master, term) — a verdict on one fixture
50
+ must not license the same term string at a different, unadjudicated
51
+ fixture for the same master. `缘起` being paraphrased in one answer says
52
+ nothing about whether it is genuinely omitted in another; only the
53
+ specific case a verdict names has evidence behind it.
54
+ """
55
+ allowed: set[tuple[str, int, str]] = set()
56
+ for adjudication in adjudications:
57
+ for case in adjudication.get("cases", []):
58
+ for verdict in case.get("mention_verdicts", []):
59
+ if verdict.get("verdict") in PERMITTING:
60
+ allowed.add((case["master"], case["index"], verdict["term"]))
61
+ return allowed
62
+
63
+
64
+ def verify(fixtures: dict[str, list[dict]], adjudications: list[dict]) -> list[str]:
65
+ """Return every reason a fixture declares more than the evidence supports."""
66
+ problems: list[str] = []
67
+ allowed = _permitted(adjudications)
68
+
69
+ declares_any = any(
70
+ case.get("must_convey") for cases in fixtures.values() for case in cases
71
+ )
72
+ if declares_any and not adjudications:
73
+ problems.append(
74
+ "no adjudication under eval/reports/ — must_convey entries exist but "
75
+ "there is nothing to check them against"
76
+ )
77
+ return problems
78
+
79
+ for master, cases in fixtures.items():
80
+ for index, case in enumerate(cases):
81
+ convey = case.get("must_convey") or []
82
+ mention = set(case.get("must_mention") or [])
83
+ for term in convey:
84
+ where = f"{master} #{index}"
85
+ if term in mention:
86
+ problems.append(
87
+ f"{where}: {term!r} is in both must_mention and must_convey — "
88
+ "a requirement is either graded or undecidable, not both"
89
+ )
90
+ if (master, index, term) not in allowed:
91
+ problems.append(
92
+ f"{where}: {term!r} is declared undecidable but no adjudication "
93
+ f"ruled it an instrument artifact for this specific fixture. A "
94
+ "verdict on a different case does not transfer. Grade it, or "
95
+ "adjudicate this exact case and show the evidence."
96
+ )
97
+ return problems
98
+
99
+
100
+ def main() -> int:
101
+ fixtures = load_fixtures()
102
+ adjudications = load_adjudications()
103
+ problems = verify(fixtures, adjudications)
104
+ convey = sum(
105
+ len(case.get("must_convey") or [])
106
+ for cases in fixtures.values()
107
+ for case in cases
108
+ )
109
+ graded = sum(
110
+ len(case.get("must_mention") or [])
111
+ for cases in fixtures.values()
112
+ for case in cases
113
+ )
114
+ if problems:
115
+ print(f"FAIL: {len(problems)} fixture term(s) declare more than the evidence supports:")
116
+ for problem in problems:
117
+ print(f" {problem}")
118
+ return 1
119
+ print(
120
+ f"OK: {graded} graded mention requirements, {convey} declared undecidable, "
121
+ f"every one of the latter backed by an adjudicated verdict."
122
+ )
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ sys.exit(main())