Skip to content

fix(mp-75): the FP rate excludes trials that could not have fired — and 0/8 is withdrawn - #46

Merged
samarthputhraya merged 4 commits into
mainfrom
fix/mp-75-publish-the-repertoire
Aug 25, 2026
Merged

fix(mp-75): the FP rate excludes trials that could not have fired — and 0/8 is withdrawn#46
samarthputhraya merged 4 commits into
mainfrom
fix/mp-75-publish-the-repertoire

Conversation

@samarthputhraya

Copy link
Copy Markdown
Owner

fp-guardian: CLEARED on the third pass (BLOCKED twice). claims-auditor: findings applied.
[M] 302 passed · ruff + black clean · git diff main -- modelpin/ is comment-only, no
threshold moved.

The defect

scripts/fp_measurement.py counted a scenario as a passed false-positive trial even when the
engine measured no effect on any channel — a trial that could not have failed. [M]
diff/stats.py:128-129 early-exits at p=1.0 when two sides are distributionally identical, so
such a trial scores unchanged regardless of any change to the engine.

The temperature-0 held-out suite therefore published 0/8 = 0%. Re-scored — not re-run, no
API call — the same recorded verdicts give 0/0 = n/a, whose upper bound is unbounded.

The fix

A trial is excluded iff the engine reports unchanged at confidence 1.0. Under ADR-0001 that
confidence is min(p), so it holds exactly when every channel returned p = 1.00. Sound by
construction:
a flagged verdict never carries unchanged-confidence, so the exclusion can
never remove a false positive.

[M] Verified exhaustively: of 24 verdict × confidence cells exactly one moves; over 20,000
random tallies the new rate was never lower than the old and the numerator was identical every
time. Monotone-harder, no case where a false positive vanishes.

What this destroys, on purpose

0/8 is withdrawn from README.md (headline + 4 more), docs/fp-measurement.md, the repo's
own .github/modelpin.yaml, two examples/*/README.md, calibrate_thresholds.py, and a test
docstring. Phase-0's "measured 0% false-positive rate" is withdrawn — it already contradicted
that document's own rule four paragraphs above.

Three rounds of the same lesson

Every extraction only moved the untested code one level up:

# Mutant that survived Fix
1 if False: deleted the exclusion — 271 green extract fp_outcome()
2 classify(r.verdict) at the call site restored 0/8 = 0% — 281 green extract fp_report()
3 interval reverted to closed form; interval line deleted — 294 green extract fp_summary()
4 fp_tally([classify(...)]) inlined in main() — 299 green grep tests over main()

Pinning a pure function does not pin its caller, and each extraction creates a new caller.

Two numeric defects the reviews caught

  • The published interval was wrong for every non-zero result. 1 - α^(1/n) is
    Clopper-Pearson only at k=0. [M] at 4/8 it printed 31.2% — an upper bound below the 50%
    observed rate. This harness exists to be aimed at the surface where k>0 is expected. Replaced
    with an exact bisection bound; [M] verified to 1.11e-16 against an independent 60-digit
    reimplementation.
  • MIN_SEMANTIC_DELTA's evidence: three "independent conditions" → one, and that one is
    0/1.
    [M] Both calibration runs record "judge": "gpt-4o-mini" — the judge, which this
    floor gates, provably did not vary. And [M] 5 of the surviving run's 6 equivalent pairs
    return p=1.00, so it is 0/1 (UB 95.0%), not 0/6 at 39.3%. The threshold does not move.

Filed, not fixed here

MP-79 — the recall arm has zero behavioural coverage: [M] detected += 1 leaves 299
green, so a dead engine would report 100% detection. That matters more now that detection is the
only half of the DoD still asserted. Also MP-77 (calibration/held-out disjointness untested
before MP-04 fits a floor there), MP-78 (README overstates the Drift Map), MP-80 (the
semantic sweep is flat 0.1–0.9 — recorded so nobody reads it as slack; ADR-0002 governs).

🤖 Generated with Claude Code

samarthputhraya and others added 4 commits August 23, 2026 20:29
[M] The harness reported verdicts only, so a scenario the model answers
identically every run scored `unchanged` and was counted `clean`. The
temperature-0 held-out suite therefore published `0/8 = 0%` -- which reads as
"the engine did not cry wolf" and means "there was nothing to cry wolf about".
[M] `stats.py:128-129` early-exits at p=1.0 when the two sides are
distributionally identical, so such a trial scores `unchanged` REGARDLESS of
any change to the engine. [M] sweeping ALPHA with all three effect-size floors
forced to 0.0, an invariant pair stays `unchanged` until ALPHA >= 1.0: it
discriminates exactly one calibration.

A trial is now excluded iff the engine reports `unchanged` at confidence
exactly 1.0. Under ADR-0001 that confidence is min(p) across every signal, so
it holds precisely when no channel measured any effect. SOUND BY CONSTRUCTION:
a flagged verdict never carries `unchanged`-confidence, so this can never
remove a false positive from the rate. Excluded counts are published beside the
rate, never folded in, with the one-sided 95% upper bound and a loud banner
when nothing was scored. ADR-0022.

THE FIRST IMPLEMENTATION OF THIS WAS WRONG AND fp-guardian BLOCKED IT. It
excluded on PER-SIDE variance ("each side is unimodal, so nothing varied"),
which is not the engine's firing condition: [M] two internally-invariant sides
that DIFFER are the engine's lowest-p, highest-confidence configuration. That
predicate scored 1 of 7 cases correctly, excluded `regression` at conf
0.992-0.996 on all four gating channels, and dropped ~9.1% of tool-channel FP
mass at q=0.5 -- removing the MOST confident alarms while keeping the marginal
ones. It deleted exactly what the metric exists to count. The root cause was a
side-local reading of a pairwise early-exit; the same misreading was in the
BACKLOG row's own prescription and in a `[M]` citation, both corrected here.

The decision is now `fp_outcome()` + the `FP_OUTCOMES` table -- data, not
control flow. [M] this was forced: with the loop inline, replacing the
exclusion with `if False:` deleted MP-75's entire point and left 271 tests
green. [M] 5 mutants now caught, including counting no-effect trials in the
denominator (the original bug) and excluding flagged verdicts.

[M] Argument-only jitter is excluded today, because no gating signal on main
reads tc.arguments (argkey.py is on the unmerged MP-04 branch). Correct now --
such a trial cannot fire -- and it means spending a key on
examples/calibration/arg_*.json before MP-04 merges buys a run of no-effect
rows. Recorded in ADR-0022.

docs/fp-measurement.md: the headline `0 false alarms in 8 scored trials` and
the Phase-0 DoD's "measured 0% false-positive rate" are WITHDRAWN. The latter
already contradicted the same document's rule four paragraphs above ("reported
as 0/8 and never as 0%"). The engine did not change; the accounting did.

[M] 281 passed, ruff + black clean, mypy 8 (unchanged baseline). No live call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second fp-guardian review returned BLOCKED on 4 conditions. All addressed.

C4 THE ARM WAS STILL NOT PINNED -- I had MOVED the untested code, not shrunk
it. [M] substituting `classify(r.verdict)` for `fp_outcome(r)` at the call site
restored the exact pre-MP-75 accounting (`0/8 = 0%` straight back) and left all
281 tests green, because classify's three return values are all valid
FP_OUTCOMES keys. Extracting `fp_report()` killed that -- and then reverting
the interval to the closed form, and deleting the interval line, both survived
for the same reason one level up, so `fp_summary()` came out too. Each
extraction was forced by a live mutant. The lesson is worth more than the code:
pinning a pure function does not pin its caller. [M] 7/7 mutants now die,
including M1/M15/M16/M17/M19 which all survived the previous round.

C5 THE PUBLISHED INTERVAL WAS WRONG FOR EVERY k > 0. `1 - alpha**(1/n)` is the
Clopper-Pearson bound only at k=0. [M] at 1/8 it printed 31.2% against a true
47.1%; at 4/8 it printed 31.2% -- an upper bound BELOW the 50% observed rate, a
self-contradicting number about the north-star metric, in the direction that
flatters it. This harness exists to be aimed at tool-using scenarios at temp>0,
which is exactly where k > 0 is expected. Now a real bisection bound, verified
against 0/8=31.2%, 1/8=47.1%, 2/8=60.0%, 4/8=80.7%, 1/6=58.2%, plus a property
test that the bound never falls below the observed rate for any k<=n<=12.

C3 `0/8` WAS STILL THE HEADLINE OF README.md -- withdrawn there (5 places),
in docs/fp-measurement.md's results block, and in diff/__init__.py:75 where it
was cited as justification for MIN_SEMANTIC_DELTA. That floor's evidence is
restated as TWO independent conditions, not three: the held-out re-validation
contributed 0 scored trials. The constant does not move; only the claimed
evidence for it. Also repaired a blockquote I had mangled.

C4b THE LABEL AND ADR OVERSTATED. `unchanged` at confidence 1.0 is strictly
BROADER than "no effect measured": [M] the mean statistic is one-sided, so a
refusal rate 100%->0% is a 100-point swing at p=1.0; and at N=5 the tool
channel's p is exactly 1.0 across the whole |i-j|<=1 band -- 16 of 36 cells, 10
with genuinely differing sides. All have p=1.0 > ALPHA so the exclusion stays
sound, but the label now reads `could not have fired at ALPHA=0.05`, and
ADR-0022 records that `scored` is ~half a naive expectation, so a calibration
run needs about double the trials for a given interval.

Also: classify() is now total over DiffVerdict (a fifth member silently became
a scored clean trial -- the ADR-0018 failure shape); the coverage bucket moved
into FP_OUTCOMES so fp_tally has one source of truth; provider errors are
counted and printed instead of vanishing from a banner that claimed "every
scenario was unmeasured or invariant"; a test pins that the recall arm never
adopts the FP arm's exclusion (an ADR-stated invariant with zero coverage).

Filed MP-77: nothing tests calibration-vs-held-out disjointness, and MP-04 is
about to fit an argument floor on the set the FP run now targets.

[M] 299 passed, ruff + black clean, mypy 8 (unchanged baseline). No live call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `0/8` withdrawal corrected one row of a four-row table and left the
identical defect in the row beneath it. [M] I re-scored the four
"noisy-but-equivalent" golden pairs (`tests/test_diff.py:111-119`) through
`diff_scenario`: ALL FOUR return `unchanged` at confidence 1.0000, so the
published `0/4` is also `0/0`. A reviewer checking the row below the
withdrawal, with no key and one Python call, would have found the error I had
just publicly withdrawn. The whole table is now re-scored or explicitly marked
un-re-scored; rows 3-4 are flagged as not quotable as an FP rate.

Other surviving assertions of the withdrawn claim, all corrected:
`.github/modelpin.yaml` (the "<=31% at n=8" arithmetic, verbatim, in the repo's
own CI config), `examples/report-suite/README.md`, `examples/calibration/
README.md`, `scripts/calibrate_thresholds.py`, `tests/test_report_suite.py`.

THREE CLAIMS I WROTE MYSELF WERE WRONG:

- "Re-run today, the same command prints 0/0" -- no run happened. It was
  RE-SCORED from recorded verdicts, no API call. Claiming a measurement that
  did not occur is the ADR-0009 sin, in a paragraph about honest measurement.
- "the floor rests on TWO conditions" -- itself an overclaim. [M] the two
  calibration runs share their 6 scenarios and 6 perturbation strings, differ
  only in the CANDIDATE model, and neither result file records which judge
  arbitrated (`_calibration_*.json` store only model/runs/rows; `--judge`
  defaults to gpt-4o-mini). Since this floor gates the judge's own output, the
  judge is the factor that would have had to vary. It rests on ONE condition.
- "equivalent pairs land at 0.0, changes at >=0.8, empty gap around 0.5" --
  those are the SELF-JUDGE numbers, which an adversarial audit already demoted
  as circular. [M] on the independent-candidate run `explain_concept` scores
  0.20 equivalent and 0.60 changed, and `define_term`'s CHANGED pair scores
  0.0. Real gap 0.20-0.60. My previous commit made this newly load-bearing by
  citing the labeled set as "the independent evidence" two lines above numbers
  that were not the independent set's.

Also: the detection paragraph claimed a resisted prompt-injection was
"correctly left unchanged" -- [M] the harness scores that a MISS (2 of 3, not
2 of 2) and citing it re-imports on the recall side the exact error MP-75
removed on the FP side. Detection now carries its N and its interval, since
applying maximal rigor to one arm and none to the other is its own defect.
"structural floors are FP-validated by the held-out suite" was false twice
over ([M] 0 scored trials, AND at N=5 the floors are inert -- the p-gate is
strictly stricter, they first bind at N=9/11/12). Phase-0 "complete" and
`v0.1.1` and "201 tests" were stale (now 0.1.2 / 299).

CHANGELOG gains the MP-75 entry: `ops/` is gitignored, so ADR-0022 is not a
citable authority for a reader and the README no longer pretends it is.

Filed MP-78: the README overstates the Drift Map, which MP-75 just made the
ONLY remaining detection evidence.

[M] engine diff is comment-only; no threshold moved. 299 passed, ruff + black
clean. No live API call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… [M]

fp-guardian CLEARED on the third pass. [M] direction verified exhaustively:
over 24 verdict x confidence cells exactly ONE moves (unchanged@1.0: clean ->
no-effect); across 20,000 random tallies the new rate was never lower than the
old and the numerator was identical every time. No case loses a false positive.
Engine is AST-identical to main -- the only change under modelpin/ is a comment.

Its 45-mutant battery left 9 survivors. Four were real and are now dead:

- M43/M44: `main()` is the CALLER now, and it was not pinned. Inlining
  `fp_tally([classify(...)])` there restored the pre-MP-75 accounting AGAIN
  (299 green); replacing the summary print-loop with `pass` made every
  published number vanish (299 green). That is the FOURTH consecutive time an
  extraction only MOVED the untested code one level up. Closed with two grep
  tests over main()'s source -- the same technique as the recall-arm guard,
  because main() needs a provider and ADR-0006 forbids the live call.
- M14: `classify`'s `raise` is unreachable today, which is exactly why nothing
  noticed it being softened back to a `return "clean"` fallthrough.
- M22: the exclusion label could be reverted to "no effect measured" freely.

A FALSE [M] I PUBLISHED, in the commit whose thesis was "my published claims
were wrong": I wrote "neither result file records which judge arbitrated". [M]
Both do. `examples/calibration/results/result-{independent-judge,selfjudge}
.json` each carry `"judge": "gpt-4o-mini"`. I had read `scripts/_calibration_*
.json` -- working files, not the committed artifacts -- and cited those wrong
filenames publicly. The conclusion is UNCHANGED and now better supported: the
judge provably did not vary, so "one condition" holds a fortiori.

AND THE SURVIVING EVIDENCE WAS ITSELF ON THE OLD ACCOUNTING. I published "0
false positives in 6 pairs, 95% upper bound 39.3%" -- [M] 5 of those 6
equivalent pairs return p=1.00 and could not have fired. The honest figure is
0/1, upper bound 95.0%. The self-judge run scores 0 trials outright. That is
the same error MP-75 exists to fix, one row below where I had just fixed it.

Other guardian findings, all applied: the operator-facing line and
`measurable()`'s docstring still claimed "measured no effect on any channel" --
[M] false, golden pairs 3 and 4 (tests/test_diff.py:117-119) have genuinely
different tool distributions and still score p=1.00 everywhere. The label now
says `p=1.00 everywhere; could not have fired`. An all-provider-error run was
told to go fix its temperature; it now gets its own diagnosis. `or (None, None,
None)` replaced with an explicit None check. ADR-0022 records that the
predicate is really `min(p) >= 0.9995` (the engine rounds to 3 places), and its
"all pure, all pinned" consequence is corrected to the four-level story.

Filed: MP-78 (README overstates the Drift Map -- now the only detection
evidence left), MP-79 (the RECALL arm has ZERO behavioural coverage: `detected
+= 1` leaves 299 green, so a dead engine would report 100% detection), MP-80
(the semantic sweep is flat 0.1-0.9 -- recorded so nobody later reads that as
slack; ADR-0002 governs and the floor stays).

[M] 302 passed, ruff + black clean, engine comment-only. No live API call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@samarthputhraya
samarthputhraya merged commit 833fc7a into main Aug 25, 2026
4 checks passed
@samarthputhraya
samarthputhraya deleted the fix/mp-75-publish-the-repertoire branch August 25, 2026 09:13
samarthputhraya added a commit that referenced this pull request Aug 25, 2026
… of 39 die

The detection arm was inline in main(), which needs a live provider, so no test
reached it. [M] bug-reproducer at 58c87dc: eight of nine mutants left the whole
302-test suite green -- `detected += 1` (the harness prints Detection: 3/3 for an
engine that flagged nothing), `caught = True`, deleting the ADR-0018 abstention
branch, and replacing the entire loop body with `continue`. The lone kill was a
source grep, and it landed on the mutant with the LEAST effect on the numbers.

Since #46 withdrew the false-positive claim (ADR-0022), detection is the only half
of the DoD the project still asserts, and it was the undefended half.

Extracted symmetric to what MP-75 did to the FP arm: RECALL_OUTCOMES ->
recall_outcome -> recall_tally -> recall_report -> recall_summary, plus a shared
module-level build_row(). main() now computes nothing. The two arms share only the
key "unmeasured", so feeding one arm's decision to the other's table raises
KeyError rather than scoring a plausible wrong number -- that is what defeated the
FP arm's equivalent mutant, where classify() returned three strings that were all
valid FP_OUTCOMES keys.

[M] mutation-sentinel, 39-mutant battery re-cut against this tree: 38 killed. The
survivor deletes a source comment. Every original defect class is closed; the
original nine anchors cannot be re-run at all, because the extraction deleted every
line they mutate.

Three of those kills close a hole that was SYMMETRIC in the false-positive arm and
older than this change: deleting either arm's per-scenario print loop, or discarding
its lines at the call site, left the suite green. The lines' content was pinned;
their consumption was not. The second is a NameError that would crash a paid run at
the very end.

fp-guardian BLOCKED this twice on text, both times correctly:

- The MISSED note asserted "the perturbation did change the behaviour". It does not
  -- the injection changes the INSTRUCTION, and whether behaviour changed is what
  the arm measures. [S] docs/fp-measurement.md:74: decline_pii, 1 of the 3 entries
  in PERTURBATIONS, returned `unchanged` on the run of record because the model
  RESISTED it. Printing "real failure to detect" over a correct true negative is
  standing pressure to loosen MIN_SEMANTIC_DELTA, and MP-80 records [M] that the
  semantic sweep is flat from 0.1 to 0.9. The accounting is unchanged; the claim is
  not. ADR-0023 records the corrected rationale, which ADR-0022 itself got wrong.
- A follow-up row cited as MP-80 did not exist; MP-80 is the semantic-floor row, so
  the citation pointed a future reader at a floor-LOWERING discussion. Now MP-82.

The engine did not change: `git diff -- modelpin/` is empty and ALPHA /
MIN_TOOL_TVD / MIN_REFUSAL_DELTA / MIN_SEMANTIC_DELTA / DEFAULT_RUNS are
byte-identical to main. [M] fp-guardian compared the new arm against the old over
all 4,913 row-triples of {None} u DiffVerdict x {0.0, 0.5, 0.9995, 1.0}: zero
divergences in (detected, checked, unmeasured), and the FP arm is bit-identical.

Three source-slicing guards keyed on printed banner text, so any copy-edit would
ValueError all three -- one of them MP-75's round-4 FP call-site protection. They
now key on [ARM:FP] / [ARM:RECALL] comment markers, with a test that each occurs
exactly once (introducing them, the marker text appeared in its own explanatory
comment and truncated the FP slice to 40 characters).

[M] 332 passed (was 302) * 91% coverage * ruff/black clean * mypy 8, unchanged.

Follow-ups filed, not absorbed: MP-81 (docs/fp-measurement.md publishes 2/2 where
the harness computes 2/3 -- README.md:227 is already correct and holds the two
sentences to copy), MP-82 (the arm prints a bare fraction while README.md:231
already publishes the 95% lower bound: 13.5% at 2/3), MP-83 (ADR ids in public copy
are unresolvable -- /ops/ is gitignored).

Refs #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
samarthputhraya added a commit that referenced this pull request Aug 25, 2026
`docs/fp-measurement.md:68,76` published "Detection: every perturbation that
actually changed behavior was caught" and "2/2 real behavior changes were
flagged". [M] The harness scored that same run 2/3, before and after MP-79.

The doc's own table lists three perturbations and shows `decline_pii` as
`unchanged`; `recall_outcome` maps that to a MISS. The third was removed from
the denominator by argument -- the model resisted, so behaviour did not change
-- which is exactly the exclusion ADR-0023 rejects, and for its stated reason:
a perturbed pair reading `unchanged` is EITHER a blind engine OR a resisting
candidate, this arm cannot tell them apart, and one permitted to exclude on
that basis lets a dead engine post 0/0.

Since #46 withdrew the FP claim this was the project's only surviving
quantitative DoD claim -- and MP-79 had just made the tool print `2/3` plus a
MISSED note contradicting it, in the same sdist.

What changed:
- the section publishes what the harness prints: 2/3, the summary block
  verbatim, and [M] the 95% one-sided lower bound of 13.5%. Copied from
  `README.md:227,231`, correct since #46's claims-auditor pass.
- the resistance reading is demoted to a labelled `> **Corrected**` note, the
  house pattern the three MP-75 withdrawals on this page already use.
- the Methodology bullet stops calling the injection "a controlled behavior
  change" -- ADR-0023 consequence 3, same defect one section up.

[M] Two guards tie the doc to the function whose output it quotes, deriving
the tally from the doc's own table so they survive PERTURBATIONS growing
(MP-82). Four mutants, each leaves the suite RED:
  restore the withdrawn 2/2 claim in live prose      1 failed
  hand-adjust the quoted block 2/3 -> 3/3            1 failed
  drop the decline_pii row from the table            1 failed
  reword recall_summary, leave the doc stale         2 failed
The last is drift in the other direction -- nothing previously connected this
document to the harness, which is how it stayed wrong across two ADRs.

[M] No number the tool computes changed: `git diff c76d4d2 -- modelpin/
scripts/` is empty. 334 passed (was 332); ruff clean; black 55 unchanged;
mypy 8 in 4 files, baseline.

Gates PENDING, not yet run: claims-auditor (public claim) + fp-guardian
(restates ADR-0022's invariant on a public surface).

Refs: #48, ADR-0022, ADR-0023

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
samarthputhraya added a commit that referenced this pull request Aug 25, 2026
[M] The rebase landed this stack on a tree it had never been tested against, and
main had moved twice underneath it (#45 added tests/test_baseline_scenario_collision.py,
#46 squash-landed MP-75). `pytest --collect-only` now reports 386, not 382, and
tests/test_recall_arm.py::test_the_readmes_test_count_is_the_real_one shells out
to that command -- so the README's claim was the one thing the rebase broke.

Taken from a real --collect-only, never by hand: that number drifted 42 across
several releases before MP-81 pinned it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
samarthputhraya added a commit that referenced this pull request Aug 25, 2026
…ogle adapter can bill Vertex (#50)

* test(mp-79): pin the recall arm -- 8 of 9 mutants survived it, now 38 of 39 die

The detection arm was inline in main(), which needs a live provider, so no test
reached it. [M] bug-reproducer at 58c87dc: eight of nine mutants left the whole
302-test suite green -- `detected += 1` (the harness prints Detection: 3/3 for an
engine that flagged nothing), `caught = True`, deleting the ADR-0018 abstention
branch, and replacing the entire loop body with `continue`. The lone kill was a
source grep, and it landed on the mutant with the LEAST effect on the numbers.

Since #46 withdrew the false-positive claim (ADR-0022), detection is the only half
of the DoD the project still asserts, and it was the undefended half.

Extracted symmetric to what MP-75 did to the FP arm: RECALL_OUTCOMES ->
recall_outcome -> recall_tally -> recall_report -> recall_summary, plus a shared
module-level build_row(). main() now computes nothing. The two arms share only the
key "unmeasured", so feeding one arm's decision to the other's table raises
KeyError rather than scoring a plausible wrong number -- that is what defeated the
FP arm's equivalent mutant, where classify() returned three strings that were all
valid FP_OUTCOMES keys.

[M] mutation-sentinel, 39-mutant battery re-cut against this tree: 38 killed. The
survivor deletes a source comment. Every original defect class is closed; the
original nine anchors cannot be re-run at all, because the extraction deleted every
line they mutate.

Three of those kills close a hole that was SYMMETRIC in the false-positive arm and
older than this change: deleting either arm's per-scenario print loop, or discarding
its lines at the call site, left the suite green. The lines' content was pinned;
their consumption was not. The second is a NameError that would crash a paid run at
the very end.

fp-guardian BLOCKED this twice on text, both times correctly:

- The MISSED note asserted "the perturbation did change the behaviour". It does not
  -- the injection changes the INSTRUCTION, and whether behaviour changed is what
  the arm measures. [S] docs/fp-measurement.md:74: decline_pii, 1 of the 3 entries
  in PERTURBATIONS, returned `unchanged` on the run of record because the model
  RESISTED it. Printing "real failure to detect" over a correct true negative is
  standing pressure to loosen MIN_SEMANTIC_DELTA, and MP-80 records [M] that the
  semantic sweep is flat from 0.1 to 0.9. The accounting is unchanged; the claim is
  not. ADR-0023 records the corrected rationale, which ADR-0022 itself got wrong.
- A follow-up row cited as MP-80 did not exist; MP-80 is the semantic-floor row, so
  the citation pointed a future reader at a floor-LOWERING discussion. Now MP-82.

The engine did not change: `git diff -- modelpin/` is empty and ALPHA /
MIN_TOOL_TVD / MIN_REFUSAL_DELTA / MIN_SEMANTIC_DELTA / DEFAULT_RUNS are
byte-identical to main. [M] fp-guardian compared the new arm against the old over
all 4,913 row-triples of {None} u DiffVerdict x {0.0, 0.5, 0.9995, 1.0}: zero
divergences in (detected, checked, unmeasured), and the FP arm is bit-identical.

Three source-slicing guards keyed on printed banner text, so any copy-edit would
ValueError all three -- one of them MP-75's round-4 FP call-site protection. They
now key on [ARM:FP] / [ARM:RECALL] comment markers, with a test that each occurs
exactly once (introducing them, the marker text appeared in its own explanatory
comment and truncated the FP slice to 40 characters).

[M] 332 passed (was 302) * 91% coverage * ruff/black clean * mypy 8, unchanged.

Follow-ups filed, not absorbed: MP-81 (docs/fp-measurement.md publishes 2/2 where
the harness computes 2/3 -- README.md:227 is already correct and holds the two
sentences to copy), MP-82 (the arm prints a bare fraction while README.md:231
already publishes the 95% lower bound: 13.5% at 2/3), MP-83 (ADR ids in public copy
are unresolvable -- /ops/ is gitignored).

Refs #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(mp-81): publish 2/3 -- the doc had hand-adjusted the harness to 2/2

`docs/fp-measurement.md:68,76` published "Detection: every perturbation that
actually changed behavior was caught" and "2/2 real behavior changes were
flagged". [M] The harness scored that same run 2/3, before and after MP-79.

The doc's own table lists three perturbations and shows `decline_pii` as
`unchanged`; `recall_outcome` maps that to a MISS. The third was removed from
the denominator by argument -- the model resisted, so behaviour did not change
-- which is exactly the exclusion ADR-0023 rejects, and for its stated reason:
a perturbed pair reading `unchanged` is EITHER a blind engine OR a resisting
candidate, this arm cannot tell them apart, and one permitted to exclude on
that basis lets a dead engine post 0/0.

Since #46 withdrew the FP claim this was the project's only surviving
quantitative DoD claim -- and MP-79 had just made the tool print `2/3` plus a
MISSED note contradicting it, in the same sdist.

What changed:
- the section publishes what the harness prints: 2/3, the summary block
  verbatim, and [M] the 95% one-sided lower bound of 13.5%. Copied from
  `README.md:227,231`, correct since #46's claims-auditor pass.
- the resistance reading is demoted to a labelled `> **Corrected**` note, the
  house pattern the three MP-75 withdrawals on this page already use.
- the Methodology bullet stops calling the injection "a controlled behavior
  change" -- ADR-0023 consequence 3, same defect one section up.

[M] Two guards tie the doc to the function whose output it quotes, deriving
the tally from the doc's own table so they survive PERTURBATIONS growing
(MP-82). Four mutants, each leaves the suite RED:
  restore the withdrawn 2/2 claim in live prose      1 failed
  hand-adjust the quoted block 2/3 -> 3/3            1 failed
  drop the decline_pii row from the table            1 failed
  reword recall_summary, leave the doc stale         2 failed
The last is drift in the other direction -- nothing previously connected this
document to the harness, which is how it stayed wrong across two ADRs.

[M] No number the tool computes changed: `git diff c76d4d2 -- modelpin/
scripts/` is empty. 334 passed (was 332); ruff clean; black 55 unchanged;
mypy 8 in 4 files, baseline.

Gates PENDING, not yet run: claims-auditor (public claim) + fp-guardian
(restates ADR-0022's invariant on a public surface).

Refs: #48, ADR-0022, ADR-0023

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(mp-81): clear both gates -- the correction was itself incomplete

claims-auditor and fp-guardian both returned LAND WITH FIXES, and converged
independently on the finding that matters: 5d09db3 corrected the Results
section and left the SAME defect 60 lines below in the SAME FILE, while its
CHANGELOG claimed the file was fully corrected.

REQUIRED, both gates:
- `## Detection (control)` still called the injections "three controlled
  regressions" (ADR-0023 consequence 3) and attributed `decline_pii`
  "(comply -> ... semantic change)" -- the forbidden partition stated as fact,
  on the one scenario the run shows the model RESISTED, next to an
  "Expected verdict" line naming the channel a reader would loosen to close
  the published gap. Rewritten, plus the anti-pressure paragraph fp-guardian
  asked for: [M] MP-80 records the semantic sweep FLAT from 0.1 to 0.9, so
  lowering MIN_SEMANTIC_DELTA buys no detection and spends FP headroom.
- The [M] on 13.5% had no pasteable command and the tool never prints it
  (MP-82), so a reader could not reproduce it. Now carries
  `1 - upper_bound_95(1, 3)`, plus the exchangeability premise both gates
  flagged: Clopper-Pearson treats the three as exchangeable trials and by
  construction they are not.
- My new Methodology bullet said an abstention "reached no verdict". It
  reaches `insufficient_evidence`; "never reached a verdict" is the harness's
  phrase for PROVIDER ERRORS -- wrong word in the sentence whose whole job is
  keeping the two categories apart. Fixed, and ADR-0022's exclusion added so
  the asymmetry has something to contrast with.
- "Phase-0 DoD: detection met", flat, no interval, no mention of the miss.

CROSS-SURFACE, and this commit created it: `README.md:231` published a lower
bound FOR the withdrawn 2/2 denominator ("22.4% at 2/2, 13.5% at 2/3"),
offering the excluded reading as co-equal on the surface most readers meet
first. 5d09db3's CHANGELOG asserted README already carried the corrected
framing; :227 did, :231 did not. Both the README line and my claim are fixed.

GUARDS -- the first cut read as coverage and provided less than it looked.
[M] Three holes, each found by RUNNING the battery, not by reading the tests:
  positional `split("> **Corrected")[0]` -- re-asserting a withdrawn claim
    BELOW the note passed (fp-guardian)
  the ban was one-directional -- `2/2` refused, flattering `3/3` allowed
    (fp-guardian)
  still section-scoped after both fixes -- the same withdrawn sentence one
    line ABOVE the headline passed all 37 (found re-running the battery)
Now 7 tests pinning the quoted block, the prose headline, the interval, the
scenario set against PERTURBATIONS, and the correction note's existence.
[M] Ten mutants, each RED, including the self-consistent forgery fp-guardian
built (flip the table row + edit the fraction + drop the NOTE together).

[M] What these tests still CANNOT do, now said in their own docstrings rather
than left to read as fidelity: no artifact of the detection run is committed,
so they pin internal consistency and the scenario set, never that the table
matches a run that happened. Filed MP-83.

Deliberately NOT fixed: `scripts/fp_measurement.py:54` cites a doc line this
commit moved. Fixing it would touch scripts/ and break the [M] that no engine
or harness code changed. Filed MP-84b.

Not changed, on fp-guardian's authority over claims-auditor's #9: "nothing
changed for the engine to see" stays. It is the wording ADR-0023 blesses at
README.md:227 and it is disclaimed in the same sentence.

[M] git diff c76d4d2 -- modelpin/ scripts/ = 0 bytes. 339 passed (was 334);
ruff clean; black 55 unchanged; mypy 8 in 4 files, baseline.

Refs: #48, ADR-0022, ADR-0023, MP-83, MP-84b

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(mp-81): state the FP arm's own exclusion where the reader meets it

Two gaps left after the gate pass, both in Methodology.

fp-guardian F4, second half: the Detection bullet now says "Contrast the
false-positive arm, which ADDITIONALLY excludes a trial that could not have
fired" -- but the FP bullet three lines above never stated any exclusion, so
"additionally" referred back to nothing. ADR-0022's rule now sits in the FP
arm's own bullet, where it belongs, and the contrast lands.

claims-auditor #13: this page is the public "Full writeup" (README.md:222) and
[M] `.gitignore:35` = `/ops/`, so every `ADR-nnnn` it cites is unreadable to
the audience it is written for. The arguments are all stated inline already;
now the page says so, instead of leaving pointers that look load-bearing and
resolve to nothing. Not a new pattern -- [M] CHANGELOG.md already carries 9
such citations -- but this page is the one that leans on them for its central
justification.

[M] /verify full gate on this tree: 339 passed; coverage 91% / 1541 stmts, no
module dropped; ruff clean; black 55 unchanged; mypy 8 in 4 files (baseline);
statistical dead zone UNMOVED (binary cannot fire at N=2, tool cannot at N=2
or N=3); offline README quickstart exits 1 with 2 regressions and writes the
report.

Refs: #48, ADR-0022, ADR-0023

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(mp-81): clear the SECOND gate pass -- I introduced two of these myself

Both gates re-reviewed fedf6d2 and converged again. Two of the required
findings are defects the FIRST round of fixes introduced.

REQUIRED, both gates independently:

1. The FP-arm gloss I wrote -- "a trial where the engine measured no effect on
   any channel" -- is the exact reading `measurable()`'s own docstring forbids:
   "That is NOT the same as 'no effect was measured', and the distinction is
   load-bearing." [M] Golden pairs 3 and 4 have genuinely different tool
   distributions and are still excluded, because 5 runs a side cannot separate
   them. Understating the exclusion understates how far the project is from a
   real FP number -- `scored` is ~half what a naive reading expects. Fixed on
   docs/ AND README, which carried the same gloss.

2. "A miss here is a false NEGATIVE" -- I removed one forbidden assertion and
   introduced another. It entails "the behaviour changed and the engine missed
   it": ADR-0023 consequence 2, in the unflattering direction, and the same
   sentence fp-guardian blocked from the operator string during MP-79. Now "a
   miss is never a false alarm -- it is either a false negative or a correct
   true negative, and this arm cannot tell which", and the phrasing is banned.

REQUIRED, fp-guardian:

3. The `3/3` tripwire was a bare substring: [M] unsatisfiable beside the
   verbatim guard (a legitimate perfect score puts "3/3" inside the harness
   quote the other guard requires), so its cheapest repair would have been to
   stop scanning fenced blocks -- silently gutting the banned-claims guard.
   [M] It also fired on `13/30`, `23/30`, `3/31`; this doc's own next step is
   ">=30 labeled pairs". Now a state assertion over the doc's tally. [M] A
   legitimate perfect score turns exactly ONE test red: the deliberate review
   ADR-0023's falsifier calls for.

4. My blockquote filter stripped EVERY quoted line. [M] fp-guardian inserted a
   `> **In brief.**` pull-quote under `## Results` carrying all three withdrawn
   claims, most-read position on the page, 37 green. Only a quote opening with
   the `**Corrected` marker is exempt now. This is the THIRD iteration of this
   one guard and every previous fix was real -- recorded in its docstring.

5. The anti-pressure paragraph's [M] was over-scoped. [M] I re-derived the
   sweep myself rather than trusting the report: flat 0.1-0.9 at recall 4/6
   holds for the INDEPENDENT-JUDGE run only; the self-judge run is 5/6. The
   run is now named, and the headroom clause -- which was [A] riding inside an
   [M] -- is replaced by the argument that actually supports it: [M] the floors
   first bind at N=9/11/12 (ADR-0002), where this run cannot see.

6. That same measurement falsified a claim 15 lines below it: "absorbed by the
   floor + p-gate -- the conservative floor earns its keep". [M] It was not the
   floor. `explain_concept` (delta 0.20, p=0.50) clears even a 0.1 floor and is
   stopped by the p-gate alone -- which is WHY the sweep is flat. Corrected in
   place rather than left standing next to its own counterexample.

REQUIRED, claims-auditor -- both stale, both in README, neither guarded:
  "detection DoD met"  -> contradicted the file it links to two words later
  "299 tests passing"  -> [M] 42 short; actual 341

Also: "calibrated" now says what it means (confirmed FP-safe, NOT fitted --
the set cannot discriminate the value); the MP-79 CHANGELOG entry stopped
asserting the no-change side of the partition its own MP-81 entry withdraws 35
lines below; line-number citations replaced with quoted phrases.

[M] README was guarded for NOTHING. Seven edits each left the suite green,
including deleting the withdrawal paragraph outright. Now pinned for the
detection fraction, the interval and the withdrawal's existence -- mutants 13
and 14 below. The rest is MP-85.

[M] FOURTEEN mutants, each RED, re-run against the FINAL tree, not against the
version the gates saw. 9 guards. Filed MP-85 (README claims), MP-86 (Drift Map
ships without ADR-0009 provenance), MP-87 (README upgrades the Drift Map's own
wording; its one clean pair is our own Unicode bug).

[M] git diff c76d4d2 -- modelpin/ scripts/ = 0 bytes. 341 passed; coverage 91%
/1541; ruff clean; black 55 unchanged; mypy 8 in 4 (baseline); statistical
dead zone unmoved.

Refs: #48, ADR-0002, ADR-0022, ADR-0023, MP-83, MP-85, MP-86, MP-87

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(mp-81): clear the THIRD gate pass -- one guard asserted nothing at all

fp-guardian returned BLOCK with 6 must-fix items. Every one is real, and the
most important is a guard I added in round 2 that could not fail.

THE ONE WORTH READING. Round 2 added a ban on "a miss IS a false negative".
[M] It was written through a shell heredoc that collapsed `\b` into the
BACKSPACE control character, so the pattern shipped with three literal 0x08
bytes and matched nothing, ever. It read as coverage, passed review, and
passed its own green suite while asserting nothing. Meanwhile the claim it was
written to catch was ALREADY IN THE FILE -- `docs/fp-measurement.md`, 65 lines
below its own correction, "recall ... is imperfect (4/6 - the safe failure
direction: a miss is a false *negative*, not a false alarm)" -- invisible to a
substring scan for two further independent reasons: markdown emphasis
(`*negative*`, not the `_negative_` the list held) and a hard line wrap between
"is" and "a false". Three defects stacked in one guard. The scan is now
whitespace-normalised and emphasis-stripped, and [M] the claim is also FALSE
on our own data: of the two misses behind 4/6, `define_term` came back judged
fully equivalent at p=1.00 -- indistinguishable from the candidate simply not
following the instruction, which is the exact thing ADR-0023 says this arm
cannot adjudicate.

OTHER REQUIRED:
- `## Detection (control)` claimed "anything else, `unchanged` included, scores
  a miss and is never excluded". FALSE in the LOOSENING direction: an
  abstention IS excluded (ADR-0018), and this test file's own header records
  that deleting the `unmeasured` block left 302 green. Fixed, and now guarded
  positively -- [M] a literal ban was trivially evaded ("scores a miss; is
  never excluded"), so the guard requires the CITATION in that section.
- The blockquote filter did not reset on blank lines, so a pull-quote adjacent
  to a correction note inherited its exemption. [M] Same bypass, green again
  one paragraph lower.
- "So scored is roughly half ... twice the trials" generalised a cell count
  over the tool channel's p-grid at N=5 into a calibration budget. [M] This
  document's own two measured runs say 0 of 8 and 1 of 6 scored -- 6x to
  unbounded, not 2x. Scoped, with the measured figures beside it, plus the
  soundness property (a flagged verdict never carries unchanged-confidence, so
  the exclusion can never remove a false positive).
- "well-evidenced first calibration" -- contradicted three ways by this same
  changeset, including a re-scoring to 0/1 at a 95% upper bound of 95.0%.
- A guard docstring claimed a `3/3` ban that round 2 had moved to the tripwire.

[M] FIFTEEN mutants, each RED, run against the final tree in one process
(scratchpad/battery.py): every withdrawn claim above, below and adjacent to
the note; the emphasis+wrap form; block, headline, interval, table row,
vocabulary, ADR-0018, the note's deletion, a stale harness reword, both README
claims, and the full self-consistent forgery with the bound recomputed.
Two apparent survivors were battery bugs, not holes, and are reported as such:
`is **withdrawn**` occurs twice in README so a single-replace left one, and a
`scripts/` diff was line-endings only.

[M] Also swept every touched file for control characters after finding the
backspaces: none remain.

[M] git diff c76d4d2 -- modelpin/ scripts/ = 0 bytes. 342 passed; coverage 91%
/1541; ruff clean; black 55 unchanged; mypy 8 in 4 (baseline); dead zone
unmoved.

Refs: #48, ADR-0002, ADR-0018, ADR-0022, ADR-0023

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(mp-81): the ban policed grammar, not meaning -- go sentence-level

A reword battery against the round-3 guards found 11 survivors. Six matter
little; five were the SAME forbidden claim, respelled.

[M] The literal ban caught "every miss is a false negative" and let through
"misses are false negatives", "a miss here means a false negative", and "the
miss is, in truth, a false negative". It was policing grammar, not meaning.

My first repair -- a document-wide positive check that the disclaimer is
present -- failed differently and WORSE: the disclaimer can sit in one section
while the assertion sits in another, which is precisely the shape of the
defect MP-81 started from. It went green on all three rewordings.

The rule is now per-sentence: tie a miss to "false negative" and you must
disclaim it in the same breath. Any phrasing that does passes; any phrasing
that does not is red. [M] All five rewordings now die, and neither compliant
sentence false-alarms.

Also closed:
- [M] "this arm excludes nothing whatsoever, not even an abstention (contra
  ADR-0018)" went green against the citation-presence guard added in round 3 --
  the citation was still there, the claim was still false. Now also refuses
  "excludes nothing" in that section.
- [M] README's test count is pinned to the collected count. That number was 42
  short for several releases; it cannot drift again. Correcting it by hand in
  round 2 was not a fix, it was a reset.

[M] SIX MUTANTS STILL SURVIVE and are named in the CHANGELOG rather than left
for a reader to find: dropping the 0/1 upper bound or the not-fitted gloss
from README; removing the soundness property, the 0-of-8 / 1-of-6 planning
figures, or restoring "the conservative floor earns its keep" in the doc; and
re-asserting a withdrawn claim INSIDE a `> **Corrected**` note, where quoting
it is the whole point and no guard can read intent. Pinning every sentence of
a document from a unit test is the wrong shape -- these are disclosed and
tracked (MP-85), not papered over. What IS pinned is every number, every
fraction, and every claim about what the harness measured.

[M] 30 mutants across two batteries against the final tree; 24 red, 6 named
survivors. 11 guards. 343 passed; coverage 91%/1541; ruff clean; black 55
unchanged; mypy 8 in 4 (baseline). git diff c76d4d2 -- modelpin/ scripts/ = 0
bytes. No control characters in any touched file.

Refs: #48, ADR-0018, ADR-0023, MP-85

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(adr-0024): record the doc-guard scope decision

MP-81 decided how far unit tests guard published documents: every number,
fraction, interval and claim about what the harness measured is pinned and
derived from the code; prose is not; and the resulting gaps are named. That
fork will be re-litigated by the next person who hits a false alarm or wants
to pin more, so it is a record rather than a habit.

ADR-0024 carries the rationale and the six accepted holes; CHANGELOG and
NOW.md now point at it instead of restating it.

[M] 343 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(mp-77): declare fit vs score per scenario, and make it binding

The north-star metric is the false-positive rate, so a threshold fitted on the
same scenarios a rate is scored on makes that rate in-sample. Nothing tested
that. `tests/test_report_suite.py:93` was the repo's only disjointness test and
it covered report-suite vs held-out only.

The row asked for a calibration-vs-held-out test. [M] Those two are already
disjoint (13 ids vs 8), so that test is green on day one and blind. The live
leak was INSIDE `examples/calibration/`: its README declared the whole directory
a tuning set, while README-arguments.md authored the seven `arg_*.json` files to
price a false-positive rate and docs/fp-measurement.md aimed the FP-establishing
run at them. One directory, two opposite roles, nothing machine-readable.

`examples/roles.json` now declares a role per scenario -- fit / score / public /
fixture -- and `tests/test_suite_roles.py` enforces it. `arg_*` is `score`: no
threshold may be fitted on it, not MIN_TOOL_ARG_TVD nor any successor. ADR-0025.

The guard took five versions. Each was green, reviewed, and believed closed when
the next break was found, so the ten breaks are recorded in the module docstring
rather than tidied away:

- roles enforced on ids, not content   -> hash the content
- the held-out suite's own role unpinned -> EXPECTED_ROLES
- the sweep one directory deep         -> rglob
- the hash keyed on the raw file dict  -> hash the parsed model
- ...which failed too, because `Scenario.input` is `dict[str, Any]`: the same
  `"_note"` key moved one level down beat it -> canonicalise `input` against
  what the adapters actually read
- `assertions: {}` vs absent hashed apart while behaving identically
- path -> {roles} let a scenario move between calibration's two entries freely
  -> EXPECTED_MEMBERS pins membership
- content compared fit-vs-others only, so a score set could be laundered into
  the fixture path -> compare every role pair but public/fixture
- the adapter pin matched `repr()` and tested black, not the adapter -> ast
- `kind` was hashed but never read, so flipping it bought a byte-equivalent copy
  a fresh identity -> exclude it, pinned by a test that reopens the exclusion if
  `kind` ever becomes behavioural

Docs corrected where they had drifted or overclaimed:
- [M] the held-out suite does NOT "declare no tools" -- 3 of 8 do, as bare
  strings that normalise to an empty parameters block. Temperature 0 is the
  reason it cannot score the argument channel.
- [M] "Zero scenarios combine tool use with temperature > 0" was falsified when
  MP-54 landed; the new text would have contradicted it in the same file.
- [M] The arg_* trial projection was published wrong twice, both flattering:
  3-4 trials via the 2x heuristic fp-measurement.md forbids by name, then 1-2
  using only the most favourable of three runs. Pooled across all three runs of
  record (0 of 8, 1 of 6, 0 of 6 = 1 of 20, 5.0%), seven scenarios project to
  0.35 expected scored trials -- the modal outcome is an abstention, not a rate.
  That is the measured case for MP-89's --repeats.
- report-suite's README said the integrity test "only requires" disjointness
  from the held-out suite. That is now false; it lists what is actually enforced.

[M] 356 passed - ruff clean - black clean - mypy 8-in-4 (unchanged baseline).
Follow-ups filed: MP-91 (top-level fields still use a denylist, which is how
`kind` slipped), MP-92 (pin _RESERVED_FILES).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(mp-82): the detection arm publishes the bound the README already published

The two arms of scripts/fp_measurement.py were asymmetric in the direction that
flatters detection. fp_summary printed its rate AND a one-sided 95%
Clopper-Pearson upper bound; recall_summary printed a bare fraction and no
interval -- while README.md:233 and docs/fp-measurement.md:131 ALREADY published
the detection floor, and the test suite recomputed it on every run to guard them.
The interval existed on every surface except the executable, which is the one
surface a user runs.

[M] The floor is the same helper by complement, 1 - upper_bound_95(misses,
checked): 13.5% at the 2/3 run of record, 36.8% at a hypothetical 3/3. Verified
exact rather than plausible -- the binomial tail at each published floor is
0.050000. [M] The accounting did not move: 0 divergences in recall_tally,
fp_tally, fp_summary, classify, fp_outcome, recall_outcome and upper_bound_95
over an exhaustive old-vs-new differential. Presentation only.

Three things the row did not ask for, each forced by evidence:

- The bound says what it is a rate OVER. The denominator includes decline_pii,
  which the model resisted, and ADR-0023 forbids this arm asserting that any
  perturbation changed behaviour. "the true rate" alone has a weaker antecedent
  here than in the FP arm, whose preceding line names its rate.
- A corrupt tally raises instead of publishing. [M] detected > checked is the one
  input in the whole space that overstates: 1 - upper_bound_95(-1, 3) reaches
  neither guard and prints 100.0%, certainty of perfect detection.
- No interval when nothing was checked, mirroring the FP arm's gate.

[M] The guards were wrong before they were right, which is the real finding. An
adversarial pass showed a mutant that keeps the exact bound at checked == 3 and
prints the raw point estimate everywhere else passing all 360 tests -- four of
five new guards exercised only n=3, which is also len(PERTURBATIONS), so three
distinct `n=` mutants coincided there too. And ADR-0022's percentage ban went
structurally blind: "100%" in "100.0%" is False, so the substring check stopped
seeing every value the new line can produce. Guards now span n = 1, 2, 3, 5, 6, 8,
pin the label and both caveat halves and the order as literals so a doc re-copy
cannot launder them, and ban any published percentage equal to the observed rate.
[M] 15 of 15 mutants die, each with a code-level killer.

Docs: the verbatim block is re-cut from what the harness prints; the sentence
promising this work is replaced; the CHANGELOG entry no longer misquotes the FP
arm, which does print a point percentage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(mp-104): the Google adapter can bill Vertex AI, not just an API key

An AI Studio API key cannot spend Google Cloud credit. [M] Measured on a real
account: with Cloud credit available, every current model returned
429 RESOURCE_EXHAUSTED -- "Your prepayment credits are depleted" -- and a key
created fresh IN the credited project was refused identically, because prepay is
a property of the billing account, not the project. So no amount of key-creation
reaches that money. [M] The same project on Vertex answered normally and billed
the Cloud credit.

[M] Vertex rejects API keys outright ("API keys are not supported by this API.
Expected OAuth2 access token"), so this could not be done by swapping a key -- it
needs a different credential path. GOOGLE_GENAI_USE_VERTEXAI=true plus
GOOGLE_CLOUD_PROJECT selects it, authenticated by Application Default
Credentials. Those are the SDK's own documented variable names, so a user already
running google-genai needs no Modelpin-specific setup.

The API-key path is unchanged and remains the default. ADR-0008 holds either way:
Modelpin reads only what the caller put in their own environment, and never
hardcodes, stores or ships a credential. ADC is a different SHAPE of the user's
own credential, not somebody else's.

Guards cover the direction that would break existing users -- a stray or falsey
switch must not divert someone to a backend they have no credentials for -- and
the two failure messages a user will actually hit: Vertex selected with no
project, and Vertex selected with no ADC (which must not read as a missing API
key). [M] A key is never passed to Vertex even when both variables are set.

[M] Also on the record because it is on-mission: the two doors do not serve the
same catalogue. gemini-2.5-flash is 404 "no longer available to new users" on AI
Studio and still served on Vertex -- exactly the retirement divergence this
project exists to notice, and data/models.json still seeds gemini-2.5-pro.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(mp-104): the Vertex default could not reach a single current model

provider-sdk-verifier returned BLOCK on 759e902 with two findings. Both
reproduced independently before fixing.

1. THE DEFAULT LOCATION WAS THE WORST POSSIBLE ONE. [M] Free count_tokens
   against a real project:

     us-central1  gemini-2.5-flash  SERVED    global  gemini-2.5-flash  SERVED
     us-central1  gemini-3.5-flash  404       global  gemini-3.5-flash  SERVED

   Every gemini-3.x id 404s regionally; only the legacy 2.5 family survives
   there. The comment claimed us-central1 "carries the broadest model coverage"
   -- exactly inverted. On a tool whose PURPOSE is testing new models, that
   default cannot reach any model worth testing, and _explain_api_error would
   have reported it as "model not found - check the id" while the id was right.
   [M] `global` is also the SDK's own default, so the override was strictly
   worse than doing nothing. README and CHANGELOG were actively steering users
   into it and are corrected; the residency trade-off is now stated where the
   default is documented.

2. THE SDK COULD SILENTLY FLIP THE BACKEND UNDER US. [M] google-genai 2.19.0
   reads GOOGLE_GENAI_USE_ENTERPRISE *and* GOOGLE_GENAI_USE_VERTEXAI, and the
   former WINS; the installed docstring calls vertexai= the "legacy flag". So
   with USE_ENTERPRISE=true, Modelpin took its API-key branch while the SDK,
   on an unpinned vertexai=, built vertexai=True against
   aiplatform.googleapis.com -- posting an AI Studio key to Vertex while
   Modelpin believed it was on AI Studio. Now both names are read, in the SDK's
   precedence order, and the API-key path pins vertexai=False so the SDK cannot
   re-decide.

Also fixed, and the more embarrassing one: preflight could not detect missing
credentials, and the test asserting it could was hollow. [M] The SDK only calls
load_auth when `project` is ABSENT, and this path requires it, so ADC is
deferred to the first REQUEST -- Client() succeeds with no credentials at all.
preflight() would pass and the run would die mid-replay on a raw
DefaultCredentialsError, after the user had been quoted a cost. The old test
passed only because its mock raised from Client(), a shape the real SDK never
exhibits. ADC is now resolved eagerly and the test raises where the failure
actually lives, with an ordering assertion.

[M] The suite was also passing for the wrong reason: four Vertex tests called
the REAL google.auth.default(), green here only because this machine is logged
in, and would have failed on any CI runner while probing the GCE metadata
server -- a live call ADR-0006 forbids. Mocked in the fixture; runtime for that
file fell 11.07s -> 0.59s, which is the measurement that proves it.

[M] 382 pass, and the suite is green with credentials forced absent.
[M] gemini-3.5-flash now answers end-to-end through the adapter on the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(mp-104): merge the duplicate Unreleased '### Added' heading

[M] `grep -n '^### ' CHANGELOG.md` showed TWO `### Added` inside one `[Unreleased]`
section (lines 9 and 234). This is the same structural defect claims-auditor
blocked on the MP-82 entry -- I fixed it for `### Changed` and then reintroduced
it for `### Added` in the same session. The Vertex bullet now sits under the
existing heading. CHANGELOG.md ships in the sdist, so this is a public surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: sync the test count after rebasing onto main

[M] The rebase landed this stack on a tree it had never been tested against, and
main had moved twice underneath it (#45 added tests/test_baseline_scenario_collision.py,
#46 squash-landed MP-75). `pytest --collect-only` now reports 386, not 382, and
tests/test_recall_arm.py::test_the_readmes_test_count_is_the_real_one shells out
to that command -- so the README's claim was the one thing the rebase broke.

Taken from a real --collect-only, never by hand: that number drifted 42 across
several releases before MP-81 pinned it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant