Files
imbad0202__academic-researc…/scripts/test_repro_lock_validation_drift.py
Edward Cheng-I Wu d0663404ff #260: Experiment Provenance Intake + claim→experiment alignment (schema-first) (#374)
## #260 — Experiment Provenance Intake + claim→experiment alignment (schema-first)

Adds the **intake + alignment** layer for experiment-backed claims (Kong et al. 2026, arXiv:2605.18661, §3.3 + §7.4.3). ARS deliberately keeps experiment **execution** outside the pipeline — the scholar runs experiments externally and brings results back; this change records that provenance and audits manuscript claims against it. Explicit non-goals (carried verbatim into the gate wording): does not run experiments, does not judge whether one was correctly designed/run/statistically-adequate/reproducible, does not auto-fill provenance, does not require provenance for literature-only pipelines.

### What ships

**Block A — `experiment_provenance[]` aggregate** (`experiment_provenance_entry.schema.json`): each scholar-entered entry carries a nested `repro_lock` (same inline-object shape as the passport-level lock, re-declared — not `$ref`'d — because the source is inline prose, not a schema file), a `planned_vs_executed[]` record (each `executed:false` unit carries a gate-checked `skip_reason`), and `negative_results[]` / `known_limitations[]` whose **key must be present** (empty `[]` = well-formed advisory; absent key = malformed → gate FAIL, the absent-key rule ported from #261 C3).

**Block B — claim→experiment alignment**: the claim manifest gains an optional per-claim `planned_experiment_ids[]` join field, plus a **fourth ref_slug-less aggregate** `experiment_alignment_results[]` (`experiment_alignment_result.schema.json`) with a MECE verdict enum `{ALIGNED, OVERSTATED, NOT_SUPPORTED_BY_PROVENANCE, PROVENANCE_INSUFFICIENT}`. The verdict is **produced by the integrity verification agent AT the gate** (Stage 2.5 sampling / 4.5 full), mirroring #261 C3 — so the row is emitted and gated in the same pass, avoiding the stage-ordering race. A **mixed-evidence claim** (both `planned_refs` and `planned_experiment_ids`) is audited by both paths; the gate decision is **worst-verdict-wins**. `PROVENANCE_MISSING` is deliberately NOT a verdict — a dangling `experiment_id` is a structural lint FAIL, never a fake judge row.

### Invariants + enforcement layering

Seven cross-array lint invariants in `check_claim_audit_consistency.py`: EP-INV-1 (experiment_id unique), EP-INV-2 (planned_experiment_ids resolve — rename + forward-reference guard), EP-INV-3 (experiment ids ⟹ empirical; mixed allowed), EP-INV-4 (declaration↔provenance symmetry), EP-INV-5 (declaration well-formedness when present), EA-INV-1 (finding_id unique), EA-INV-2 (alignment-row references resolve).

The persisted passport-level `experiment_intake_declaration` gives a **fail-closed legacy boundary, split by enforcement layer (stated precisely)**: the lint deterministically enforces declaration↔provenance *symmetry* (EP-INV-4) and *well-formedness* (EP-INV-5); the **integrity gate — not the lint — owns the `ars_version` numeric legacy decision and the declaration-presence FAIL** (a passport is `legacy_unknown` only with positive `ars_version < #260-constant` proof, everything else is treated-as-post-#260 so the declaration is REQUIRED). The `ars_version` numeric half is kept at the gate layer by design: the release constant it compares against is frozen at ship time, not at intake.

### Three documented departures from the issue's literal text

Each corrected after a first-party read of the **tracked** repo:

1. `repro_lock` is an inline-prose object, not a schema file — so "inherit repro_lock" means nesting the shape, not `$ref`'ing a non-existent file. A shared `repro_lock_validation.py` single-sources the field set (imported by both checkers) with a drift test.
2. The claim manifest had **no** experiment pathway (`additionalProperties:false`, `planned_refs` is literature-only) — the join field is **added**, not assumed.
3. "Path X / Tier-1 required / writer-binding" are not named conventions in the tracked repo — the discipline is **described**, not cited by a name a reader cannot find.

Two spec-table typos found during implementation were also corrected (the `report_compiler_agent` path and the pytest invocation path).

### Producers (taught in lockstep)

Three manifest writers (`synthesis_agent` / `draft_writer_agent` / `report_compiler_agent`) emit `planned_experiment_ids` when an experiment backs a claim; the integrity agent gains a disclosure-only Phase carrying the POSITIONING non-goal verbatim; the orchestrator carries the aggregate + declaration forward; README intake detection sets the declaration.

### Tests

`examples/passport_with_experiment_provenance.yaml` (2 experiments, a mixed-evidence claim, an OVERSTATED row) + full TDD suite: schema ±, fail-closed symmetry, declaration well-formedness, mixed-evidence two-row, verdict-derivation, mutation-verified non-vacuous invariants (each fixture flips fully clean when its invariant is neutralised), reverse-invariant producer pins, repro_lock drift, literature-only regression, and a documented D4-c carve-out boundary (the experiment carve-out is the LLM-caller's job; the deterministic detector is manifest-unaware — pinned explicitly so a future deterministic caller is on notice). Full suite green (2347 passed, 0 regression).

### Not a release

CHANGELOG `[Unreleased]`. All schemas, the manifest field, and all seven invariants are additive and backward-compatible.

Closes #260

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-08 22:10:03 +08:00

101 lines
3.6 KiB
Python

"""Drift guard: the nested repro_lock sub-shape in
experiment_provenance_entry.schema.json MUST stay in sync with the canonical
field set in scripts/repro_lock_validation.py (#260 D1 drift guard).
The repro_lock shape is now declared in TWO places:
1. scripts/repro_lock_validation.py REQUIRED_* constants (single source of
truth, also imported by scripts/check_repro_lock.py).
2. The nested `repro_lock` sub-object inside
shared/contracts/passport/experiment_provenance_entry.schema.json (each
experiment_provenance[] entry carries its own lock).
Without this test the two copies silently diverge over time — a field added to
the standalone validator but forgotten in the nested schema (or vice versa)
would pass every other gate. This test asserts the required-key sets are equal
at the top level AND inside each sub-block, so the two declarations cannot drift.
Run:
python -m unittest scripts.test_repro_lock_validation_drift -v
"""
from __future__ import annotations
import json
import sys
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "scripts"))
from repro_lock_validation import ( # noqa: E402
REQUIRED_CROSSMODEL,
REQUIRED_EXTERNAL,
REQUIRED_FIELDS,
REQUIRED_MATERIALS,
REQUIRED_MODEL,
REQUIRED_PROMPTS,
)
ENTRY_SCHEMA = REPO / "shared/contracts/passport/experiment_provenance_entry.schema.json"
def _nested_repro_lock() -> dict:
schema = json.loads(ENTRY_SCHEMA.read_text(encoding="utf-8"))
return schema["properties"]["repro_lock"]
class ReproLockDriftTest(unittest.TestCase):
"""The nested schema's required keys equal the shared canonical constants."""
def setUp(self) -> None:
self.lock = _nested_repro_lock()
def test_top_level_required_matches_canonical(self) -> None:
nested = set(self.lock["required"])
self.assertEqual(
nested,
REQUIRED_FIELDS,
msg="nested repro_lock top-level required drifted from "
f"repro_lock_validation.REQUIRED_FIELDS: only-in-schema="
f"{nested - REQUIRED_FIELDS}, only-in-constant={REQUIRED_FIELDS - nested}",
)
def test_sub_block_required_match_canonical(self) -> None:
cases = {
"model": REQUIRED_MODEL,
"prompts": REQUIRED_PROMPTS,
"materials": REQUIRED_MATERIALS,
"external_protocols": REQUIRED_EXTERNAL,
"cross_model": REQUIRED_CROSSMODEL,
}
for block, expected in cases.items():
with self.subTest(block=block):
nested = set(self.lock["properties"][block]["required"])
self.assertEqual(
nested,
expected,
msg=f"nested repro_lock.{block}.required drifted: "
f"only-in-schema={nested - expected}, only-in-constant={expected - nested}",
)
def test_check_repro_lock_imports_shared_constant(self) -> None:
"""check_repro_lock.py re-exports the shared constant (no duplicate copy).
Pins that the standalone validator imports from repro_lock_validation
rather than re-declaring REQUIRED_FIELDS — the drift guard is only sound
if BOTH copies trace back to the single source.
"""
import check_repro_lock
self.assertIs(
check_repro_lock.REQUIRED_FIELDS,
REQUIRED_FIELDS,
msg="check_repro_lock.REQUIRED_FIELDS is not the shared "
"repro_lock_validation.REQUIRED_FIELDS object — a duplicate copy "
"would defeat the drift guard.",
)
if __name__ == "__main__":
unittest.main()