#!/usr/bin/env python3 """Validator for evals/gold// gold subsets. Enforces 9 invariants documented in docs/design/2026-05-21-v3.10-184-extend-eval-harness-spec.md implementation plan (Task 4 of #184 Phase 1a; I10 added in 2026-05-24 amendment). Usage: python -m scripts.check_evals_gold_set Exit code 0 = clean, non-zero = invariants violated. Prints one line per violation prefixed with the invariant tag (I1-I7, I9, I10; I8 retired). """ from __future__ import annotations import json import sys from pathlib import Path from typing import Any import yaml from jsonschema import Draft202012Validator _HERE = Path(__file__).resolve().parent if str(_HERE) not in sys.path: sys.path.insert(0, str(_HERE)) from citation_verification_summary import ( # noqa: E402 reduce_lookup_verified as _reduce_lookup_verified, ) LABEL_ENUM = {"true", "false", "unresolvable"} KIND_ENUM = {"valid_doi", "valid_arxiv", "manual_exempt", "fabricated", "fabricated_title_only"} RESOLVER_NAMES = ("crossref", "openalex", "semantic_scholar", "arxiv") # status + queried_by enums (and their status↔queried_by coherence) are now # enforced by _RESOLVER_OUTCOME_VALIDATOR against the shipped summary-schema $def, # not local constants (#332 P2). _CORPUS_ENTRY_SCHEMA_PATH = ( Path(__file__).resolve().parent.parent / "shared" / "contracts" / "passport" / "literature_corpus_entry.schema.json" ) _CORPUS_ENTRY_VALIDATOR = Draft202012Validator( json.loads(_CORPUS_ENTRY_SCHEMA_PATH.read_text(encoding="utf-8")), format_checker=Draft202012Validator.FORMAT_CHECKER, ) # Validate each resolver_outcome against the SHIPPED summary-schema $def rather # than a hand-rolled coherence check, so the gold validator can't drift from the # contract it pins (the I9b single-source-of-truth philosophy, applied to shape). # The $def carries the status↔queried_by allOf coherence (ran → {id,title}; # skipped/unreachable → null) plus the required-present rule that a flat enum # check silently under-enforced (#332 P2). _SUMMARY_SCHEMA_PATH = ( Path(__file__).resolve().parent.parent / "shared" / "contracts" / "passport" / "citation_verification_summary.schema.json" ) # Safe to validate the extracted $def in isolation because resolver_outcome is # $ref-less today. If it ever gains a $ref into a sibling $def, build the validator # from the full schema document (with a registry) instead of the slice. _RESOLVER_OUTCOME_VALIDATOR = Draft202012Validator( json.loads(_SUMMARY_SCHEMA_PATH.read_text(encoding="utf-8"))["$defs"]["resolver_outcome"] ) def _load_json_strict(path: Path) -> Any: """Load JSON; raise on duplicate keys (I4).""" def _no_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: seen: set[str] = set() for k, _ in pairs: if k in seen: raise ValueError(f"duplicate JSON key: {k!r}") seen.add(k) return dict(pairs) return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_no_duplicates) def validate(root: Path) -> list[str]: """Return a list of invariant-violation messages. Empty list = clean.""" errors: list[str] = [] root = Path(root) expected_path = root / "expected_outcomes.json" tuples_dir = root / "tuples" manifest_path = root / "manifest.yaml" if not tuples_dir.is_dir(): errors.append(f"I1: tuples/ directory not found at {tuples_dir}") return errors # I1: tuple filename stems == expected_outcomes keys tuple_stems = {p.stem for p in tuples_dir.glob("*.json")} try: expected = _load_json_strict(expected_path) except ValueError as e: errors.append(f"I4: {e}") return errors expected_keys = set(expected.keys()) missing = expected_keys - tuple_stems extra = tuple_stems - expected_keys if missing: errors.append(f"I1: tuples missing for expected_outcomes keys: {sorted(missing)}") if extra: errors.append(f"I1: extra tuple files without expected_outcomes entry: {sorted(extra)}") # Load all tuple files ONCE into tuples_by_id tuples_by_id: dict[str, dict] = {} for path in sorted(tuples_dir.glob("*.json")): try: tuples_by_id[path.stem] = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as e: errors.append(f"I2: {path.name} is not valid JSON: {e}") # malformed tuple gets a single I2 error; I3/I5/I6/I7/I10 skip it # I2: tuple_id == filename stem for stem, tup in tuples_by_id.items(): tid = tup.get("tuple_id") if tid != stem: errors.append(f"I2: {stem}.json tuple_id={tid!r} != filename stem {stem!r}") # I3: kind distribution matches manifest tuple_distribution if not manifest_path.is_file(): errors.append(f"I3: manifest.yaml not found at {manifest_path}") return errors try: manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) except yaml.YAMLError as e: errors.append(f"I3: manifest.yaml does not parse: {e}") return errors if not isinstance(manifest, dict): errors.append(f"I3: manifest.yaml root is not a mapping (got {type(manifest).__name__})") return errors tuple_dist = manifest.get("tuple_distribution") if not isinstance(tuple_dist, list): errors.append( f"I3: manifest.yaml missing or invalid 'tuple_distribution' " f"(got {type(tuple_dist).__name__})" ) return errors declared: dict[str, int] = {} for entry in tuple_dist: if not isinstance(entry, dict) or "kind" not in entry or "n" not in entry: errors.append(f"I3: manifest.yaml tuple_distribution entry malformed: {entry!r}") return errors declared[entry["kind"]] = entry["n"] # I3 observed accumulator observed: dict[str, int] = {} for stem, tup in tuples_by_id.items(): k = tup.get("kind") if k not in KIND_ENUM: errors.append(f"I3: {stem}.json has unknown kind {k!r}") continue observed[k] = observed.get(k, 0) + 1 # I3 declared-vs-observed comparison (Change 2: add observed-but-not-declared loop) for k, declared_n in declared.items(): obs_n = observed.get(k, 0) if obs_n != declared_n: errors.append(f"I3: kind {k!r} count {obs_n} != manifest declared {declared_n}") for k in observed.keys() - declared.keys(): errors.append(f"I3: kind {k!r} observed in tuples but not declared in manifest tuple_distribution") # I5: expected_outcomes label matches manifest's kind->label mapping kind_to_label = {entry["kind"]: entry["expected_lookup_verified"] for entry in tuple_dist} tuple_id_to_kind = {stem: tup.get("kind") for stem, tup in tuples_by_id.items()} for tid, outcome in expected.items(): label = outcome.get("lookup_verified") if label not in LABEL_ENUM: errors.append(f"I5: {tid} lookup_verified={label!r} not in {sorted(LABEL_ENUM)}") continue kind = tuple_id_to_kind.get(tid) if kind is None: continue # I1 already reported missing tuple expected_label = kind_to_label.get(kind) if expected_label is not None and label != expected_label: errors.append( f"I5: {tid} lookup_verified={label!r} but manifest declares " f"kind {kind!r} -> {expected_label!r}" ) # I6: arxiv_id placement consistency for stem, tup in tuples_by_id.items(): kind = tup.get("kind") arxiv_id = tup.get("arxiv_id") doi = (tup.get("corpus_entry") or {}).get("doi") if kind == "valid_arxiv": if not arxiv_id: errors.append(f"I6: {stem}.json kind=valid_arxiv but arxiv_id is null/missing") if doi: errors.append(f"I6: {stem}.json kind=valid_arxiv but corpus_entry.doi={doi!r} present") elif kind == "valid_doi": if arxiv_id: errors.append(f"I6: {stem}.json kind=valid_doi but arxiv_id={arxiv_id!r} present") if not doi: errors.append(f"I6: {stem}.json kind=valid_doi but corpus_entry.doi missing/null") else: if arxiv_id: errors.append(f"I6: {stem}.json kind={kind!r} but arxiv_id={arxiv_id!r} present (must be null)") # I7: fabrication_intent <-> kind is a fabrication kind. Both `fabricated` # (ID-keyed → false) and `fabricated_title_only` (no identifier → unresolvable, # the C-V6(a) by-design FN fixture) are fabrications and MUST carry the marker. fabrication_kinds = {"fabricated", "fabricated_title_only"} for stem, tup in tuples_by_id.items(): kind = tup.get("kind") marker = tup.get("fabrication_intent") if kind in fabrication_kinds and marker is not True: errors.append(f"I7: {stem}.json kind={kind!r} but fabrication_intent={marker!r} (must be true)") if kind not in fabrication_kinds and marker is True: errors.append(f"I7: {stem}.json kind={kind!r} but fabrication_intent=true (must be false)") # I9: resolver_outcomes has all four resolver keys with valid status enum # + valid queried_by enum (v3.11 #182 Delta 4 / C-V6(a)). # I9b: every gold label must be REPRODUCIBLE by the shipped reducer (the # single source of truth, C-V6(a) narrowed-false). Rather than hand-rolling # the false condition here (which would drift from the reducer if C-V6(a) is # ever amended), recompute each label via reduce_lookup_verified and assert # it matches — pinning the gold to the reducer, not a parallel copy of its # logic. Both share a single pass over expected.items(). for tid, outcome in expected.items(): ros = outcome.get("resolver_outcomes", {}) for resolver in RESOLVER_NAMES: entry = ros.get(resolver) if entry is None: errors.append(f"I9: {tid} resolver_outcomes missing resolver {resolver!r}") continue # Validate the resolver_outcome against the shipped summary-schema # $def: status enum, queried_by enum, queried_by required-present, AND # the status↔queried_by coherence allOf (ran → {id,title}; # skipped/unreachable → null). A flat enum check under-enforced the # last two (#332 P2). for verr in _RESOLVER_OUTCOME_VALIDATOR.iter_errors(entry): errors.append( f"I9: {tid} resolver_outcomes.{resolver} violates " f"citation_verification_summary $defs.resolver_outcome: " f"{verr.message}" ) recomputed = _reduce_lookup_verified(ros) declared = outcome.get("lookup_verified") if recomputed != declared: errors.append( f"I9b: {tid} lookup_verified={declared!r} but the shipped reducer " f"computes {recomputed!r} from its resolver_outcomes " f"(gold label must match the single-source-of-truth reducer; " f"C-V6(a) narrowed-false: false needs an ID-keyed unmatched)" ) # I10: per-tuple corpus_entry validates against literature_corpus_entry.schema.json for stem, tup in tuples_by_id.items(): corpus_entry = tup.get("corpus_entry") if corpus_entry is None: errors.append(f"I10: {stem}.json missing required field 'corpus_entry'") continue for ve in _CORPUS_ENTRY_VALIDATOR.iter_errors(corpus_entry): path = "$" + "".join(f"[{p!r}]" if isinstance(p, str) else f"[{p}]" for p in ve.absolute_path) errors.append( f"I10: {stem}.json corpus_entry{path}: {ve.message}" ) return errors def main(argv: list[str]) -> int: if len(argv) != 2: print("usage: python -m scripts.check_evals_gold_set ", file=sys.stderr) return 2 root = Path(argv[1]) errors = validate(root) if errors: for e in errors: print(e, file=sys.stderr) return 1 print(f"OK: {root} passes all gold-set invariants") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))