Files
imbad0202__academic-researc…/scripts/test_dispatch_calibration_panel.py
Edward Cheng-I Wu 75070eec84 feat(evals): #653/#828 add reviewer-calibration harness with isolated dispatch and audited scoring (#835)
* 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>
2026-09-08 08:38:58 +09:00

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