mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
d79eea760e
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>
280 lines
12 KiB
Python
Executable File
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))
|