A dormant deterrent must not look dead, and the ceiling must not depend on a silence - #89
Merged
Merged
Conversation
added 2 commits
September 4, 2026 20:12
…ra#81) `renderedCount` exempts every matchable clause from the per-tier ceiling, and that exemption is what makes a mining pipeline structurally unable to consume the prompt budget. It is not a property of the schema: it holds only because the render paths test a patterned clause against the call and drop it. That is a cross-module dependency, and `ablate.ts` says so two lines above the function. Asserting `renderedCount === 0` alone would pass for the wrong reason the day a render path stops dropping — the count would simply be over the wrong population. So these assertions name the behaviour instead: - over a corpus of matchable mined clauses past the ceiling, `renderedCount` is 0 *and* `selectClauses` reports every one of them as `evaluated-missed` with nothing selected and no budget spent; - a clause that does match is rendered for that call and the count still does not move, so the exemption is per call rather than per corpus; - the same clauses as prose consume the whole ceiling, so the zero is the pattern test and not the fixture; - `coreClauses` / `renderCore` exempt them too. eranra#81 names only the selector, but there are two channels a patternless clause reaches the prompt through, and relaxing the core one would push every mined red into the cached prefix — the standing instruction budget, plus a revision-hash change on every running session. Strictly worse, and equally silent. Mutation-proved both ways: removing the selector's `evaluated-missed` branch fails the first two while `renderedCount` keeps returning 0, which is exactly the wrong-population failure the issue describes; relaxing `coreClauses` fails the fourth. A test rather than a runtime invariant, because no runtime input can reach this state: the filters are code, there is no `SelectOptions` field or setting that reintroduces an unfiltered bundle, and a hand-edited artifact cannot make a patterned clause render either. A per-decision branch guarding against a source edit CI already sees would be a counter nobody reads.
…ranra#85) `lifetimeFires` was computed from the trail, and the trail rotates at 4 MiB keeping one generation. So a red that fired steadily for months and is quiet this week was indistinguishable from one that never fired: `classify()` called it `dead-weight?` or `insufficient-exposure` instead of `deterrent`, and the listing a human reads proposed retiring a clause whose whole value is that it stopped something being tried. `pipeline/citations.json` is one number per clause, folded by the same offset-driven Stage A pass that already advances the trail offset, and read by `ablate` as `lifetimeFires`. **Monotonic, structurally.** `raise` is the only writer and it assigns `max(prior, candidate)`. There is no subtraction, no reset and no other assignment in the module, so "the count went down" is not a bug to avoid but a value the writer cannot express. A rotation that shrinks the trail therefore cannot shrink the count, and new fires still add on top of the held number. **Idempotent.** The fold commits an offset and a tail hash per generation, so a re-run reads nothing. When an offset fails its tail-hash check the module re-reads both generations whole and merges the recount with `max` rather than adding it, so re-reading counted bytes cannot double them either. Its ceiling is documented in place: fires living only in bytes rotated away before any fold saw them are lost, so the count can lag the truth but never exceed it and never fall — and an undercount can only make a deterrent look under-exposed, never make a live clause look dead. Its own file rather than `shapes.json`, even though that file already carries offsets over the same bytes: `readShapes` discards a file whose version does not match and rebuilds it from the trail, which is right for derived counts and fatal for a lifetime one. The two have opposite disposability. Not a `DecisionRecord` field either — the trail is append-only, so a stored derived value goes stale with no way to correct or detect it. `AblationOptions.citations` is the corpus-wide form of the `lifetimeFires` seam, because `ablateAll` ablates every clause against one options object. Combined with the record scan by `max`, never replacing it: the durable count lags fires that arrived since the last fold, the scan lags a rotation, and each is a floor under the truth. The transition table, every entry produced by calling `classify()`: `lifetimeFires` is read on exactly one path (`changed === 0 && matches === 0 && isSafetyLevel(level)`), so of six cases two move — `dead-weight?` → `deterrent` and `insufficient-exposure` → `deterrent` — and four are no-ops (`in-service`, `shadowed`, `retire` twice). `retirement_candidate` is `changed === 0 && !isSafetyLevel(level)` and does not read the count at all, so no value of it can propose a retirement that was not already proposed. All six `EvidenceClass` members are asserted reachable by construction. Mutation-proved, each half independently: `raise` as a plain assignment fails 3 tests; never committing an offset fails 5; the re-read branch adding instead of merging fails 2; `classify` requiring two fires fails 2; `ablate` ignoring the durable count fails 2; `??` instead of `max` fails 1; `accumulate` not folding fails 1.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Clause evidence integrity — closes #81 and #85
Two issues, one theme: clause evidence that is wrong in a way nothing complains about. Neither is
a feature. Base
origin/main(fd87f2a), branchpr/evidence-integrity, two commits — one perissue, in one branch because #85 changes
ablate.ts's options and #81 assertsablate.ts's ceilingagainst
select.ts, so a reviewer reading either wants the other in front of them; and because onebranch means one
plugin/libregeneration instead of two that conflict.make check: 106 files / 2,722 tests → 107 files / 2,749 tests, green.make pluginrun last,plugin/libcommitted,ci/check-plugin-lib.shreports "plugin/lib is the current build of src/".No new dependency, no
.py, no new code path that can produce an approval.#81 — the rendered-ceiling exemption depends on another module and nothing asserted it
renderedCountcounts onlypatterns.length === 0, so a matchable clause is exempt from the per-tierprompt ceiling. That exemption is what makes pipeline output structurally unable to consume the prompt
budget — every mined candidate carries an anchored matcher by construction. It is not a property of
the schema, and
ablate.tssays so itself two lines above the function.Test only, no runtime check, and the justification is that no runtime input can reach the bad state.
The filters are code. There is no
SelectOptionsfield, noPluginSettingskey, and nosettings.jsonknob that reintroduces an unfiltered bundle; a hand-edited compiled artifact cannot do it either,
because both render paths filter on
patternsat render time regardless of what the artifact holds.Reaching this state takes a source edit, which is exactly what CI sees. A runtime observation would be
a per-decision branch guarding against a code change, producing a counter nobody reads — a silence
that reads as success, which is the bug shape this wave is hunting rather than a defence against it.
What the test asserts (
src/test/policy/ablate.test.ts, 4 tests). Over a corpus of 35 matchablemined clauses — deliberately past
CEILING_PER_TIER:renderedCountis 0 for both learned tiers andselectClausesreports all 35 asevaluated-missed, with nothing selected and zero budget spent.exemption is per call, not per corpus.
fixture failing to be a learned clause at all.
coreClauses/renderCoreexempt them too.Mutation evidence
selectClauses'sevaluated-missedbranch (patterned clauses fall through tofill)renderedCountkept returning 0 throughout — which is the wrong-population failure #81 describes, demonstrated rather than arguedcoreClausesto drop itspatterns.length === 0filterselect.test.tsHonest scoping of what was and was not already covered. The selector's drop behaviour was
already tested in
select.test.ts, andcoreClauses's filter incompile.test.ts. What was missingis the link: a reviewer who removes the filter sees
select.test.tsfail, reasonably concludes thetest encodes the old behaviour, updates it, and never learns the prompt ceiling depended on it. These
tests fail with the ceiling named in the failure. That is the whole contribution and it is not more
than that.
Found, not fixed
pipeline.tsreports the ceiling outcome as a hardcodedrendered: 0for both learned tiers(with a comment saying why). It is honest given the code, but it is a literal, so it cannot move the
day the exemption stops holding — which is precisely the day the run line ought to move. Making it
real needs the compiled artifact inside
propose(), which only hasClause[]. Left alonedeliberately: what the literal claims is now asserted by test 1, so the claim is checked even though
the number is not computed.
#85 — a dormant deterrent looked dead after the trail rotates
lifetimeFirescame from the trail; the trail rotates at 4 MiB keeping one generation. A red thatfired steadily for months and is quiet this week was indistinguishable from one that never fired, so
classify()returneddead-weight?orinsufficient-exposurewhere the right answer isdeterrent— and a retirement got proposed for a clause whose whole value is that it stopped something being
tried.
pipeline/citations.json(src/policy/citations.ts, new): one number per clause, folded by thesame offset-driven Stage A pass, read by
ablateaslifetimeFires. It reusesmine.ts'sreadNewBytesandtailShaAtrather than writing a second offset machine (both are now exported;SourceStatetoo).Monotonic in effect — structurally, not by discipline
raiseis the only path that writes a count and it assignsmax(prior, candidate). Nosubtraction, no reset, no other assignment in the module. "The count went down" is therefore not a bug
that can be introduced by getting a branch wrong; it is a value the writer cannot express.
Two consequences that are asserted, not asserted-about: a rotation that shrinks the trail cannot
shrink the count, and new fires after that rotation still add on top of the held number.
Idempotent
The fold commits an offset plus a tail hash per generation, so a re-run reads no lines and changes
nothing. When an offset fails its tail-hash check — rotation, truncation — both generations are re-read
whole and the recount is merged with
maxinstead of added, so re-reading already-counted bytes cannotdouble them either.
The ceiling, documented in place: fires living only in bytes rotated away before any fold saw
them are lost, so a post-rotation count can lag the truth. It can never exceed it and never fall,
which is the direction that matters —
classify()askslifetimeFires >= 1, so an undercount canonly make a deterrent look under-exposed, never make a live clause look dead. Upgrade path noted: fold
on rotation rather than at session end.
Why its own file, and why not a record field
Not a
DecisionRecordfield: the trail is append-only, so a stored derived value goes stale with noway to correct it and no way to detect that it has.
Not
shapes.json, even though that file already carries offsets over the same bytes:readShapesdiscards a file whose
versiondoes not match and rebuilds it from the trail. That is right forderived counts and fatal for a lifetime one — a rebuild from a rotated trail resets the count downward,
the exact fabricated-dead-clause failure. The two files have opposite disposability, so they are two
files.
readCitationsis correspondingly the mirror image ofreadShapes: on a version mismatch itkeeps the counts and drops the offsets, because the counts are the part that cannot be rebuilt.
Cost: a second read of the same ≤8 MiB at session end.
The class-transition table — every entry produced by calling
classify()classify(level, changed, lifetimeFires, misses, matches)readslifetimeFireson exactly one path:changed === 0 && matches === 0 && isSafetyLevel(level). Everywhere else it is dead input. So thereare two transitions and four no-ops, and all six are asserted at
lifetimeFires0 and 1:lifetimeFires: 0lifetimeFires: 1changed 0, near-miss present — the misclassification #85 is aboutdead-weight?deterrentchanged 0, no near-missinsufficient-exposuredeterrentchanged 3)in-servicein-servicechangedshort-circuitsmatches 4)shadowedshadowedmatchesshort-circuitschanged 0retireretirechanged 5in-servicein-serviceBoth transitions move towards
deterrent, i.e. towards do not retire this. Verified rather thanassumed:
EvidenceClasshas six members withretirefirst (ablate.ts:70); all six are assertedreachable by construction;
retireis unreachable for red/orange and the three safety classes areunreachable for green/yellow, because
isSafetyLevelis red-or-orange. No class string literal appearsas an expectation anywhere in the new tests.
Blast radius: none on what gets proposed.
retirement_candidateischanged === 0 && !isSafetyLevel(level)and does not read the count at all, asserted acrosslifetimeFiresof 0, 1 and 10,000 for both a red and a green. Fail-closed holds: this changes alabel a human reads, never a verdict and never a proposal.
Callers, all of them
classifyhas exactly one caller,ablate;ablateis reached throughablateAllfromsrc/cli/learn.tsandsrc/policy/cli.ts. Both now passcitations.AblationOptions.citationsisthe corpus-wide form of the existing
lifetimeFiresseam, becauseablateAllablates every clauseagainst one options object and a single number cannot serve a corpus. It combines with the record scan
by
max, never replacing it: the durable count lags fires that arrived since the last fold, thescan lags a rotation, and each is a floor under the truth.
session-sitter learnfolds viaaccumulate('cli')before ablating, so it reads the freshest count available;policy ablatereportswithout folding, so an unfolded counter leaves the record scan in charge — the pre-existing behaviour.
Mutation evidence — each half reverted independently
raisebecomes a plain assignmentmaxclassifyrequires two fires fordeterrent(>= 1→>= 2)ablateignoresopts.citations??instead ofmaxwhen combining (takes a stale zero over a live scan)accumulateno longer folds citationsAlso found, not fixed
docs/superpowers/specs/.../11-pipeline.mdcalls the Stage A aggregateshapes.jsonl; the codewrites
shapes.json, a single JSON object, not JSONL. The doc is a prior draft under review, so itis named here rather than edited, and
citations.jsonis likewise undocumented there.pipeline.ts's hardcodedrendered: 0— see The rendered-ceiling exemption depends on the selector, and nothing asserts it #81 above.