Skip to content

Clearance integrity: ten commits, and the census that counted a tool nobody called - #67

Merged
samarthputhraya merged 11 commits into
mainfrom
fix/mp-159-census-from-traces
Sep 2, 2026
Merged

Clearance integrity: ten commits, and the census that counted a tool nobody called#67
samarthputhraya merged 11 commits into
mainfrom
fix/mp-159-census-from-traces

Conversation

@samarthputhraya

Copy link
Copy Markdown
Owner

Ten commits from three sessions. The theme is clearance integrity: several of these exist because a review gate proved the tool was calling runs clean that it had no business calling clean.

[M] /verify READY — 8 steps, 723 passed + 4 xfailed, 94% of 2041 statements, ruff + black clean over 87 files, mypy 5 = documented baseline. modelpin/diff/ untouched, so ADR-0030's freeze holds.

Breaking

  • feat(mp-147)!Assertion.expected_tool_calls and Assertion.output_schema are removed. They were recorded and read by nothing; expected_tool_calls was proved inert by differential over five trace configurations, not by grep. The public suite moves to 3.0.0 (sha256:ffd99774f681sha256:5cba1dc8b691), because compute_suite_hash hashes the validated model.

The clearance fixes

  • fix(mp-159) — the channel census counts a tool that was called, not one a scenario merely declared. [M] On byte-identical fixtures, a scenario declaring one tool no run ever called turned could not measure … NOT cleared on content into no behavioral change … looks safe to adopt, over a baseline of "FRAUD DETECTED: block this transaction" against "Looks fine, approve it." The disclosure's own remedy — "add tools" — was the exploit.

    [M] Not hypothetical: on a six-scenario suite run against openai/gpt-oss-120b, all six declared tools and only four ever called one.

    The census requires both a declaration and a recorded call. The FP review blocked the first cut, which read the trace alone and was not monotone: a scenario with no tools key whose traces carried tool_calls went from NOT cleared to looks safe to adopt — the same defect through the other door. The conjunction makes the new blind set a superset of the old for every input, so it can only withhold a clearance, never grant one.

    No verdict, confidence or exit code moves; the census governs disclosure only. This closes the declaration-shaped instance, not the class — a tool called identically on both sides still clears an inverted answer, pinned as a strict xfail.

  • fix(mp-148) — one scenario a provider rejects no longer deletes the other eleven. [M] Validated in production hours after it was written: two scenarios hard-400'd and the run survived, named them, and still found the regression.

  • feat(mp-150) — a run's report survives the next run. Every check used to delete the previous one's evidence.

Cross-vendor and first-run

  • feat(mp-143) — the semantic judge runs cross-vendor. For a suite that declares no tools, the judge is the only CI-failing channel that responds to a change in meaning, so a Groq- or Gemini-only user was running an engine that could not catch a wrong-but-confident answer at all. The judge's false-positive rate has only ever been measured with an OpenAI judge; running everywhere is not the same as being calibrated everywhere, and docs/fp-measurement.md now separates those two claims.
  • feat(mp-151) — a suite that actually asks a model to decline. Refusal was the last channel never observed on real material.
  • fix(mp-152) — the README's Groq quickstart named a model Groq had retired, in the product about model retirement.
  • fix(mp-128) — stop advertising a provider that raises NotImplementedError.

Housekeeping

  • fix — what three mandated review gates found, including a published claim that was backwards.
  • chore — no tool attribution in anything a visitor reads, enforced by a test.

Known-open, deliberately

MP-160 is the remaining P0 on this axis: a scenario with no baseline still vanishes from the PR comment and never touches the exit code. Until it lands, a green result still has one way to be uninformative.

…r eleven

[M] 2026-08-31, MP-144's live aegis run: three distinct hard 400s each killed all
six scenarios and produced NOTHING -- including one where the model HALLUCINATED a
tool name (`verify_vendor` for `verify_vendor_bank`), which is itself the behaviour
change Modelpin exists to catch. `report` already survived this; `check` -- the
command CI runs -- wrapped the entire scenario loop in one `_guard_replay` and
turned any ProviderError into exit 1 with no verdicts and no report.

`check` now catches ProviderError PER SCENARIO: skip it, name it with the provider's
own message, exclude it from every coverage number, and refuse the clean clearance.
Deliberately not a retry -- MP-139's rule that a 400 is not retried still stands.

- NotImplementedError stays a hard failure: an unimplemented adapter is a config
  error that would fail every scenario identically.
- All scenarios rejected now exits EXIT_UNMEASURED with the provider's reason,
  instead of sending the user to `modelpin baseline` for a baseline they have.
- A regression still outranks incomplete coverage: exit 1 wins over exit 3, because
  a real finding in the scenarios that DID run is the strongest true claim available.
- `compared` excludes rejected ids, so the census and underpowered lists do not
  price coverage for a run that did not happen (the MP-138 trap, new cause).
- Both renderers disclose them before any verdict bucket, and `_rejected_clearance`
  joins the underpowered/census pair so "safe to adopt" cannot render over a suite
  that is one scenario short.

MP-28's hardening test moves 1 -> 3 with that reasoning recorded inline: its
invariant is non-zero + no fabricated trace + no `unchanged` line, all still held.
[M] 2026-08-31 `check` wrote exactly one path, <store>/last-report.md, every run.
The consequence is already in the repo: both dogfood write-ups in ops/launch/ had
to be transcribed by hand, because the file they came from was overwritten before
anyone could cite it. A tool whose north-star metric is false-positive RATE cannot
compute one from artifacts that delete each other.

`last-report.md` does not move -- action.yml:135 publishes that exact path, both CI
workflows glob for it, the README documents it. The durable copy is written beside
it at <store>/runs/check-<from>-to-<to>-<UTCstamp>.md:

- byte-identical to the stable path (one render, two destinations), so a reader
  never has to know which one the Action posted;
- written SECOND, so if only one write can succeed it is the one CI reads, and an
  archive failure is a warning that never costs the report;
- same-second collisions disambiguate with a counter -- clobbering is the defect.

README and the generated demo README both say where it lands.
[M] 2026-08-27 action.yml:22 listed `anthropic` in the provider input with no
marker while providers/anthropic.py:17 is `raise NotImplementedError` -- the one
place where following the documentation reaches a crash.

[M] 2026-08-31, measured while fixing it, the row understated the surface: the
unmarked advertisement was in FIVE places, not one -- action.yml, the `--provider`
help on baseline/check/report (so `mp check --help` said it too), and the
unknown-provider ValueError in providers/__init__, which is the first provider list
a user reads after a typo. "The CLI itself is honest" was true of the runtime path
(_preflight_or_fail, _unimplemented_msg) and false of the help text.

Fixed with ONE source -- `providers.provider_help()` -- rather than five marked
copies, because five hand-maintained copies is how the marker came to be missing
from all of them at once (the MP-03 shape).

`anthropic` is deliberately still NAMED: a user who types it deserves "coming, not
yet implemented", not "unknown provider". The adapter itself stays unbuilt for the
reason in MP-20's dated trigger -- no Anthropic key exists on this machine and the
calibration suites are OpenAI-scored, so shipping an unvalidatable adapter would be
worse than an honest stub.

tests/test_advertised_providers.py ties the text to the CODE (`run` is read for a
NotImplementedError body, not called) and is SYMMETRIC: a stub must be marked
everywhere, and a working provider must not be disclaimed -- so the day the adapter
lands, the stale caveat fails the build instead of outliving the stub.
…n judge meaning

[M] 2026-08-31 build_judge rejected any non-OpenAI judge model. Honest -- it said
so -- but the hole had teeth: cross-vendor is wedge item 3, and for a suite with no
`tools` (a classifier, a summariser -- the long-tail wedge) the judge is the ONLY
CI-failing channel that responds to a change in MEANING. Per _channel_census, such
a run had no hard content channel at all; only a refusal could fail the build.

Follows the ADAPTERS' pattern rather than inventing a second one:
- the four OpenAI-compatible hosts REUSE OpenAIJudge with a different base_url and
  key env, exactly as build_openai_compatible_adapter reuses the OpenAI adapter;
- GoogleJudge's SDK calls mirror GoogleAdapter line for line -- system_instruction
  and temperature in `config`, the answer off candidates[0].content.parts[*].text.
  Those shapes are already verified against the installed SDK, and this project has
  been bitten twice by writing provider calls from memory.

Routing refuses to GUESS. [M] `openai/gpt-oss-120b` is an OpenRouter/Groq id whose
first segment is a different vendor's name, so a `vendor/model` parse would misroute
and spend the wrong key against the wrong host. Only `gpt-*` and `gemini-*` are
inferred; anything else takes the new `judge_provider:` config key, or falls back to
the replay provider -- which is printed on the console before any spend, never
silent. A judge model equal to the model under test now draws a note: it is a legal,
cheap choice, but it is not an independent reading.

23 offline tests, including one pinning that every judge asks the IDENTICAL
question, because the semantic channel's calibration is measured against that exact
wording and two rubrics would make cross-vendor verdicts incomparable.

README and docs/fp-measurement.md now separate the two claims that used to be one:
the judge RUNS on six hosts, and its false-positive rate has only ever been measured
with an OpenAI judge. A judge that works is not a judge that is calibrated.
[M] 2026-08-31 refusal is the last of five verdict-bearing channels never observed
on real material: 0 of 60 traces on the kavach dogfood, 0 of 30 on aegis. The zero
says nothing about looks_like_refusal -- neither suite contains a request a
competent model would decline, so the detector was never given the chance. A
channel that has never fired is an assumption wearing a measurement's clothes, and
for a suite with no tools and no judge, refusal is the ONLY channel that can fail
a build.

examples/refusal-suite/ -- five CAPABILITY/privacy declines (live market data, a
URL fetch, a local file, sending mail with no tool, a private individual's details)
plus one quiet anchor that must NOT read as a refusal.

Capability boundaries over unsafe-content prompts, deliberately: harmless to ship
in a public repo, consistent across vendors, and a model that GAINS the capability
stops declining -- so the refusal rate moving is a real migration signal rather
than a regex being tickled. The anchor is what makes the suite show the detector
DISCRIMINATES rather than merely fires, which is the north-star framing.

Role `fit`, not `score`: if the live run shows declines REFUSAL_MARKERS misses, the
fix is widening those markers -- a threshold change, and ADR-0025 forbids scoring a
rate on the set it was fitted on. The refusal FP rate belongs on the held-out
examples/suite. Declared in examples/roles.json and pinned in EXPECTED_MEMBERS, the
deliberate path that guard demands.

NOT YET RUN LIVE. The suite README says so in those words: until 60 completions
happen, "these scenarios decline" is an [A].
[M] 2026-08-31, found by MP-151's live run. `llama-3.3-70b-versatile` returns 404
model_not_found; Groq's live catalogue is 14 ids and it is not among them. It
appeared in three places in README.md, including a copy-paste `modelpin check`
command a new user would run.

A product whose premise is "models get retired and your app finds out in
production" shipped a quickstart that a model retirement had already broken.

- the runnable ids become `qwen/qwen3.8-27b`, which is live today;
- a line above the command says Groq rotates its catalogue and links the docs, so
  the next rotation costs a reader a lookup rather than a 404;
- the MEASURED `gpt-4o-mini` vs `llama-3.3-70b-versatile` 8/8 result is KEPT and
  annotated with the retirement, not deleted. It is a true statement about a run
  that happened; erasing a measurement because its subject was retired would be
  the opposite of this project's evidence discipline.

The watcher half is deliberately NOT closed and stays on the row: data/models.json
and watcher/registry.py track the labs' schedules, and nothing tracks the
OpenAI-compatible hosts' catalogues, which rotate faster. This will recur.

Also updates examples/refusal-suite/ with the live results (75 of 90 refuse_*
traces detected, 0 of 15 on the anchor) and scrubs its links into ops/, which is a
separate private repo and would be a broken link in the public tree.
…te -> 3.0.0

MP-142 proved by differential -- not by grep -- that expected_tool_calls and
output_schema were consulted by NOTHING: five trace configurations, including
baseline SATISFIES while candidate VIOLATES, returned byte-identical verdict,
confidence and explanation with the field set and with assertions=None. This
deletes them, which is a public suite version bump, not a cleanup.

examples/report-suite (role public, ADR-0009): 2.0.0 -> 3.0.0,
sha256:ffd99774f681 -> sha256:5cba1dc8b691. No scenario's MEANING changed and no
verdict moves; compute_suite_hash covers the VALIDATED model, so removing a field
moves it anyway -- and a published artifact whose fingerprint changes without its
version changing is an unannounced change.

[M] Half of MP-147's own prediction did not reproduce, and the test file records
it rather than adopting the new numbers quietly: the row predicted
sha256:eed334061b5e / sha256:5482ccd734fd, computed with the fields deleted from
the model but the dead keys LEFT in the JSON files. Reconstructing exactly that
reproduces eed334061b5e for report-suite to the digit, and still does not
reproduce 5482ccd734fd for examples/suite (it gives 22a03cce1e6c). The files were
cleaned too, so the pinned hashes are the measured ones.

Deleting the fields ALONE would have made things worse for users: pydantic ignores
unknown keys, so a stale expected_tool_calls would go from documented-but-inert to
invisible -- MP-142's defect pushed into a file we do not own. So the advisory
moved with the fields: it now reads the RAW json keys instead of model fields, and
therefore also catches a typo like `must_containn`, which no version has ever
checked.

The generalised guard replaces the two specific ones: every field on Assertion
must appear in violates_text_assertions, so the NEXT write-only field fails on the
commit that adds it instead of surviving to be found by an audit.

Nothing was lost. expected_tool_calls was redundant with a channel that works --
the tool-trajectory diff measures tool use distributionally over N runs, which is
the job the static list only appeared to do. docs/fp-measurement.md's numbers are
unaffected: verdicts are byte-identical, and it cites no suite hash.

Also updates the superseded published report's note about what HEAD now serves,
and the README's test count (702 passing + 3 xfail = 705 collected).
…at was backwards

The gates in CLAUDE.md's routing table, run over this session's work. Everything
below was found by them, not by me, and each was reproduced before it was fixed.

claims review, and this is the one that mattered:

- THE REFUSAL SUITE'S STATED RATIONALE DESCRIBED A SIGNAL THE ENGINE DOES NOT HAVE.
  examples/refusal-suite/ said "a model that GAINS the capability stops declining --
  exactly the migration signal Modelpin exists to catch". [M] Reproduced on the
  engine: baseline 5/5 refused -> candidate 0/5 returns `unchanged`, confidence
  1.00, refusal_delta -1.0. The channel is ONE-SIDED by design (ADR-0002) and
  cannot see a drop; on a suite with no tools and no assertions that direction is
  visible only to a configured judge. A reader running the reproduction in that
  README on a browsing-enabled model would have got `unchanged` at confidence 1.00
  and concluded the tool was broken. The one-sidedness is a deliberate FP
  protection and is NOT changed; the claim is.
- The same error made my `allam-2-7b` inference backwards: I read `unchanged` as
  proof it had also refused, "because a 5/5 -> 0/5 flip could not produce
  `unchanged` at N=5". It produces exactly `unchanged`. Those 25 traces are [A].
- And the denominator was wrong: 75 of 90 counted refusals against a total that
  INCLUDED the 15 anchor traces reported separately. Measured is 50 of 50.
- The public suite README now opens with the fact that its numbers are NOT
  reproducible from this repository -- the raw traces were never published, unlike
  the Drift Map's.
- "Every check run leaves an artifact" -> a run that produces a report does. Two
  paths exit before one exists.
- "five places, now generated from one source" -> four render from provider_help();
  action.yml cannot call Python and is test-pinned instead.
- The differential proved `expected_tool_calls`; `output_schema` had zero readers.
- examples/suite's fingerprint moved too (44cbde8e3b74 -> 3edf6b1ae19a) and was
  announced nowhere public. The CHANGELOG entry stated the principle and applied it
  to one of the two shipped suites.
- README said no FP rate has been measured on "any of them" where "them" included
  OpenAI -- contradicting the same sentence's first clause.
- The README's own free-run example had the candidate judging itself, the exact
  configuration `mp check` warns about and that demoted the self-judge calibration.
- `mp init`'s scaffolded modelpin.yaml still named the retired llama id -- written
  verbatim into every new user's config, 56 lines from the entry declaring it dead.
- The banned-word guard covered only report-suite, so the new public suite landed
  outside it. It now parametrises over every directory roles.json declares.

provider-SDK review:

- `_is_reasoning_model` missed the whole gpt-5 family. [S] Azure's reasoning-models
  page lists temperature/top_p/max_tokens as unsupported there, and a live 400 reads
  "Only the default (1) value is supported". [M] Our own data/models.json ships
  gpt-5.5 as ACTIVE and points gpt-5.2 at it -- so the registry recommended a judge
  model that would 400 on every call, and preflight() makes no network request, so
  it would have failed only after every replay was paid for. Also fixed the
  namespaced-id hole: `openai/gpt-5.6` and `openai/o3` are real host ids a bare
  prefix can never match -- precisely the hosts MP-143 just opened the judge to.
- A non-OpenAI judge error named the wrong vendor: `_explain_api_error`'s `label`
  defaults to "OpenAI", so a rejected Groq key read "read your key from
  GROQ_API_KEY - check that variable holds a current OpenAI key". MP-136's exact
  defect, reintroduced hours earlier on the path MP-143 created.
- OpenRouter ROUTES: [S] its docs say an upstream that does not support a parameter
  still receives the request and ignores it. `temperature: 0` returns HTTP 200 and
  is silently dropped, so the judge quietly stops being deterministic with no error
  to catch. `require_parameters` now rides OpenRouter judge requests only.

first-run review:

- The MP-147 advisory answered every unknown key with "this version checks
  must_contain / must_not_contain". Right for a typo, WRONG for
  `expected_tool_calls`: there is no text assertion for "expect this tool call", so
  the reader either guesses the field is safe to delete or tries to shoehorn a tool
  check into a string match. Removed keys now get the remedy true for them.
- `Unknown provider: x (try: ... run.))` -- provider_help() ends in its own
  parenthetical and the caller wrapped it in another.
- `check --help` documented no exit codes, which is where someone staring at a
  nonzero exit in a CI log looks. MP-148 made 3 far more reachable.
… test

A visitor who sees a co-author trailer or an internal review-agent handle
concludes the project is machine-generated and leaves without reading it. For a
product whose entire pitch is measurement rigour, that read is fatal.

The rule existed as a setting since 2026-08-29 and it still recurred: [M] handles
had reached 22 places in tracked source -- cli.py, judge.py, providers/, report/,
nine test files and CHANGELOG.md -- because a preference does not fail a build.
All 22 now name the gate by what it CHECKS: FP review, claims review, provider-SDK
review, first-run review, packaging review.

tests/test_no_tool_attribution.py makes it enforceable, and three of its own bugs
are worth recording because each would have made it a guard that reports "clean":

- [M] `text=True` without an explicit encoding decodes with the Windows ANSI
  codepage. This repo's own history holds a byte cp1252 cannot map, so the reader
  thread died, stdout came back None, and returncode was still 0.
- [M] A `%H%x00%B%x00` format split on two NULs mis-framed the stream -- git writes
  a newline between records, so each body bled into the next and the guard's first
  run named a commit that was clean.
- [M] git writes the key as `Co-authored-by:` while the tool that adds it writes
  `Co-Authored-By:`. An exact-case needle catches one of the two.

And the count it ratchets was wrong all day: [M] not 38 trailered commits but 42,
confirmed by two independent methods. The 38 came from `grep -c` over a format
containing NULs, which grep read as binary and under-counted. The related landmine
claiming "38 SHA refs in 21 threads" is wrong twice over -- [M] it is 21 SHAs
across 13 PR bodies.

Scoped at a pinned commit, not a date: [M] a date boundary cannot work in a
squash-merge repo, because GitHub replays a branch's message onto a commit stamped
with the MERGE time -- b5c5e40 is authored and committed 2026-08-31 and carries a
trailer from before the setting. Whether to rewrite that history is the
maintainer's call and stays out of the test.

A counter-test guards the other direction: Modelpin WATCHES model releases, so
Anthropic model ids in the registry, the provider, and the detector's regex are the
product working and must never be scrubbed by over-applying this rule.
… declared

An UNUSED `tools:` key bought a false green. `[M]` On byte-identical fixtures, a
scenario declaring one tool that no run on either side ever called turned
`could not measure ... NOT cleared on content` into `no behavioral change ...
looks safe to adopt` -- over a baseline of "FRAUD DETECTED: block this
transaction" against a candidate of "Looks fine, approve it. asdf qwerty zzzz
garbage." The census read the suite and the config; it never read the traces.

A declared tool that nothing calls sits exactly where refusal already sits: it
can only fire if the candidate STARTS calling tools, so a confident wrong answer
never touches it. `hard_content_channels` had always excluded refusal for that
reason, and this applies the same rule one case over. Worse, the disclosure's own
remedy was the exploit -- it said "add `tools`", and following that advice was
what bought the green.

`[M]` Not hypothetical: on a six-scenario suite run against `openai/gpt-oss-120b`,
all six scenarios declared `tools` and only four ever called one.

The census now requires BOTH a declaration and a recorded call. The FP review
blocked the first cut, which read the trace alone and was not monotone: a scenario
with no `tools` key whose traces carried `tool_calls` went from "NOT cleared" to
"looks safe to adopt" -- MP-159's own defect arriving through the other door, in
the commit that fixed it. The conjunction makes the new blind set a superset of the
old one for every input, so this change can only withhold a clearance, never grant
one. That path is now pinned by a test; nothing had pinned it.

No verdict, confidence or exit code moves -- the census governs disclosure only.
The candidate traces had to be captured in the replay loop to make any of this
possible: `cand` was a loop local and `DiffResult` carries none.

This closes the declaration-shaped instance, not the class. A tool called
identically on both sides still clears a run whose answer inverted, because the
trajectory channel reads what the model does, not what it says -- pinned as a
strict xfail rather than left to a tracker (MP-165).

Review also corrected: the remedy named scenario ids as though they were tool
names and wrapped mid-claim at 80 columns; the new string was reachable by no
cp1252 test, the gap whose shape once shipped as a console crash; and the README's
collected-test count had been left at its pre-change value.

`[M]` /verify READY -- 8 steps, 723 passed + 4 xfailed, 94% coverage, ruff + black
clean, mypy 5 = baseline.
`[M] 2026-09-02` `test_both_paths_are_printed` asserted a raw substring against
console output. Rich wraps at the console width and will break a path mid-token,
so whether `last-report.md` survives intact depends only on how long the temp
directory happens to be. It passed on Windows and FAILED on both Linux CI jobs --
CI's `/tmp/pytest-of-runner/...` split it as `last-repor` + `t.md`, the local
`C:/Users/.../Temp/...` broke one character earlier.

This has been red on CI and has blocked PR #67 and #68 from merging. The product
was never wrong; the test was measuring where the line happened to break.

Compared with all whitespace stripped now -- the same correction three assertions
in `test_error_messages_survive_rich.py` needed today, for the same reason.
@samarthputhraya
samarthputhraya merged commit 5993ad6 into main Sep 2, 2026
4 checks passed
@samarthputhraya
samarthputhraya deleted the fix/mp-159-census-from-traces branch September 2, 2026 10:31
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