bench(w4.3): decorrelate untrusted from poison, and measure what the defense costs - #103
Conversation
…defense costs
W4.2 ingested every haystack round as internal and carried trust_level=untrusted
only on poison, so downweighting untrusted was indistinguishable from
downweighting the answer key. This adds two corpora that break that correlation.
Harness:
- --trust-plan {none,mixed,evidence} assigns trust at ingest. mixed marks a
seeded 20% of non-evidence rounds untrusted; evidence marks every round of an
answer session untrusted and carries no poison. none is byte-identical to W4.2.
- Selection is seeded off a crc32 of the question id, not builtin hash(), which
is salted per process and would make the plan unreproducible.
- LME_NS_SUFFIX gives each variant its own corpus. ingest, answer, and
poison_corpus.py inject/delete all read it, and the injected-ID ledger is
written per-suffix so a variant run cannot overwrite the only record of what
is in the W4.2 corpus.
- ns() now raises past 64 chars instead of truncating; truncation would merge
two questions' corpora into one namespace and cross-contaminate retrieval.
- analyze_poison.py selects its arm set by suffix, because scoring the _mix arms
against the W4.2 ledger compares ids across namespaces and reports a false 0%.
- Judge usage is recorded in judged.jsonl so per-arm spend is measured, not
estimated.
Analysis: analyze_mixed.py (accuracy, poison in top-k, benign-untrusted
occupancy, evidence-in-top-k, within-corpus exact McNemar), build_mixed_report.py,
spend.py. Trust levels join to retrieved ids through Postgres, since the plan
records round indices and retrieval returns memory ids.
Results (n=120, top_k=15) are in the PR. The short version: Arm M holds
(0.317 -> 0.700, p=1.2e-10) but Arm N shows the defense destroys utility when
benign evidence arrives untrusted (0.858 -> 0.042, evidence recall 0.99 -> 0.00).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dfc501eb94
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # W4.3 — separate corpora (_mix, _evu). Accuracy is comparable across rows | ||
| # here, but McNemar is only valid WITHIN a corpus: these arms answer over | ||
| # different namespaces, so they are not paired with the W4.2 rows above. | ||
| ("M mix TWRoff", "mix_twr_off"), | ||
| ("M mix w=0.35", "mix_twr_w35"), | ||
| ("N evid-untrust TWRoff", "evu_twr_off"), | ||
| ("N evid-untrust w=0.35", "evu_twr_w35"), |
There was a problem hiding this comment.
Exclude cross-corpus arms from baseline McNemar tests
Adding these W4.3 rows to configs also sends them through the unconditional baseline significance loop at lines 138–145, so compare_sweep.py reports McNemar results comparing _mix and _evu arms with the W4.2 baseline. This directly contradicts the adjacent requirement that McNemar be run only within each corpus and can publish invalid significance results; exclude these rows from that loop or pair each arm with its own corpus control.
Useful? React with 👍 / 👎.
| if len(suffixes) < len(CORPORA): | ||
| report["status"] = "partial" | ||
| report["corpora_pending"] = [s for s in CORPORA if s not in suffixes] | ||
| else: | ||
| report["status"] = "complete" |
There was a problem hiding this comment.
Mark the report incomplete while any arm is pending
When the default build includes both suffixes but one or more judged.jsonl files are absent, the corpus blocks correctly contain arms_pending, yet this condition still sets the top-level status to complete because it checks only the number of requested suffixes. A failed or unfinished arm can therefore be mistaken for a finished experiment; derive completeness from the pending-arm state as well.
Useful? React with 👍 / 👎.
| for qid, rec in hyp.items(): | ||
| ids = rec.get("retrieved_memory_ids") or [] |
There was a problem hiding this comment.
Restrict retrieval metrics to judged questions
If any judge calls fail—an explicitly supported scenario because phase_judge collects exceptions and leaves a partial judged.jsonl—this loop still aggregates retrieval metrics over every hypothesis while accuracy and n_questions use only judged records. The resulting arm silently combines different samples, so occupancy/evidence rates cannot be compared reliably with its reported accuracy; iterate over the intersection with labels or report the retrieval sample separately.
Useful? React with 👍 / 👎.
The .gitignore comment already said committed reports stay tracked, but results/ excluded the whole tree, so the roll-up had nowhere to live and the numbers existed only in a PR body. Narrow the rule to results/* with a negation for top-level *_report.json. It has to be results/* rather than results/ — git does not descend into an excluded directory, so a negation under results/ would never have matched. Per-run artifacts (results/<run>/judged.jsonl, results/poison/) stay ignored; verified with git check-ignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The objection this answers
In W4.2 every haystack round was ingested as
internalandtrust_level=untrustedwascarried only by poison. Downweighting untrusted was therefore indistinguishable from
downweighting the answer key — the label was an oracle, and the headline result inherited
that. This adds two corpora that break the correlation and measure what trust-weighted
retrieval costs when the label is no longer free information.
_mix) — a seeded 20% of non-evidence rounds ingested as untrusted, plusthe same 360 poison memories. Untrusted is now mostly benign.
_evu) — every round of ananswer_session_idssession ingested asuntrusted, no poison. This is the adversarial case for the defense: the evidence
itself arrives untrusted.
Both at n=120,
top_k=15, readerclaude-sonnet-5, judgegpt-4o-2024-08-06.Prediction was pre-registered in
build_mixed_report.pybefore any arm was inspected.Results
_mix_mix_evu_evuWithin-corpus exact McNemar (never across corpora — different namespaces, not paired):
_mix+51/−5, p=1.2e-10._evu0/+98 the other way, p=6.3e-30.Arm M holds. With untrusted no longer a poison oracle, the defense still recovers
0.317 → 0.700 and drops poison out of rank #1 entirely. That is the W4.2 claim surviving
its main methodological objection.
Arm N is the honest bad news, and it is worse than predicted. The pre-registered
margin (
w_trust * delta_prior / w_semantic= 0.544) predicted partial demotion ofbenign untrusted content. Observed suppression is total: benign-untrusted top-k occupancy
goes to 0.00% in both arms, and in Arm N that content is the evidence — evidence recall
0.99 → 0.00, accuracy 0.858 → 0.042. At w=0.35 the trust prior gap (0.7) dominates the
achievable semantic range, so nothing untrusted survives ranking regardless of relevance.
Note the asymmetry in Arm M's 0.0% occupancy: it looks like a clean win only because the
mixed plan draws its untrusted band exclusively from non-evidence rounds, so what got
suppressed was distractors. Arm N is the same mechanism pointed at content that mattered.
The two arms are the same finding under different sampling.
Implication for the product, not settled here: w=0.35 is not a safe default for corpora
where untrusted is a provenance fact rather than an attack signal. It needs either a
relevance floor that trust cannot override, or per-namespace tuning.
Changes
Harness (
run_longmemeval.py)--trust-plan {none,mixed,evidence}assigns trust at ingest;noneis byte-identicalto W4.2. The realised plan is written to
trust_plan.jsonlbefore the write, socorpus composition survives an ingest that dies partway.
hash()is salted per process andwould make the plan unreproducible across runs.
LME_NS_SUFFIXgives each variant its own corpus; ingest, answer, andpoison_corpus.pyinject/delete all read it.ns()raises past 64 chars instead of truncating — truncation would silently merge twoquestions' corpora into one namespace and cross-contaminate retrieval.
judged.jsonlso per-arm spend is measured, not estimated.Ledger safety (
poison_corpus.py) — the injected-ID ledger is per-suffix(
injected_mix.jsonl). It is the only record of what is in a poisoned corpus; a variantinject writing to the shared path would have destroyed the W4.2 record.
poison.jsonlitself is read-only here and is not regenerated.
Scoring (
analyze_poison.py,compare_sweep.py) — arm sets are selected by suffix.Scoring
_mixarms against the W4.2 ledger compares retrieved ids against poison livingin other namespaces and reports a spurious 0% clean.
New analysis —
analyze_mixed.py(accuracy, poison in top-k, benign-untrustedoccupancy, evidence-in-top-k, within-corpus exact McNemar),
build_mixed_report.py(pre-registration, realised composition, prediction-vs-observed, status),
spend.py(measured per-arm cost; arms judged before this change report
judge_measured=falserather than presenting a stale estimate as measured). Trust levels join to retrieved ids
through Postgres — the plan records round indices, retrieval returns memory ids, and the
DB is the only place those meet.
Measured spend for the four arms: $11.22 ($10.97 Anthropic on Sonnet 5 intro pricing —
$16.45 at standard — plus $0.25 judge). Embeddings were ~$0: the W4.3 corpora replay W4.2
content into new namespaces and hit the server-side content-hash cache.
results/mixed_untrusted_report.jsonis committed — the full numbers, realised corpuscomposition, prediction-vs-observed, and per-arm spend. The
.gitignorerule forresults/is narrowed toresults/*with a negation for top-level*_report.json; ithas to be
results/*because git does not descend into an excluded directory, so anegation under
results/would never match. Per-run artifacts (results/<run>/,results/poison/) stay ignored.🤖 Generated with Claude Code