Files
Edward Cheng-I Wu d79eea760e fix(eval): remove fabricated tuple mislabeled as genuine unindexed paper (#250) (#366)
Gold tuple 052-valid-unindexed-regional-paper was labeled
`fabrication_intent: false` and its expert-verdict notes asserted it was a
"GENUINE ... real regional, non-English-indexed agronomy paper" (Sembiring &
Ginting 2023, "Smallholder Agroforestry Adaptation in the Karo Highlands of
North Sumatra", Jurnal Penelitian Pertanian Regional).

First-party verification across all four resolvers (Crossref / OpenAlex /
Semantic Scholar / arXiv) plus DOAJ, OpenAlex Sources, Crossref Journals, and
general web search finds no evidence the paper or the journal exists. The
tuple is a fabrication labeled as genuine — exactly the failure this repo
exists to detect, and a direct violation of #250's closing condition, which
requires a *first-party-verifiable* real-but-unindexed source.

Functionally the tuple was redundant: the harness reduces pre-recorded
resolver_outcomes (it does not live-query), and tuple 051 (the OQ-5 by-design
false-negative, a no-identifier fabrication) already exercises the identical
title-only-unmatched -> unresolvable reducer path.

Changes:
- Remove tuples/052-valid-unindexed-regional-paper.json.
- expected_outcomes.json: drop the 052 entry (51 entries).
- manifest.yaml: sample_n 52 -> 51; drop the valid_unindexed distribution
  row; description rewritten to one coverage-gap fixture + #250 note.
- check_evals_gold_set.py: drop valid_unindexed from KIND_ENUM.
- citation_verification_summary.py + test comments: drop 052 references;
  expert-concordance count 12 -> 11; unresolvable support 7 -> 6.

This realigns manifest/tests/summary with the gold-set README, which already
described a 51-tuple set and tracked the gap at #250. The real-but-unindexed
canary remains genuinely unfilled, so #250 stays open.

Closes nothing — #250 deliberately remains OPEN (the verified-real canary is
still missing; only a mislabeled synthetic proxy was removed).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:39:11 +08:00

280 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Validator for evals/gold/<task>/ 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 <gold-set-dir>
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 <gold-set-dir>", 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))