mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
d0663404ff
## #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)
101 lines
3.6 KiB
Python
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()
|