mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
75070eec84
* feat(evals): #653 reviewer-calibration suite scaffolding — corpus assembler, isolated dispatcher, deterministic scorer, pre-registered rubric/RUN_PLAN (corpus freeze pending PDF access) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2iNYa6YYYaPUwD2Z2Jr5e * feat(evals): #653 freeze the ICLR 2026 calibration corpus manifest (12 papers) + shared PDF-text normalization Corpus freeze (PR-A of #653): `corpus/papers.json` (label-free, 6+6 ICLR 2026 papers by the pre-registered seed; pypdf 6.11.0; pool hashes unchanged from the 2026-08-07 selection) and `manifests/gold_labels.json` (public Decision note ids + strings). No page-cap exclusion fired; `verify` PASS. First real-PDF contact found a hashing defect: pypdf emits lone UTF-16 surrogates from math fonts (61 in one sampled manuscript) and strict UTF-8 encoding raised, so `extracted_text_sha256` was uncomputable. The normalization now lives in one shared module (`scripts/_calibration_pdf_text.py`: NFC + lone-surrogate -> U+FFFD), imported by both the assembler and the dispatcher so freeze/verify/dispatch hash identical bytes; the rule is recorded in the manifest's `extraction.text_normalization` and `verify` fails hard on rule drift (a rule, not a version). Two tests added (41 total). `scripts/fetch_calibration_corpus.py` is the authenticated OpenReview operator tool that produces the freeze input, so the "third-party reconstruction" claim in the README is backed by a runnable path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EehvYnmG8Xym5tXhTLfVm1 * refactor(evals): #653 simplify pass — shared hashing/fence/git-state, contract 1.1 docs /simplify findings applied (reuse, simplification, efficiency, altitude): - `_calibration_pdf_text.py` owns `sha256_hex` + `pdf_facts` (bytes hashed and parsed from one read via BytesIO; `extract_text=False` lets `verify` skip extraction when the pypdf version cannot be compared); surrogate replacement is one `re.sub` pass. Both the assembler and the dispatcher import it. - dispatcher reuses E4's closed data-fence grammar (`_delimited`), `_git_state` (declares unknown provenance dirty instead of raising), and the evidence path guard (`assert_plain_file`: rejects symlinked parent components, not just the leaf); one `_prepare` preamble for both stages; a text-hash mismatch now names its cause (installed vs manifest pypdf version). - assembler: exclusion rows stay dicts, `pool_list_mismatches` shared by freeze/verify, exclusion set built once. - scorer: `confusion`/`bootstrap_ci` take (predicted, gold) pairs (same RNG stream as before), `Counter` for the exact-mode vote, dead `_path` dropped. - RUN_PLAN/README: measurement contract 1.0 is closed to new rows (#664); the run publishes under 1.1 with its pre-registration record + write-once execution manifest (dispatcher/scorer support lands with the scored run). Re-freeze after the refactor reproduces papers[] and gold_labels byte-for-byte. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EehvYnmG8Xym5tXhTLfVm1 * fix(evals): #653 Iron Rule #7 at the two whole-file call boundaries + paper-id shape check Security review round 1 (first-party) found two below-threshold gaps and both are verified real: - The calibration dispatcher omitted E4's `DATA_BOUNDARY` sentence on the field-analyst call (the one E4 call that carries it, because `field_analyst_agent.md` states no untrusted-material rule of its own). Restored, and a fitted `REPORT_BOUNDARY` added on the synthesizer call, whose agent file is likewise dispatched whole with no such rule. Pinned by a transport-capture test that checks both sentences precede their fence. - Paper ids are spliced into file names (`<id>.pdf`, `cards/<id>/`) but `load_pool` accepted any non-empty string. Ids now must match `^[A-Za-z0-9_-]+$` (OpenReview's forum-id shape) in the assembler and the fetch tool; test pins the refusal. 43 tests pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EehvYnmG8Xym5tXhTLfVm1 * fix(evals): #653 codex round 1 — dispatch/verify invariant parity, card-path guard, scorer completeness Codex round 1 (gpt-6-astra xhigh) findings 2-7, 10, 11 and the cheap half of 9, each re-verified first-party before the change: - dispatcher: frozen cards go through the same plain-file guard as PDFs and agent files (a symlinked card1.md -> gold_labels.json was readable); the manifest's text_normalization rule and page_count are checked before dispatch, so dispatch and verify enforce the same manuscript invariants; transport-failure artifacts keep the partial stdout and stderr verbatim; every call attempt records RFC-3339 start/complete and prompt/output hashes into the panel record and cards freeze (the per-call evidence the heldout-measurement/1.1 execution manifest is built from). - verify: label must match decision_raw under the label transform; paper count and per-class label counts must equal the recorded quotas (synchronized paper+label removal no longer passes). - scorer: a second record for the same paper/replicate is a hard error, not a silent overwrite; a gold paper with no complete ensemble blocks the full tier; an A1 override needs its verbatim `raw` excerpt present in synthesis.md. Nine regression tests added (52 total). Real-corpus verify still PASS. Not addressed here (need a decision): finding 1 (camera-ready format leaks the accept label) and finding 8 (numeric seat scores vs categorical seat contract); finding 9's manifest/row builders land with the scored run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EehvYnmG8Xym5tXhTLfVm1 * fix(evals): #653 drop the numeric score axis — protocol Phase 2 forbids AUC, seats are categorical Codex round 1 finding 8, verified against the source: the seat contract (eic/methodology/... agents) emits criterion-bound categorical judgements and states "Do not total, weight, average"; `calibration_mode_protocol.md` Phase 2 says "Do not report AUC: there is no continuous rubric score." The scorer nevertheless extracted a `Weighted Average` figure (a retired field) and RUN_PLAN promised AUC + score variance, so a conforming run would have published null numerics against a plan that promised them. The scorer now reports only what the protocol's full-tier table names: confusion matrix, balanced accuracy, FNR, FPR (bootstrap CIs), exact-label agreement (count/share/target-set size, with the binary-gold caveat), and replicate stability as categorical agreement (on side, on exact label). AUC is emitted as an explicit NOT REPORTED line. RUN_PLAN and the test fixtures follow. 52 tests pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EehvYnmG8Xym5tXhTLfVm1 * docs(evals): #653 mark the 2026-09-06 corpus SUPERSEDED (layout leaks the label, #828); RUN_PLAN model currency - README/RUN_PLAN: the frozen ICLR 2026 corpus is a harness-rehearsal corpus only — camera-ready replacement makes accepted PDFs visibly different from rejected submission PDFs (6/6 + 6/6; 30/30 in a fresh accepted-pool sample). No profile or measurement row may be published from it; the gold corpus becomes an ICLR 2027 submission-time capture. The "Why ICLR 2026" rationale is kept as pre-registered and annotated with the two facts that now cut against it (layout leak; Fable 5.1's 2026-06 cutoff covers the decisions). - RUN_PLAN + dispatcher default: subject `claude-fable-5` -> `claude-fable-5-1`, judge `gpt-5.6-sol` -> `gpt-6-astra` (provisional, #783 policy). Pre-dispatch edits, not amendments. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EehvYnmG8Xym5tXhTLfVm1 * feat(evals): #828 layout-tell guard at corpus freeze — refuse a corpus whose page-1 layout is not constant `assemble_calibration_corpus.py freeze` now reads page 1 of every cached PDF and evaluates four venue-template signals (published-as header, under-review header, "Anonymous authors", >=10 bare three-digit line numbers). Any signal that is not constant across the whole corpus refuses the freeze with the per-class counts; a uniform corpus records `layout_tell_check` in papers.json. `verify` recomputes the same check (skipped with a warning when a PDF is not cached; a manifest without the block warns). On the superseded 2026-09-06 ICLR 2026 corpus every signal is 6/0, so `verify` now FAILs on it by design. Shared `_open_reader` + `first_page_text` in the PDF helper. Six tests (signal detection, full and partial separation refused, uniform freeze + verify round-trip, missing-PDF skip, pre-check manifest warning). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014bJSoFkGeRaWTMJodk4VGh * feat(evals): #653/#828 rehearsal fixes + heldout-measurement/1.1 manifest and row builders Rehearsal 2026-09-06 (2 papers x 1 replicate, blocked at the first call by a rejected API key) exposed three dispatcher gaps, all fixed with tests: - credential preflight: zero-cost `GET /v1/models` before the first billed call; a definitive 401/403 refuses (key never echoed), network trouble is `inconclusive` and proceeds; outcome recorded in every record - credential rejection mid-run (`Failed to authenticate` / `API Error: 401` / `Not logged in`) is never retried (`CredentialRejected`); other transport failures keep the single retry - an aborted cards stage writes `runs/blocked-cards-<paper>.json` with its per-call rows instead of losing them; both stages share one record writer 1.1 contract substrate (RUN_PLAN "pre-registration record + execution manifest" item): - `dispatch_calibration_panel.py --stage manifest` folds the completed call rows of one attempt (frozen cards + panel records; `load_attempt` refuses mixed attempt identities) into a write-once, schema-validated `execution-manifest.json` - `build_calibration_measurement_row.py` composes the 1.1 row: plan and rubric hashed and compared against `frozen_commit` (drift refuses; dirty commit refuses), manifest re-derived from the records and compared field-for-field, judge rows required (no judges, no row), agreement recomputed by the checker's own `judge_divergence` (extracted from `check_heldout_measurement_report.py`, behaviour unchanged), validated by the checker before a write-once write - adjudication rubric gains `## Resolution direction` (flags_only, I13 lower-bound labelling); README tooling section; RUN_PLAN names the row builder; DATA_FLOWS names the dispatcher's preflight touchpoint; scorer docstring de-staled (no score axis); pytest manifest +1 No calibration number is recorded anywhere in the repository. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014bJSoFkGeRaWTMJodk4VGh * fix(evals): #653/#828 codex round 2 — bind every row input to its attempt, harden the guards 12 of 13 findings applied (gpt-6-astra xhigh, read-only exec): - P1 foreign metrics: scorer output is bound to the attempt (per_panel keys == the complete panel records here, attempt ids match, n_papers matches) - P1 raw drift: record admission re-hashes every completed call's raw output against output_sha256 (manifest stage and row builder alike); prompts are not retained (they embed the manuscript) - P1 preflight redirects: the probe uses a no-redirect opener (a 3xx is `inconclusive`) and skips a non-https ANTHROPIC_BASE_URL - P2 estimand: class-A adjudication is now pre-registered as bidirectional (every synthesis decision transcribed blind and compared with the grammar), so the row publishes a point_estimate instead of an I13 "lower bound" that only meant audit coverage - P2 pre-write parity with R5: manifest timestamps parsed and ordered before the write; declared claims checked against the local manifest - P2 strict JSON: inputs parsed with the checker's strict loader, outputs serialized with allow_nan=False and round-tripped - P2 judge failures: `--blocked-run` ledger entries merge into attempts.blocked_runs (I11) - P2 admission by content: suite/stage/status/provenance from the record body, never the filename; blocked records are identity-checked too - P2 cards re-run: a reused evidence dir refuses (write-once stage records) - P2 auth signature: anchored at the start of stdout/stderr and limited to exit-code failures; a timeout's partial prose is never a credential error - P2 layout signals: phrase tests run on whitespace-folded text - P2 partial PDF cache: verify checks every cached PDF (can refuse, cannot clear) instead of skipping the guard - P3 real `git show` test for sha256_at_commit on a temporary repository Partially applied: "distinguish unobservable signals from absence" (not built; the constancy rule is pre-registered as stricter by design). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014bJSoFkGeRaWTMJodk4VGh * fix(evals): #653/#828 shared transport — capture every assistant message, fence the subject's config Rehearsal take 2 (2026-09-06/07, 8 billed calls on the first paper) found two transport defects in `ClaudeCliTransport`, shared by the E4 and the calibration dispatchers: - text-mode `claude -p` prints only the LAST assistant message: the first paper's synthesis (long enough to be continued) came back starting mid-table, with the Editorial Decision Letter and its `### Decision:` line in the missing head. The transport now runs `--output-format stream-json --verbose` and concatenates the text blocks of every assistant message; an error result or an unreadable stream is a TransportFailure that keeps the raw bytes. - `--bare` does not fence the subject: a two-call probe on 2.1.260 showed the operator's whole global CLAUDE.md arriving as a system-reminder, plus `settings.json` `language` and the output style (the seats appended Traditional-Chinese "plain-language summary" sections). The subject now runs with an allowlisted environment (PATH/HOME/LANG/TMPDIR/TERM/USER/ SHELL + ANTHROPIC_*; no CLAUDE_* inherited from a parent session) and a per-transport empty `CLAUDE_CONFIG_DIR`; the same probe then reported no instruction beyond the SDK identity line and the date. E4 tests: one fake updated to emit stream-json; five new tests (message joining, error/junk results, unreadable-stream failure with bytes, environment allowlist, argv/env of a live call). Calibration docs and the panel record's `dispatch` field describe the new recipe (pre-dispatch change, no amendment). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014bJSoFkGeRaWTMJodk4VGh * fix(evals): #653/#828 codex round 3 on the shared transport — eviction signals, LF framing, network env, failure evidence Five P2 findings (gpt-6-astra xhigh, read-only exec), all applied: - refusal-fallback eviction: assistant `supersedes` and system `model_refusal_fallback.retracted_message_uuids` (wire fields verified in the installed CLI 2.1.260) drop retracted partials before concatenation - NDJSON split on LF only (`str.splitlines` also splits on U+0085 / U+2028 / U+2029 inside a JSON string); CRLF tolerated - environment allowlist keeps documented network/TLS inputs (proxies, NODE_EXTRA_CA_CERTS, SSL_CERT_*, CLAUDE_CODE_CLIENT_*); an apiKeyHelper that needs more is documented as unsupported behind the fence - transport failures carry assistant TEXT in `stdout` and the raw stream in `raw_stdout`; a framing-only stream is "no model response" (E4 no longer writes stream metadata as a partial response); both dispatchers preserve the raw stream as `*.transport-stream.jsonl` - a structured error result (`[TRANSPORT: result <subtype>]`, diagnostic in stdout) is classified by the calibration retry loop like the plain-text startup failure: a credential rejection is never retried E4 tests +6 (256), calibration +1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014bJSoFkGeRaWTMJodk4VGh * feat(evals): #653/#828 keep the raw stream of successful calls as evidence `ClaudeCliTransport.last_raw_stdout` exposes the stream-json framing of the most recent successful call; the calibration dispatcher writes it next to the text as `<label>.transport-stream.jsonl`, so the next rehearsal shows how many assistant messages a deliverable spanned (the 2026-09-06 synthesis lost its head to exactly that). Probe 2026-09-07: a 12,000-line reply at effort low arrived as ONE text message after a thinking-only message, so the head loss is attributed to multiple text messages in one turn (likely interleaved thinking at xhigh), not to an output-length continuation; the parser covers both. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014bJSoFkGeRaWTMJodk4VGh * fix(evals): #653/#828 allow requiring a successful credential preflight * fix(calibration): bind audited decisions and preserve failed dispatch evidence * fix(transport): retain truncated UTF-8 output as byte evidence --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
719 lines
31 KiB
Python
719 lines
31 KiB
Python
"""Mutation tests for dispatch_calibration_panel.py (#653). Offline via ScriptedTransport."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import ssl
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
import dispatch_calibration_panel as mod
|
|
from _calibration_pdf_text import TEXT_NORMALIZATION, pdf_facts
|
|
|
|
pypdf = pytest.importorskip("pypdf")
|
|
|
|
ANALYSIS = """# Field Analysis
|
|
|
|
## Reviewer Configuration Cards
|
|
|
|
### Card #1: EIC
|
|
eic config
|
|
|
|
### Card #2: Methodology
|
|
methodology config
|
|
|
|
### Card #3: Domain
|
|
domain config
|
|
|
|
### Card #4: Perspective
|
|
perspective config
|
|
|
|
## Review Strategy Recommendations
|
|
panel-wide notes that must never reach a seat
|
|
"""
|
|
|
|
SEAT_REPORT = "## Review\n\nfindings\n\nWeighted Average: 61.0\n"
|
|
SYNTHESIS = "# Part 1\n\n### Decision: [Major Revision]\n\n# Part 2\nroadmap\n"
|
|
|
|
|
|
def make_pdf(path: Path, pages: int = 1) -> None:
|
|
writer = pypdf.PdfWriter()
|
|
for _ in range(pages):
|
|
writer.add_blank_page(width=200, height=200)
|
|
with path.open("wb") as handle:
|
|
writer.write(handle)
|
|
|
|
|
|
def pdf_hashes(path: Path) -> tuple[str, str]:
|
|
pdf_sha, text_sha, _, _ = pdf_facts(path)
|
|
return pdf_sha, text_sha
|
|
|
|
|
|
@pytest.fixture()
|
|
def env(tmp_path):
|
|
corpus_dir = tmp_path / "suite"
|
|
(corpus_dir / "corpus").mkdir(parents=True)
|
|
(corpus_dir / "manifests").mkdir()
|
|
pdf_cache = tmp_path / "pdfs"
|
|
pdf_cache.mkdir()
|
|
make_pdf(pdf_cache / "p1.pdf")
|
|
pdf_sha, text_sha = pdf_hashes(pdf_cache / "p1.pdf")
|
|
(corpus_dir / "corpus" / "papers.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"suite": "reviewer_calibration",
|
|
"extraction": {
|
|
"tool": "pypdf",
|
|
"pypdf_version": pypdf.__version__,
|
|
"text_normalization": TEXT_NORMALIZATION,
|
|
},
|
|
"papers": [
|
|
{
|
|
"paper_id": "p1",
|
|
"title": "T",
|
|
"pdf_url": "https://openreview.net/pdf?id=p1",
|
|
"pdf_sha256": pdf_sha,
|
|
"extracted_text_sha256": text_sha,
|
|
"page_count": 1,
|
|
"retrieved_at": "2026-08-07T00:00:00Z",
|
|
}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(corpus_dir / "manifests" / "gold_labels.json").write_text("{}", encoding="utf-8")
|
|
work = tmp_path / "work"
|
|
return {"corpus": corpus_dir, "cache": pdf_cache, "work": work}
|
|
|
|
|
|
def base_argv(env, stage, replicate=1):
|
|
return [
|
|
"--stage", stage, "--paper", "p1", "--replicate", str(replicate),
|
|
"--corpus-dir", str(env["corpus"]), "--pdf-cache", str(env["cache"]),
|
|
"--work-dir", str(env["work"]), "--date", "2026-08-07",
|
|
"--generated-at", "2026-08-07T00:00:00Z", "--attempt-id", "attempt-1",
|
|
"--transport", "scripted",
|
|
]
|
|
|
|
|
|
def scripted(tmp_path, responses):
|
|
path = tmp_path / "responses.json"
|
|
path.write_text(json.dumps(responses), encoding="utf-8")
|
|
return ["--scripted-responses", str(path)]
|
|
|
|
|
|
def run_cards(env, tmp_path, analysis=ANALYSIS):
|
|
return mod.main(
|
|
base_argv(env, "cards") + scripted(tmp_path, {"field_analyst": [analysis]})
|
|
)
|
|
|
|
|
|
def panel_responses():
|
|
return {
|
|
"seat-eic": [SEAT_REPORT],
|
|
"seat-methodology": [SEAT_REPORT],
|
|
"seat-domain": [SEAT_REPORT],
|
|
"seat-perspective": [SEAT_REPORT],
|
|
"seat-da": ["## DA Review\n\nchallenges\n"],
|
|
"synthesis": [SYNTHESIS],
|
|
}
|
|
|
|
|
|
def test_cards_stage_freezes_four_cards(env, tmp_path):
|
|
assert run_cards(env, tmp_path) == 0
|
|
cards_dir = env["work"] / "cards" / "p1"
|
|
for index, expected in ((1, "eic config"), (2, "methodology config"),
|
|
(3, "domain config"), (4, "perspective config")):
|
|
text = (cards_dir / f"card{index}.md").read_text()
|
|
assert expected in text
|
|
assert "panel-wide notes" not in text
|
|
frozen = json.loads((cards_dir / "frozen.json").read_text())
|
|
assert frozen["paper_id"] == "p1"
|
|
|
|
|
|
def test_cards_stage_refuses_missing_card(env, tmp_path):
|
|
truncated = ANALYSIS.replace("### Card #4: Perspective\nperspective config\n", "")
|
|
assert run_cards(env, tmp_path, analysis=truncated) == 1
|
|
blocked = json.loads((env["work"] / "runs" / "blocked-cards-p1.json").read_text())
|
|
assert "Card #4" in blocked["abort_reason"]
|
|
assert [c["outcome"] for c in blocked["calls"]] == ["completed"]
|
|
assert not (env["work"] / "cards" / "p1" / "frozen.json").exists()
|
|
assert not (env["work"] / "cards" / "p1" / "card1.md").exists()
|
|
|
|
|
|
def test_panel_complete_record_and_raw(env, tmp_path):
|
|
assert run_cards(env, tmp_path) == 0
|
|
assert mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses())) == 0
|
|
record = json.loads((env["work"] / "runs" / "2026-08-07-p1-r1.json").read_text())
|
|
assert record["status"] == "complete"
|
|
assert record["substrate_plan"] == "primary_only"
|
|
assert record["suite"] == "reviewer_calibration"
|
|
assert len(record["completed_calls"]) == 6
|
|
raw = env["work"] / "runs" / "2026-08-07-p1-r1" / "raw"
|
|
assert (raw / "synthesis.md").read_text() == SYNTHESIS
|
|
assert (raw / "seat-da.md").is_file()
|
|
|
|
|
|
def test_panel_without_frozen_cards_aborts(env, tmp_path):
|
|
rc = mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses()))
|
|
assert rc == 1
|
|
blocked = json.loads((env["work"] / "runs" / "blocked-2026-08-07-p1-r1.json").read_text())
|
|
assert blocked["status"] == "aborted"
|
|
assert "Card #1" in blocked["abort_reason"]
|
|
|
|
|
|
def test_panel_missing_response_emits_blocked_record(env, tmp_path):
|
|
assert run_cards(env, tmp_path) == 0
|
|
responses = panel_responses()
|
|
responses.pop("synthesis")
|
|
rc = mod.main(base_argv(env, "panel") + scripted(tmp_path, responses))
|
|
assert rc == 1
|
|
blocked = json.loads((env["work"] / "runs" / "blocked-2026-08-07-p1-r1.json").read_text())
|
|
assert blocked["status"] == "aborted"
|
|
assert "seat-da" in blocked["completed_calls"]
|
|
|
|
|
|
def test_synthesizer_never_sees_manuscript(env, tmp_path, monkeypatch):
|
|
transports = []
|
|
real_build = mod.build_transport
|
|
|
|
def capture(args):
|
|
transport = real_build(args)
|
|
transports.append(transport)
|
|
return transport
|
|
|
|
monkeypatch.setattr(mod, "build_transport", capture)
|
|
assert run_cards(env, tmp_path) == 0
|
|
assert mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses())) == 0
|
|
seen = {call.label: call for transport in transports for call, _ in transport.calls}
|
|
synthesis_call = seen["synthesis"]
|
|
assert f"<{mod.MANUSCRIPT_TAG}>" not in synthesis_call.user
|
|
assert not synthesis_call.paper_visible
|
|
for seat in mod.SEATS:
|
|
assert f"<{mod.MANUSCRIPT_TAG}>" in seen[f"seat-{seat}"].user
|
|
|
|
|
|
def test_gold_labels_never_on_read_path(env, tmp_path, monkeypatch):
|
|
"""Mutation guard: dispatching a full panel never opens gold_labels.json."""
|
|
labels = env["corpus"] / "manifests" / "gold_labels.json"
|
|
opened = []
|
|
real_read_text = Path.read_text
|
|
|
|
def spy(self, *a, **kw):
|
|
if self.name == "gold_labels.json":
|
|
opened.append(self)
|
|
return real_read_text(self, *a, **kw)
|
|
|
|
monkeypatch.setattr(Path, "read_text", spy)
|
|
assert run_cards(env, tmp_path) == 0
|
|
assert mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses())) == 0
|
|
assert opened == []
|
|
assert labels.is_file()
|
|
|
|
|
|
def test_replicate_cannot_overwrite_existing_evidence(env, tmp_path):
|
|
assert run_cards(env, tmp_path) == 0
|
|
assert mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses())) == 0
|
|
with pytest.raises(mod.PreconditionFailure, match="already holds content"):
|
|
mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses()))
|
|
|
|
|
|
def test_pdf_hash_mismatch_refused(env, tmp_path):
|
|
make_pdf(env["cache"] / "p1.pdf", pages=2) # overwrite: different doc
|
|
with pytest.raises(mod.PreconditionFailure, match="pdf_sha256 mismatch"):
|
|
run_cards(env, tmp_path)
|
|
|
|
|
|
def test_symlink_in_pdf_cache_refused(env, tmp_path):
|
|
os.symlink(
|
|
env["corpus"] / "manifests" / "gold_labels.json", env["cache"] / "labels.json"
|
|
)
|
|
with pytest.raises(mod.PreconditionFailure, match="symlink"):
|
|
run_cards(env, tmp_path)
|
|
|
|
|
|
def test_work_dir_inside_repo_refused(env, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(mod, "REPO", env["work"].parent)
|
|
with pytest.raises(mod.PreconditionFailure, match="outside the repository"):
|
|
run_cards(env, tmp_path)
|
|
|
|
|
|
def test_fence_collision_refused():
|
|
with pytest.raises(mod.PreconditionFailure, match="closing delimiter"):
|
|
mod._fence("paper_content", "text with </paper_content> inside")
|
|
|
|
|
|
def test_untrusted_blocks_carry_boundary_sentences(env, tmp_path, monkeypatch):
|
|
"""Mutation guard: the two whole-file calls (field analyst, synthesizer)
|
|
state Iron Rule #7 at the call boundary, ahead of the fenced block."""
|
|
seen = []
|
|
real_build = mod.build_transport
|
|
|
|
def capture(args):
|
|
transport = real_build(args)
|
|
seen.append(transport)
|
|
return transport
|
|
|
|
monkeypatch.setattr(mod, "build_transport", capture)
|
|
assert run_cards(env, tmp_path) == 0
|
|
assert mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses())) == 0
|
|
calls = {call.label: call for transport in seen for call, _ in transport.calls}
|
|
analyst = calls["field_analyst"].user
|
|
assert mod.DATA_BOUNDARY in analyst
|
|
assert analyst.index(mod.DATA_BOUNDARY) < analyst.index(f"<{mod.MANUSCRIPT_TAG}>")
|
|
synthesis = calls["synthesis"].user
|
|
assert mod.REPORT_BOUNDARY in synthesis
|
|
assert synthesis.index(mod.REPORT_BOUNDARY) < synthesis.index(f"<{mod.REPORT_TAG}>")
|
|
|
|
|
|
def _edit_manifest(env, mutate):
|
|
path = env["corpus"] / "corpus" / "papers.json"
|
|
payload = json.loads(path.read_text())
|
|
mutate(payload)
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
|
|
def test_normalization_rule_drift_refused(env, tmp_path):
|
|
_edit_manifest(env, lambda p: p["extraction"].update(text_normalization="NFC"))
|
|
with pytest.raises(mod.PreconditionFailure, match="text_normalization"):
|
|
run_cards(env, tmp_path)
|
|
|
|
|
|
def test_page_count_mismatch_refused(env, tmp_path):
|
|
_edit_manifest(env, lambda p: p["papers"][0].update(page_count=7))
|
|
with pytest.raises(mod.PreconditionFailure, match="page_count mismatch"):
|
|
run_cards(env, tmp_path)
|
|
|
|
|
|
def test_symlinked_frozen_card_refused(env, tmp_path):
|
|
"""A card pointing at gold_labels.json must not reach any seat prompt."""
|
|
assert run_cards(env, tmp_path) == 0
|
|
card = env["work"] / "cards" / "p1" / "card1.md"
|
|
card.unlink()
|
|
card.symlink_to(env["corpus"] / "manifests" / "gold_labels.json")
|
|
rc = mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses()))
|
|
assert rc == 1
|
|
blocked = json.loads((env["work"] / "runs" / "blocked-2026-08-07-p1-r1.json").read_text())
|
|
assert "frozen card" in blocked["abort_reason"] and "symlink" in blocked["abort_reason"]
|
|
|
|
|
|
def test_records_carry_per_call_timing_and_hashes(env, tmp_path):
|
|
assert run_cards(env, tmp_path) == 0
|
|
assert mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses())) == 0
|
|
record = json.loads((env["work"] / "runs" / "2026-08-07-p1-r1.json").read_text())
|
|
assert [c["call"] for c in record["calls"]] == [f"seat-{s}" for s in mod.SEATS] + ["synthesis"]
|
|
for row in record["calls"]:
|
|
assert row["outcome"] == "completed"
|
|
assert row["started_at"] <= row["completed_at"]
|
|
assert len(row["prompt_sha256"]) == 64 and len(row["output_sha256"]) == 64
|
|
frozen = json.loads((env["work"] / "cards" / "p1" / "frozen.json").read_text())
|
|
assert frozen["calls"][0]["call"] == "field_analyst"
|
|
|
|
|
|
# --- 2026-09-06 rehearsal findings (#828) ----------------------------------
|
|
|
|
import urllib.error # noqa: E402
|
|
|
|
from dispatch_e4_panel import TransportFailure # noqa: E402
|
|
|
|
|
|
class _RaisingTransport:
|
|
"""Raises queued TransportFailures per label before replaying responses."""
|
|
|
|
def __init__(self, failures: dict[str, list[TransportFailure]], responses: dict[str, list[str]]):
|
|
self.failures = {k: list(v) for k, v in failures.items()}
|
|
self.responses = {k: list(v) for k, v in responses.items()}
|
|
self.calls: list[str] = []
|
|
|
|
def __call__(self, call, sandbox):
|
|
self.calls.append(call.label)
|
|
queue = self.failures.get(call.label)
|
|
if queue:
|
|
raise queue.pop(0)
|
|
return self.responses[call.label].pop(0)
|
|
|
|
|
|
def parsed(env, stage, extra=()):
|
|
return mod.build_parser().parse_args(base_argv(env, stage) + list(extra))
|
|
|
|
|
|
AUTH_FAILURE = TransportFailure(
|
|
"field_analyst", "[TRANSPORT: exit 1]",
|
|
stdout="Failed to authenticate. API Error: 401 API key is invalid.\n",
|
|
)
|
|
|
|
|
|
def test_auth_failure_is_not_retried_and_cards_abort_leaves_blocked_record(env, tmp_path):
|
|
transport = _RaisingTransport({"field_analyst": [AUTH_FAILURE, AUTH_FAILURE]}, {})
|
|
assert mod.stage_cards(parsed(env, "cards"), transport) == 1
|
|
assert transport.calls == ["field_analyst"], "a rejected credential must not burn a retry"
|
|
blocked = json.loads((env["work"] / "runs" / "blocked-cards-p1.json").read_text())
|
|
assert blocked["stage"] == "cards" and blocked["status"] == "aborted"
|
|
assert "credential" in blocked["abort_reason"].lower()
|
|
assert blocked["retries"] == []
|
|
assert [c["outcome"] for c in blocked["calls"]] == ["transport_failure"]
|
|
assert blocked["calls"][0]["started_at"] <= blocked["calls"][0]["completed_at"]
|
|
assert not (env["work"] / "cards" / "p1" / "frozen.json").exists()
|
|
assert "sk-" not in json.dumps(blocked)
|
|
|
|
|
|
def test_generic_transport_failure_still_retries_once(env, tmp_path):
|
|
generic = TransportFailure("field_analyst", "[TRANSPORT: exit 1]", stderr="boom")
|
|
transport = _RaisingTransport({"field_analyst": [generic]}, {"field_analyst": [ANALYSIS]})
|
|
assert mod.stage_cards(parsed(env, "cards"), transport) == 0
|
|
assert transport.calls == ["field_analyst", "field_analyst"]
|
|
frozen = json.loads((env["work"] / "cards" / "p1" / "frozen.json").read_text())
|
|
assert [c["outcome"] for c in frozen["calls"]] == ["transport_failure", "completed"]
|
|
assert [c["attempt"] for c in frozen["calls"]] == [1, 2]
|
|
assert len(frozen["retries"]) == 1
|
|
assert frozen["attempt_id"] == "attempt-1" and len(frozen["suite_commit"]) == 40
|
|
|
|
|
|
def test_auth_failure_in_panel_stage_blocks_without_retry(env, tmp_path):
|
|
assert run_cards(env, tmp_path) == 0
|
|
seat_auth = TransportFailure("seat-eic", "[TRANSPORT: exit 1]", stdout="Not logged in\n")
|
|
transport = _RaisingTransport({"seat-eic": [seat_auth, seat_auth]}, panel_responses())
|
|
assert mod.stage_panel(parsed(env, "panel"), transport) == 1
|
|
assert transport.calls == ["seat-eic"]
|
|
record = json.loads((env["work"] / "runs" / "blocked-2026-08-07-p1-r1.json").read_text())
|
|
assert record["retries"] == [] and record["completed_calls"] == []
|
|
|
|
|
|
class _Ctx:
|
|
def __init__(self, status):
|
|
self.status = status
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
|
|
def test_credential_preflight_refuses_rejected_key_without_echoing_it():
|
|
seen = {}
|
|
|
|
def opener(request, timeout):
|
|
seen["url"] = request.full_url
|
|
seen["key"] = request.get_header("X-api-key")
|
|
raise urllib.error.HTTPError(request.full_url, 401, "Unauthorized", {}, None)
|
|
|
|
with pytest.raises(mod.PreconditionFailure) as excinfo:
|
|
mod.credential_preflight({"ANTHROPIC_API_KEY": "sk-ant-test-secret"}, opener=opener)
|
|
assert "401" in str(excinfo.value) and "sk-ant-test-secret" not in str(excinfo.value)
|
|
assert seen["url"].startswith("https://api.anthropic.com/v1/models")
|
|
assert seen["key"] == "sk-ant-test-secret"
|
|
|
|
|
|
def test_credential_preflight_honours_base_url_and_reports_ok():
|
|
def opener(request, timeout):
|
|
assert request.full_url.startswith("https://proxy.example/v1/models")
|
|
return _Ctx(200)
|
|
|
|
env = {"ANTHROPIC_API_KEY": " sk-ant-x ", "ANTHROPIC_BASE_URL": "https://proxy.example/"}
|
|
assert mod.credential_preflight(env, opener=opener) == "ok"
|
|
|
|
|
|
def test_credential_preflight_is_inconclusive_on_network_trouble_and_skips_without_key():
|
|
def opener(request, timeout):
|
|
raise urllib.error.URLError("no route")
|
|
|
|
assert mod.credential_preflight({"ANTHROPIC_API_KEY": "k"}, opener=opener).startswith("inconclusive")
|
|
|
|
def opener_500(request, timeout):
|
|
raise urllib.error.HTTPError(request.full_url, 503, "down", {}, None)
|
|
|
|
assert mod.credential_preflight({"ANTHROPIC_API_KEY": "k"}, opener=opener_500) == "inconclusive: HTTP 503"
|
|
|
|
def never(request, timeout): # pragma: no cover - must not be reached
|
|
raise AssertionError("no key, no probe")
|
|
|
|
assert mod.credential_preflight({}, opener=never).startswith("skipped")
|
|
assert mod.credential_preflight({"ANTHROPIC_API_KEY": " "}, opener=never).startswith("skipped")
|
|
|
|
|
|
@pytest.mark.parametrize("wrapped", [False, True])
|
|
def test_credential_preflight_identifies_tls_trust_failure_without_echoing_reason(wrapped):
|
|
def opener(request, timeout):
|
|
error = ssl.SSLCertVerificationError("untrusted issuer; sk-ant-test-secret")
|
|
raise urllib.error.URLError(error) if wrapped else error
|
|
|
|
outcome = mod.credential_preflight({"ANTHROPIC_API_KEY": "sk-ant-test-secret"}, opener=opener)
|
|
assert outcome == "inconclusive: TLS certificate verification failed"
|
|
assert "sk-ant-test-secret" not in outcome
|
|
|
|
|
|
@pytest.mark.parametrize("stage", ["cards", "panel"])
|
|
@pytest.mark.parametrize("outcome", [
|
|
"inconclusive: TLS certificate verification failed",
|
|
"inconclusive: HTTP 503",
|
|
"skipped: ANTHROPIC_API_KEY unset (apiKeyHelper path is not probed)",
|
|
])
|
|
def test_required_preflight_stops_before_transport_is_constructed(env, monkeypatch, stage, outcome):
|
|
monkeypatch.setattr(mod, "credential_preflight", lambda: outcome)
|
|
|
|
def never(args):
|
|
pytest.fail("a failed required preflight must not construct or call the transport")
|
|
|
|
monkeypatch.setattr(mod, "build_transport", never)
|
|
with pytest.raises(mod.PreconditionFailure, match="no model call was made"):
|
|
mod.main(base_argv(env, stage) + ["--transport", "cli", "--require-preflight-ok"])
|
|
assert not env["work"].exists()
|
|
|
|
|
|
@pytest.mark.parametrize("required,outcome", [
|
|
(True, "ok"),
|
|
(False, "inconclusive: TLS certificate verification failed"),
|
|
])
|
|
def test_preflight_gate_preserves_success_and_explicit_default_fallback(env, monkeypatch, required, outcome):
|
|
transport = mod.ScriptedTransport({"field_analyst": [ANALYSIS]})
|
|
monkeypatch.setattr(mod, "build_transport", lambda args: transport)
|
|
monkeypatch.setattr(mod, "credential_preflight", lambda: outcome)
|
|
argv = base_argv(env, "cards") + ["--transport", "cli"]
|
|
if required:
|
|
argv.append("--require-preflight-ok")
|
|
assert mod.main(argv) == 0
|
|
assert len(transport.calls) == 1
|
|
record = json.loads((env["work"] / "cards/p1/frozen.json").read_text())
|
|
assert record["credential_preflight"] == outcome
|
|
|
|
|
|
def test_scripted_transport_cannot_satisfy_required_live_preflight(env, tmp_path):
|
|
with pytest.raises(mod.PreconditionFailure, match="skipped: scripted transport"):
|
|
mod.main(base_argv(env, "cards") + ["--require-preflight-ok"])
|
|
assert not env["work"].exists()
|
|
|
|
|
|
def _manifest_argv(env, generated_at="2026-08-07T01:00:00Z", extra=()):
|
|
return [
|
|
"--stage", "manifest", "--work-dir", str(env["work"]),
|
|
"--generated-at", generated_at, *extra,
|
|
]
|
|
|
|
|
|
def test_manifest_stage_assembles_completed_calls_and_is_write_once(env, tmp_path):
|
|
assert run_cards(env, tmp_path) == 0
|
|
assert mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses())) == 0
|
|
assert mod.main(_manifest_argv(env)) == 0
|
|
path = env["work"] / "execution-manifest.json"
|
|
manifest = json.loads(path.read_text())
|
|
assert manifest["schema_version"] == "heldout-execution-manifest/1.0"
|
|
assert manifest["suite"] == "reviewer_calibration" and manifest["write_once"] is True
|
|
assert manifest["created_at"] == "2026-08-07T01:00:00Z"
|
|
calls = manifest["calls"]
|
|
ids = [c["call_id"] for c in calls]
|
|
assert ids[0] == "cards-p1/field_analyst" and ids[-1] == "2026-08-07-p1-r1/synthesis"
|
|
assert len(ids) == 7 == len(set(ids))
|
|
assert [c["sequence_index"] for c in calls] == list(range(1, 8))
|
|
for row in calls:
|
|
assert row["attempt"] == 1 and row["concurrency_group"] is None
|
|
assert row["started_at"] <= row["completed_at"]
|
|
window = manifest["execution_window"]
|
|
assert window["started_at"] == calls[0]["started_at"]
|
|
assert window["completed_at"] == max(c["completed_at"] for c in calls)
|
|
jsonschema = pytest.importorskip("jsonschema")
|
|
schema = json.loads(
|
|
(mod.REPO / "evals" / "heldout" / "execution_manifest.schema.json").read_text()
|
|
)
|
|
jsonschema.Draft202012Validator(schema).validate(manifest)
|
|
before = path.read_bytes()
|
|
with pytest.raises(mod.PreconditionFailure, match="write-once"):
|
|
mod.main(_manifest_argv(env, generated_at="2026-08-07T02:00:00Z"))
|
|
assert path.read_bytes() == before
|
|
|
|
|
|
def test_manifest_stage_keeps_retry_attempt_numbers_and_skips_failed_rows(env, tmp_path):
|
|
generic = TransportFailure("field_analyst", "[TRANSPORT: exit 1]", stderr="boom")
|
|
transport = _RaisingTransport({"field_analyst": [generic]}, {"field_analyst": [ANALYSIS]})
|
|
assert mod.stage_cards(parsed(env, "cards"), transport) == 0
|
|
assert mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses())) == 0
|
|
assert mod.main(_manifest_argv(env)) == 0
|
|
calls = json.loads((env["work"] / "execution-manifest.json").read_text())["calls"]
|
|
assert len(calls) == 7
|
|
assert calls[0]["call_id"] == "cards-p1/field_analyst" and calls[0]["attempt"] == 2
|
|
|
|
|
|
def test_manifest_stage_refuses_mixed_attempts_and_blocked_only_work(env, tmp_path):
|
|
assert run_cards(env, tmp_path) == 0
|
|
argv = base_argv(env, "panel")
|
|
argv[argv.index("--attempt-id") + 1] = "attempt-2"
|
|
assert mod.main(argv + scripted(tmp_path, panel_responses())) == 0
|
|
with pytest.raises(mod.PreconditionFailure, match="attempt_id"):
|
|
mod.main(_manifest_argv(env))
|
|
assert not (env["work"] / "execution-manifest.json").exists()
|
|
|
|
other = {"corpus": env["corpus"], "cache": env["cache"], "work": tmp_path / "work2"}
|
|
transport = _RaisingTransport({"field_analyst": [AUTH_FAILURE]}, {})
|
|
assert mod.stage_cards(parsed(other, "cards"), transport) == 1
|
|
with pytest.raises(mod.PreconditionFailure, match="no completed call"):
|
|
mod.main(_manifest_argv(other))
|
|
|
|
|
|
def test_records_carry_credential_preflight_outcome(env, tmp_path):
|
|
assert run_cards(env, tmp_path) == 0
|
|
assert mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses())) == 0
|
|
record = json.loads((env["work"] / "runs" / "2026-08-07-p1-r1.json").read_text())
|
|
frozen = json.loads((env["work"] / "cards" / "p1" / "frozen.json").read_text())
|
|
assert record["credential_preflight"].startswith("skipped")
|
|
assert frozen["credential_preflight"].startswith("skipped")
|
|
|
|
|
|
# --- codex round 2 (2026-09-06) --------------------------------------------
|
|
|
|
def test_auth_signature_ignores_partial_prose_and_timeouts(env, tmp_path):
|
|
timeout = TransportFailure(
|
|
"field_analyst", "[TRANSPORT: TimeoutExpired after 3600s]",
|
|
stdout="Not logged in is what the reviewed UI displays; the paper argues...",
|
|
)
|
|
assert not mod._is_auth_failure(timeout)
|
|
mid_text = TransportFailure("field_analyst", "[TRANSPORT: exit 1]", stdout="Review\n\nNot logged in\n")
|
|
assert not mod._is_auth_failure(mid_text)
|
|
assert mod._is_auth_failure(AUTH_FAILURE)
|
|
assert mod._is_auth_failure(TransportFailure("x", "[TRANSPORT: exit 1]", stderr="Not logged in\n"))
|
|
transport = _RaisingTransport({"field_analyst": [timeout]}, {"field_analyst": [ANALYSIS]})
|
|
assert mod.stage_cards(parsed(env, "cards"), transport) == 0
|
|
assert transport.calls == ["field_analyst", "field_analyst"]
|
|
|
|
|
|
def test_credential_preflight_never_follows_redirects_and_skips_plain_http():
|
|
handler = mod._NoRedirect()
|
|
assert handler.redirect_request(None, None, 302, "Found", {}, "https://elsewhere.example/") is None
|
|
|
|
def opener(request, timeout):
|
|
raise urllib.error.HTTPError(request.full_url, 302, "Found", {"Location": "https://elsewhere.example/"}, None)
|
|
|
|
assert mod.credential_preflight({"ANTHROPIC_API_KEY": "k"}, opener=opener) == "inconclusive: HTTP 302"
|
|
|
|
def never(request, timeout): # pragma: no cover
|
|
raise AssertionError("plain http must not carry the key")
|
|
|
|
outcome = mod.credential_preflight({"ANTHROPIC_API_KEY": "k", "ANTHROPIC_BASE_URL": "http://proxy.local"}, opener=never)
|
|
assert outcome.startswith("skipped") and "https" in outcome
|
|
|
|
|
|
def test_cards_rerun_refuses_reused_evidence_dir(env, tmp_path):
|
|
transport = _RaisingTransport({"field_analyst": [AUTH_FAILURE]}, {})
|
|
assert mod.stage_cards(parsed(env, "cards"), transport) == 1
|
|
blocked = env["work"] / "runs" / "blocked-cards-p1.json"
|
|
before = blocked.read_bytes()
|
|
with pytest.raises(mod.PreconditionFailure, match="fresh work dir"):
|
|
run_cards(env, tmp_path)
|
|
assert blocked.read_bytes() == before
|
|
|
|
|
|
def test_manifest_admits_records_by_content_not_filename(env, tmp_path):
|
|
assert run_cards(env, tmp_path) == 0
|
|
seat_auth = TransportFailure("seat-eic", "[TRANSPORT: exit 1]", stdout="Not logged in\n")
|
|
transport = _RaisingTransport({"seat-eic": [seat_auth]}, panel_responses())
|
|
assert mod.stage_panel(parsed(env, "panel"), transport) == 1
|
|
runs = env["work"] / "runs"
|
|
(runs / "blocked-2026-08-07-p1-r1.json").rename(runs / "2026-08-07-p1-r1.json")
|
|
assert mod.main(_manifest_argv(env)) == 0 # the aborted panel is still listed as blocked
|
|
calls = json.loads((env["work"] / "execution-manifest.json").read_text())["calls"]
|
|
assert [c["call_id"] for c in calls] == ["cards-p1/field_analyst"]
|
|
|
|
|
|
def test_manifest_refuses_edited_raw_output_and_foreign_stage(env, tmp_path):
|
|
assert run_cards(env, tmp_path) == 0
|
|
assert mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses())) == 0
|
|
synthesis = env["work"] / "runs" / "2026-08-07-p1-r1" / "raw" / "synthesis.md"
|
|
synthesis.write_text(SYNTHESIS.replace("Major Revision", "Accept"))
|
|
with pytest.raises(mod.PreconditionFailure, match="no longer hashes"):
|
|
mod.main(_manifest_argv(env))
|
|
synthesis.write_text(SYNTHESIS)
|
|
frozen = env["work"] / "cards" / "p1" / "frozen.json"
|
|
record = json.loads(frozen.read_text())
|
|
record["stage"] = "panel"
|
|
frozen.write_text(json.dumps(record))
|
|
with pytest.raises(mod.PreconditionFailure, match="cards record"):
|
|
mod.main(_manifest_argv(env))
|
|
|
|
|
|
def test_manifest_refuses_bad_timestamps(env, tmp_path):
|
|
assert run_cards(env, tmp_path) == 0
|
|
assert mod.main(base_argv(env, "panel") + scripted(tmp_path, panel_responses())) == 0
|
|
with pytest.raises(mod.PreconditionFailure, match="RFC 3339"):
|
|
mod.main(_manifest_argv(env, generated_at="not-a-timestamp"))
|
|
assert not (env["work"] / "execution-manifest.json").exists()
|
|
|
|
|
|
def test_structured_auth_failure_is_not_retried(env, tmp_path):
|
|
structured = TransportFailure(
|
|
"field_analyst", "[TRANSPORT: result error_during_execution] Failed to authenticate.",
|
|
stdout="Failed to authenticate. API Error: 401 API key is invalid.",
|
|
raw_stdout='{"type":"result","is_error":true}',
|
|
diagnostic="Failed to authenticate. API Error: 401 API key is invalid.",
|
|
)
|
|
assert mod._is_auth_failure(structured)
|
|
transport = _RaisingTransport({"field_analyst": [structured, structured]}, {})
|
|
assert mod.stage_cards(parsed(env, "cards"), transport) == 1
|
|
assert transport.calls == ["field_analyst"]
|
|
raw = env["work"] / "cards" / "p1" / "raw"
|
|
assert (raw / "field_analyst.attempt1.transport-stream.jsonl").read_text().startswith("{")
|
|
|
|
|
|
@pytest.mark.parametrize("exit_code", [0, 1])
|
|
@pytest.mark.parametrize("partial", ["", "A partial review."])
|
|
def test_cli_structured_auth_diagnostic_stops_after_one_call(env, tmp_path, monkeypatch, exit_code, partial):
|
|
events = [{"type": "assistant", "message": {"content": [{"type": "text", "text": partial}]}},
|
|
{"type": "result", "subtype": "error_during_execution", "is_error": True,
|
|
"result": "Failed to authenticate. API Error: 401 API key is invalid."}]
|
|
raw = "\n".join(json.dumps(e) for e in events) + "\n"
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
|
transport = mod.ClaudeCliTransport(model="test", effort="high")
|
|
calls = []
|
|
from types import SimpleNamespace
|
|
def fake_cli(*args, **kwargs):
|
|
calls.append(args)
|
|
return SimpleNamespace(returncode=exit_code, stdout=raw, stderr="")
|
|
# Stage provenance also runs subprocesses; pin it before replacing the shared module.
|
|
monkeypatch.setattr(mod, "_git_state", lambda: ("f" * 40, False))
|
|
import dispatch_e4_panel
|
|
monkeypatch.setattr(dispatch_e4_panel.subprocess, "run", fake_cli)
|
|
assert mod.stage_cards(parsed(env, "cards"), transport) == 1
|
|
assert len(calls) == 1
|
|
record = json.loads((env["work"] / "runs" / "blocked-cards-p1.json").read_text())
|
|
assert record["abort_reason"].startswith("CredentialRejected")
|
|
assert record["retries"] == []
|
|
|
|
|
|
@pytest.mark.parametrize("stage,label", [("cards", "field_analyst"), ("panel", "seat-methodology")])
|
|
def test_interrupt_preserves_a_blocked_stage_and_call_ledger(env, tmp_path, stage, label):
|
|
if stage == "panel":
|
|
assert run_cards(env, tmp_path) == 0
|
|
transport = _RaisingTransport({label: [KeyboardInterrupt()]}, panel_responses())
|
|
method = mod.stage_cards if stage == "cards" else mod.stage_panel
|
|
assert method(parsed(env, stage), transport) == 1
|
|
name = "blocked-cards-p1.json" if stage == "cards" else "blocked-2026-08-07-p1-r1.json"
|
|
record = json.loads((env["work"] / "runs" / name).read_text())
|
|
assert record["status"] == "aborted" and record["abort_reason"].startswith("KeyboardInterrupt")
|
|
row = record["calls"][-1]
|
|
assert row["call"] == label and row["outcome"] == "interrupted" and row["attempt"] == 1
|
|
assert row["started_at"] <= row["completed_at"] and len(row["prompt_sha256"]) == 64
|
|
assert record["retries"] == []
|
|
if stage == "panel":
|
|
assert record["completed_calls"] == ["seat-eic"]
|
|
assert (env["work"] / record["raw_bundle"] / "seat-eic.md").is_file()
|
|
_, _, blocked = mod.load_attempt(env["work"])
|
|
assert name in blocked
|
|
|
|
|
|
def test_successful_calls_keep_the_raw_stream_when_the_transport_offers_it(env, tmp_path):
|
|
class Streaming(_RaisingTransport):
|
|
def __call__(self, call, sandbox):
|
|
text = super().__call__(call, sandbox)
|
|
self.last_raw_stdout = '{"type":"assistant"}\n{"type":"result","subtype":"success"}\n'
|
|
return text
|
|
|
|
transport = Streaming({}, {"field_analyst": [ANALYSIS]})
|
|
assert mod.stage_cards(parsed(env, "cards"), transport) == 0
|
|
raw = env["work"] / "cards" / "p1" / "raw"
|
|
assert (raw / "field_analyst.transport-stream.jsonl").read_text().startswith('{"type":"assistant"}')
|
|
assert (raw / "field_analyst.md").read_text() == ANALYSIS
|