feat(optimize): add --subtract, the subtraction axis, behind a deterministic floor

Every command so far asked an addition question — what to add, what to move,
what it costs. Nothing asked what is no longer earning its always-loaded rent.
This adds that axis as a fourth lensCheck on the existing hybrid motor rather
than a new scanner or a 22nd command: the measured payoff (~18% of one file)
justifies a mode, not machinery.

It is the only lens that proposes REMOVING config, so it carries a guarantee
the others don't need: a load-bearing block is never a candidate. Precision is
asymmetric — a missed dead line costs a few tokens per turn, a deleted one
costs a wrong remote or a broken script — so the floor is decided in code
(lib/floor-exclusion.mjs) before the opus judge sees anything, never in prose.

Granularity is the leaf block, with two structural exceptions: a paragraph
ending in ':' merges with the list it introduces, and an ordered list is a
contract whose steps inherit floor from any sibling. Unordered lists
deliberately do not inherit — a load-bearing bullet and a disposable one
routinely share a list, and container-reasoning is the error the hand-built
ground truth exists to catch.

Verified against that ground truth (built before any classifier existed), with
the comparison machine-checked rather than read by eye: zero load-bearing
blocks proposed, 11/18 deletable groups surfaced, ~756 tok ~ 18% of a ~4300
token file — inside the pre-registered band. The first run found five floor
violations the synthesized fixture missed; each got a structural rule and a
fixture shape so it cannot regress.

Three real bugs the dogfood run exposed, all now covered:
- JS \b is ASCII-only, so /\bunngå\b/ never matches — every Norwegian keyword
  ending in æ/ø/å was silently dead.
- A bare word/word is not a path; "pros/cons" vetoed the largest deletable
  block until PATH_RE was tightened to rooted paths and globs.
- "Mid-sentence" must key on a preceding lowercase letter; the loose version
  read **bold labels:** and quoted openers as entities, costing 4 of 11 groups.

BP-SUB-001 is grounded entirely in the Anthropic steering blog already cited by
BP-MECH-001..004 and asserts nothing from the talk that motivated the feature —
no "80%", no ablation figure.

Suite 1365 -> 1382/0. Frozen v5.0.0 snapshots untouched; plain optimize output
byte-identical on identical input (--subtract adds keys only when passed).
knowledge-refresh-cli's reference date moved to 2026-08-01: its premise that
every seed entry was verified 2026-06-20 expired when BP-SUB-001 got a genuine
verification date, and backdating the entry to fit the test would have been a
lie about when its source was checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RW2haJXbxZpKivKHseSXNh
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 16:24:51 +02:00
commit e9921d3c9d
13 changed files with 1080 additions and 32 deletions

View file

@ -0,0 +1,98 @@
# Subtraction shapes — synthesized fixture
This fixture reproduces the *discriminating shapes* from the hand-built subtraction fasit
(økt #40) without reproducing any of the operator's private config text. Every block below
exists to make one classification call hard in the same way a real block was hard.
Both languages appear on purpose: the config this lens was designed against is Norwegian
prose with English identifiers, and a detector that only speaks English would score well
here and fail in the field.
## Preferences
- Language: Norwegian for dialogue, English for code and documentation.
- Tone: direct and technical, minimal ceremony.
- Format: be concrete and action-oriented, avoid unnecessary text.
- Never say "as an AI I cannot"; skip apologies and over-explanation.
## Kodepreferanser
- TypeScript > JavaScript
- Readability > cleverness
- Conventional Commits: `type(scope): description`
## Oppstart
1. Run `pwd` to see where you are.
2. Read the nearest STATE.md and confirm the active task.
3. Sjekk `git status` mot det STATE.md hevder.
4. Summarize before you do anything.
## Arbeidsregler
- Commit often with descriptive messages.
- Test incrementally, and ask when blocked.
- Tenk før du koder — uttal antakelser eksplisitt og spør ved tvetydighet.
- Aldri gjett på rotårsak; undersøk først.
## Defensive shell-scripting
- Alltid siter variabel-ekspansjoner (`"$var"`) — en usitert ekspansjon brøt en zsh-loop.
- Hold testkode ASCII-ren; et multibyte-tegn krasjet bash 3.2 under `set -u`.
## Anti-patterns
- Do not one-shot large features.
- Never declare "done" without running the tests.
- Aldri jobb i et annet repo enn det økten står i. Hvert repo eier sin egen
versjonsplan og sin egen tilstand. Skriver du der, tar du beslutninger på
deres vegne uten deres kontekst — `coord-send` er mekanismen, send arbeidet
dit i stedet.
- Do not refactor as part of a bugfix without approval.
## Git
- Aldri GitHub. Kun Forgejo (`git.example-forge.test`). Aldri `gh` CLI.
- Etter `git commit`: push til Forgejo umiddelbart.
## Miljø
- System bash er **3.2**: ingen `declare -A`, ingen `readarray`.
- Alle hook-scripts må være 3.2-kompatible.
## Modellvalg
Default for alt arbeid er Opus 4.8 med xhigh effort. Ingen annen modell skal brukes.
Sett `model: "opus"` på hver spawn. Haiku: aldri.
## Secrets
- ALDRI commit secrets. Bruk `.env`/`.template`-mønsteret.
### The Iron Law: NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
## Arbeidsflyt
Default for alle oppgaver: forstå problemet, vurder alternativer, presenter dem,
og vent på bekreftelse før du implementerer. Ved usikkerhet om en oppgave faller
i unntakene: den gjør det ikke.
**Viktig:** `/harness continue` og andre skills styrer sin egen workflow.
## Kodeblokk
```bash
# Never run this in production, and always check every time first.
echo "avoid unnecessary output"
```
## Kontinuitet
Konteksten mellom sesjoner hviler på tre lag med hver sin ene jobb:
- **`STATE.md`** — current state-of-play der du jobber. Overskrives alltid ved sesjonsslutt.
- **`MEMORY.md`** — varige fakta om bruker. Hold den under 200 linjer.
## Verktøy
- Deleger arbeid til subagenter, og bruk Explore for søk.
- Skriv alltid kortest mulig kode.

View file

@ -0,0 +1,228 @@
/**
* subtraction-prefilter tests the deterministic half of the v5.13 subtraction
* lens (`/config-audit optimize --subtract`, CA-OPT / BP-SUB-001).
*
* The subtraction axis asks the inverse of every other command: *what is no
* longer earning its always-loaded rent?* Precision here is ASYMMETRIC a
* missed dead line costs a few tokens per turn, a deleted load-bearing line
* costs a wrong remote or a broken script (brief §6.0). So unlike
* `lens-prefilter` (recall-first, agent as the gate), this module is
* precision-first and carries a BLOCKING deterministic guarantee:
*
* **a load-bearing block never reaches the judge at all.**
*
* That guarantee is delivered by two independent mechanisms, and the contract is
* therefore asserted on the CANDIDATE LIST rather than on either mechanism:
* 1. the compensatory detector does not fire on declarative local facts
* ("Language: Norwegian for dialogue") they are never generated; and
* 2. floor-exclusion vetoes any leaf block carrying an underivable local
* literal (code span, path, domain, version pin) or a policy invariant.
* A test written against `isLoadBearing()` alone would miss group 1 entirely.
*
* GRANULARITY (the one design choice the fasit left open, §5): leaf-block one
* markdown list item including its wrapped continuation lines, or one paragraph.
* It must SPLIT a mixed numbered list and three consecutive bullets, and must
* KEEP WHOLE a bullet whose load-bearing literal sits on a continuation line.
*
* FIXTURE, not the real subject file. The fasit was built against the operator's
* private `~/.claude/CLAUDE.md`; that file is machine-dependent, edited between
* sessions, and this repo's only remote is a PUBLIC mirror. So the committed
* suite runs against a synthesized fixture that reproduces the discriminating
* *shapes*, and the run against the real file is the manual dogfood gate (brief
* §8), recorded in the worklist not a suite test.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
splitLeafBlocks,
subtractionCandidates,
floorExcluded,
SUBTRACT_DETECTORS,
} from '../../scanners/lib/subtraction-prefilter.mjs';
import { isLoadBearing } from '../../scanners/lib/floor-exclusion.mjs';
const FIXTURE = readFileSync(
fileURLToPath(new URL('../fixtures/subtraction/shapes-claude-md.md', import.meta.url)),
'utf8',
);
const candidateLines = (text) => subtractionCandidates(text).map((c) => c.line);
const covers = (cands, line) => cands.some((c) => line >= c.startLine && line <= c.endLine);
/**
* Every line of the fixture that the fasit labels FLOOR or POLICY-FLOOR. If any
* of these is covered by a candidate block, the gate fails outright whatever
* the recall. (brief §8, "zero load-bearing blocks proposed for deletion")
*/
const FLOOR_LINES = [
13, 14, // declarative preference facts (B-04 / B-05 shape) — must never fire
22, // format contract in a code span (B-30c) — adjacent to two other calls
26, 27, 28, 29, // ordered list = contract (B-16 / B-17): siblings 27-28 floor the whole run
91, 92, 93, // list stem + its floor items (B-09) — the stem carries no literal of its own
97, // "bruk Explore for søk" — an entity the mechanism cannot resolve, so it keeps it
40, 41, // generic-sounding advice wrapped around a local incident (B-27 — THE trap)
47, 48, 49, 50, // floor bullet inside a compensatory list (B-32b), literal on a continuation line
55, 56, // §8 named anchor: the remote (B-25) + the push workflow (B-23)
60, 61, // §8 named anchor: system bash version (B-26)
65, 66, // stale-in-content but load-bearing (B-29) — staleness is NOT a deletion signal
70, // policy invariant, floor by decision not classification (B-34)
72, // absolute policy prohibition as a heading (B-37)
80, // local fact about how skills behave (B-38b)
85, 86, // inside a fenced code block — never a candidate
];
/** Lines the fasit labels DELETABLE that the mechanism must actually surface. */
const MUST_SURFACE = [
15, // "avoid unnecessary text" — the B-06 half of the B-05/B-06 adjacent pair
16, // "Never say 'as an AI I cannot'" — textbook tier-3 compensatory
33, // "Commit often with descriptive messages" (B-19b) — the named pure-behaviour case
36, // "Aldri gjett på rotårsak" — Norwegian imperative
46, // "Never declare done without running the tests"
76, // the workflow paragraph (B-38a) — split from its floor line at 80
98, // "Skriv alltid kortest mulig kode" — survives next to an entity-vetoed sibling
];
describe('subtraction-prefilter — detector table', () => {
it('registers the subtraction class against BP-SUB-001, separately from LENS_DETECTORS', async () => {
const map = new Map(SUBTRACT_DETECTORS.map((d) => [d.lensCheck, d]));
assert.ok(map.has('compensatory-instruction'), 'subtraction lensCheck must exist');
assert.equal(map.get('compensatory-instruction').registerId, 'BP-SUB-001');
// Byte-stability: the three mechanism-fit detectors drive the plain
// `optimize` payload's `register` block. Adding the subtraction class there
// would change plain-optimize output, which the brief forbids.
const { LENS_DETECTORS } = await import('../../scanners/lib/lens-prefilter.mjs');
assert.equal(LENS_DETECTORS.length, 3, 'plain optimize must still expose exactly 3 detectors');
assert.ok(
!LENS_DETECTORS.some((d) => d.lensCheck === 'compensatory-instruction'),
'the subtraction class must not leak into the plain-optimize detector table',
);
});
});
describe('subtraction-prefilter — leaf-block granularity (the declared contract)', () => {
it('keeps a wrapped bullet whole, so a continuation-line literal still protects it', () => {
const blocks = splitLeafBlocks(FIXTURE);
const b32b = blocks.find((b) => b.startLine === 47);
assert.ok(b32b, 'the wrapped bullet must start its own block at line 47');
assert.equal(b32b.endLine, 50, 'its continuation lines belong to the same block');
});
it('splits three consecutive bullets into three blocks (three different calls)', () => {
const blocks = splitLeafBlocks(FIXTURE);
for (const line of [20, 21, 22]) {
assert.ok(
blocks.some((b) => b.startLine === line && b.endLine === line),
`line ${line} must be its own leaf block`,
);
}
});
it('treats an ordered list as a contract: a floor sibling floors the whole run', () => {
// Steps 2-3 name the file to read and the check to run; steps 1 and 4 are
// filler. Splitting them would propose deleting step 4 of a five-step
// protocol — so the run inherits floor. (Structural exception 2.)
const cands = subtractionCandidates(FIXTURE);
for (const line of [26, 27, 28, 29]) {
assert.ok(!covers(cands, line), `ordered step at line ${line} must inherit floor`);
}
const reasons = floorExcluded(FIXTURE);
assert.ok(
reasons.some((r) => r.startLine === 29 && r.marker === 'ordered-contract'),
'step 4 must be recorded as floored by contract inheritance, not by its own literal',
);
});
it('merges a list stem with the items it introduces (structural exception 1)', () => {
const blocks = splitLeafBlocks(FIXTURE);
const stem = blocks.find((b) => b.startLine === 91);
assert.ok(stem, 'the stem paragraph must open a block at line 91');
assert.equal(stem.endLine, 93, 'the stem absorbs the list it introduces');
// The stem itself carries no literal — it is protected only by the merge.
assert.equal(isLoadBearing('Konteksten mellom sesjoner hviler på tre lag med hver sin ene jobb:'), false);
});
it('never emits a heading as a candidate block', () => {
const cands = subtractionCandidates(FIXTURE);
assert.ok(!covers(cands, 72), 'the Iron Law heading must not be a deletion candidate');
});
});
describe('subtraction-prefilter — THE GATE (brief §8, blocking)', () => {
it('proposes ZERO load-bearing lines for deletion', () => {
const cands = subtractionCandidates(FIXTURE);
const violations = FLOOR_LINES.filter((line) => covers(cands, line));
assert.deepEqual(
violations,
[],
`load-bearing lines proposed for deletion: ${violations.join(', ')} — gate fails outright`,
);
});
it('still surfaces genuinely compensatory blocks (recall is secondary, not zero)', () => {
const cands = subtractionCandidates(FIXTURE);
const missed = MUST_SURFACE.filter((line) => !covers(cands, line));
assert.deepEqual(missed, [], `expected these to be candidates: ${missed.join(', ')}`);
});
it('separates the adjacent near-identical preference bullets (B-05 vs B-06)', () => {
const lines = candidateLines(FIXTURE);
assert.ok(!lines.includes(14), 'the tone preference is a fact about the human — keep');
assert.ok(lines.includes(15), 'the "avoid unnecessary text" nag is compensatory — surface');
});
it('does not fire inside fenced code blocks', () => {
const cands = subtractionCandidates(FIXTURE);
assert.ok(!covers(cands, 85) && !covers(cands, 86), 'fenced code is not config prose');
});
});
describe('floor-exclusion — the deterministic veto', () => {
it('vetoes underivable local literals', () => {
assert.ok(isLoadBearing('Aldri GitHub. Kun Forgejo (`git.example.test`).'), 'code span + domain');
assert.ok(isLoadBearing('System bash er **3.2**: ingen declare -A.'), 'version pin');
assert.ok(isLoadBearing('Read the nearest STATE.md before starting.'), 'concrete filename');
assert.ok(isLoadBearing('Tokens live in ~/.zshenv on this machine.'), 'home path');
});
it('vetoes policy invariants regardless of phrasing (§6.0 carve-out)', () => {
assert.ok(isLoadBearing('ALDRI commit secrets.'), 'secrets are floor by decision');
});
it('does not veto generic behaviour correction carrying no local fact', () => {
assert.equal(isLoadBearing('Commit often with descriptive messages.'), false);
assert.equal(isLoadBearing('Think before you code and avoid unnecessary text.'), false);
});
it('vetoes an unresolvable mid-sentence entity, but not a bold label or a quoted opener', () => {
// The conservative default: a product/tool name is local vocabulary the
// mechanism cannot resolve without a dictionary, so it keeps the block.
assert.ok(isLoadBearing('push til deres egne Forgejo-remotes også'), 'mid-sentence entity');
// …but these are sentence openings dressed in punctuation. Reading them as
// entities cost 4 of 11 deletable groups in the dogfood run.
assert.equal(isLoadBearing('- **Format:** Konkret og handlingsorientert'), false, 'bold label');
assert.equal(isLoadBearing('- **Aldri:** "Som AI kan jeg ikke..."'), false, 'quoted opener + acronym');
});
it('does not treat a bare word/word as a path (it is not one)', () => {
// "pros/cons" vetoed the largest deletable block until PATH_RE was tightened.
assert.equal(isLoadBearing('Presenter alternativene med pros/cons og foreslå ett valg.'), false);
assert.ok(isLoadBearing('Store baselines under ~/.config-audit/baselines/.'), 'a real path still vetoes');
});
});
describe('subtraction-prefilter — Unicode word boundaries', () => {
// JS `\b` is ASCII-only, so /\bunngå\b/ never matches: the trailing "å" is
// not a word character. Every Norwegian keyword ending in æ/ø/å was silently
// dead until the dogfood run surfaced it.
it('matches Norwegian keywords ending in æ/ø/å', () => {
const c = subtractionCandidates('- Vær konkret og handlingsorientert, unngå overflødig tekst her.');
assert.equal(c.length, 1, '"unngå" must be detected despite the trailing å');
});
it('still requires a whole-word match', () => {
assert.deepEqual(subtractionCandidates('- Systemet er uunngåelig komplekst og stort.'), []);
});
});

View file

@ -9,10 +9,18 @@ import { tmpdir } from 'node:os';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const CLI = resolve(__dirname, '../../scanners/knowledge-refresh-cli.mjs');
// The bundled register's seed entries are all verified 2026-06-20, so a reference
// date of 2026-06-21 makes every entry exactly 1 day old — deterministic regardless
// of the real clock. We exploit that to exercise both the fresh and stale branches.
const REF = '2026-06-21';
// The reference date must sit AFTER the newest entry's `verified` date, so every
// entry is at least one day old and both the fresh and stale branches are
// reachable deterministically, regardless of the real clock.
//
// It used to be 2026-06-21, on the premise that every seed entry was verified
// 2026-06-20. That premise expired when BP-SUB-001 was added with a genuine
// 2026-07-31 verification date — an entry verified *after* the reference date has
// a negative age and counts as fresh, so `--stale-after 0` no longer emptied the
// fresh bucket. Backdating the entry to fit the test would have been a lie about
// when its source was checked; moving the reference date is the honest fix.
// Bump this again when a newer entry lands.
const REF = '2026-08-01';
function runCli(extraArgs) {
try {
@ -56,7 +64,7 @@ describe('knowledge-refresh-cli — payload shape', () => {
const out = JSON.parse(stdout);
assert.equal(out.status, 'ok');
assert.match(out.registerPath, /knowledge\/best-practices\.json$/);
assert.equal(out.referenceDate, '2026-06-21');
assert.equal(out.referenceDate, REF);
assert.equal(out.staleAfterDays, 90);
assert.ok(Array.isArray(out.stale));
assert.ok(Array.isArray(out.fresh));
@ -78,7 +86,12 @@ describe('knowledge-refresh-cli — payload shape', () => {
const s = out.stale[0];
assert.match(s.id, /^BP-[A-Z]+-\d{3}$/);
assert.match(s.verified, /^\d{4}-\d{2}-\d{2}$/);
assert.equal(s.ageDays, 1);
// Was `=== 1`, which only held while every entry shared one verified date.
// Assert the invariant that actually matters: ageDays is a positive integer
// and agrees with this entry's own `verified` date against the reference.
const expectedAge = Math.round((Date.parse(REF) - Date.parse(s.verified)) / 86400000);
assert.equal(s.ageDays, expectedAge);
assert.ok(s.ageDays >= 1, 'a stale entry is at least a day old');
assert.ok(s.url && s.claim);
});
});