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
350 lines
13 KiB
JavaScript
350 lines
13 KiB
JavaScript
/**
|
||
* subtraction-prefilter — deterministic candidate generator for the v5.13
|
||
* subtraction lens (`/config-audit optimize --subtract`, BP-SUB-001).
|
||
*
|
||
* Every other command in this plugin asks an ADDITION question — what could you
|
||
* add, what would fit a better mechanism, how expensive is what you have. This
|
||
* module asks the inverse: **what is no longer earning its always-loaded rent?**
|
||
*
|
||
* It is the mirror image of `lens-prefilter` in one important way. That module
|
||
* is recall-first, because a false candidate only costs the judge a moment's
|
||
* thought. Here a false candidate is a proposal to DELETE something, so the
|
||
* polarity flips: precision-first, and a hard deterministic floor
|
||
* (`floor-exclusion`) that the judge is not allowed to override.
|
||
*
|
||
* ## Granularity: leaf blocks
|
||
*
|
||
* The one design choice the hand-built fasit deliberately left open. A block is
|
||
* one markdown *leaf*: a list item including its wrapped continuation lines, or
|
||
* a paragraph. Headings, table rows and fenced code are structural, never
|
||
* candidates.
|
||
*
|
||
* Both halves of that choice are load-bearing, and the fasit tests both:
|
||
* - It must SPLIT. A numbered list whose steps 2–3 are local facts and whose
|
||
* steps 1 and 4 are filler is a mixed block; section granularity would have
|
||
* to keep or drop all four.
|
||
* - It must NOT split further. A bullet's load-bearing literal often sits on a
|
||
* wrapped continuation line ("…— `coord-send` er mekanismen"). A
|
||
* line-granular mechanism severs the first line from the fact that protects
|
||
* it and proposes a floor block for deletion — the exact failure the gate
|
||
* exists to prevent.
|
||
*
|
||
* ## Two independent guarantees, not one
|
||
*
|
||
* A load-bearing block fails to become a candidate for either of two reasons,
|
||
* and both are needed:
|
||
* 1. it is *declarative* — "Language: Norwegian for dialogue" states a fact
|
||
* about the human and corrects no behaviour, so no detector fires; or
|
||
* 2. `floor-exclusion` vetoes it for carrying an underivable local literal.
|
||
* Group 1 never reaches the veto at all, which is why the contract is asserted
|
||
* on the candidate list rather than on either mechanism alone.
|
||
*
|
||
* Norwegian and English are both first-class: the config this was designed
|
||
* against is Norwegian prose carrying English identifiers.
|
||
*
|
||
* Zero external dependencies. Pure: input text → candidate array.
|
||
*/
|
||
|
||
import { floorMarker } from './floor-exclusion.mjs';
|
||
|
||
/**
|
||
* The subtraction detector, kept in its OWN table. `LENS_DETECTORS` drives the
|
||
* plain `optimize` payload's register block, and the subtraction axis must not
|
||
* fire on a plain run — it asks a different question and the operator has to
|
||
* opt into it with `--subtract`.
|
||
*/
|
||
export const SUBTRACT_DETECTORS = Object.freeze([
|
||
{ lensCheck: 'compensatory-instruction', registerId: 'BP-SUB-001', mechanism: 'deletion' },
|
||
]);
|
||
|
||
/**
|
||
* Absolute / insistent phrasing. An instruction that has to shout is usually
|
||
* correcting behaviour rather than stating a fact.
|
||
*/
|
||
/**
|
||
* Word boundaries that understand æ/ø/å.
|
||
*
|
||
* JavaScript's `\b` is ASCII-only, so `/\bunngå\b/` never matches "unngå " —
|
||
* the trailing "å" is not a word character, so there is no boundary after it.
|
||
* Every Norwegian keyword ending in æ/ø/å was silently dead until the dogfood
|
||
* run surfaced it. Do not reintroduce `\b` around this vocabulary.
|
||
*/
|
||
const LB = '(?<![\\wæøåÆØÅ])';
|
||
const RB = '(?![\\wæøåÆØÅ])';
|
||
|
||
const ABSOLUTE_RE = new RegExp(
|
||
LB +
|
||
'(?:never|always|avoid|don\'t|do not|must not|ensure|remember to|make sure|' +
|
||
'aldri|alltid|unngå|husk|sørg for|ikke)' +
|
||
RB,
|
||
'i',
|
||
);
|
||
|
||
/**
|
||
* Imperative verbs — the grammatical signature of telling the model how to
|
||
* behave. Matched anywhere in the block, since Norwegian list prose puts them
|
||
* after a colon ("…oppgaver: forstå problemet, vurder alternativer").
|
||
*
|
||
* Word boundaries matter more than the list length: `\bdocument\b` must not
|
||
* match "documentation", or the declarative language-preference fact — a floor
|
||
* block with no local literal to veto it — would become a deletion candidate.
|
||
*/
|
||
const IMPERATIVE_RE = new RegExp(
|
||
LB +
|
||
'(?:' +
|
||
// English
|
||
'think|write|test|commit|use|read|check|ask|verify|stop|start|summarize|' +
|
||
'wait|match|change|fix|present|identify|keep|prefer|declare|refactor|' +
|
||
'document|explain|split|review|' +
|
||
// Norwegian
|
||
'tenk|skriv|test|commit|bruk|les|sjekk|spør|verifiser|dokumenter|stopp|' +
|
||
'start|oppsummer|vent|gjør|match|endre|fiks|presenter|identifiser|forstå|' +
|
||
'vurder|hold|siter|jobb|gjett|push|del|sett|forklar|utfør|følg' +
|
||
')' +
|
||
RB,
|
||
'i',
|
||
);
|
||
|
||
/**
|
||
* Minimum words for a block whose ONLY signal is an absolute marker. A bare
|
||
* "Haiku: aldri." is a declarative policy fact wearing the word "aldri", not an
|
||
* instruction about how to behave — the same reason "Tone: direct and technical"
|
||
* never fires. Blocks carrying a real imperative verb are exempt from the floor,
|
||
* so "Test inkrementelt" still surfaces at two words.
|
||
*/
|
||
const ABSOLUTE_ONLY_MIN_WORDS = 6;
|
||
|
||
const HEADING_RE = /^\s*#{1,6}\s/;
|
||
const TABLE_RE = /^\s*\|/;
|
||
const FENCE_RE = /^\s*(?:```|~~~)/;
|
||
const LIST_ITEM_RE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)/;
|
||
const ORDERED_ITEM_RE = /^\s*\d+[.)]\s+/;
|
||
const CONTINUATION_RE = /^\s+\S/;
|
||
/** A paragraph that introduces the list beneath it ("…tre lag med hver sin ene jobb:"). */
|
||
const STEM_RE = /:\s*$/;
|
||
|
||
/**
|
||
* Split markdown into leaf blocks.
|
||
*
|
||
* @param {string} text
|
||
* @returns {Array<{startLine:number, endLine:number, text:string, type:string}>}
|
||
* `type` is one of paragraph | list-item | heading | table | code.
|
||
*/
|
||
export function splitLeafBlocks(text) {
|
||
const lines = String(text == null ? '' : text).split('\n');
|
||
const blocks = [];
|
||
let current = null;
|
||
let inFence = false;
|
||
|
||
const flush = () => {
|
||
if (current) blocks.push(current);
|
||
current = null;
|
||
};
|
||
const indentOf = (line) => (line.match(/^\s*/) || [''])[0].length;
|
||
const open = (type, i, line) => {
|
||
current = { startLine: i + 1, endLine: i + 1, text: line, type, indent: indentOf(line) };
|
||
};
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
|
||
if (FENCE_RE.test(line)) {
|
||
flush();
|
||
inFence = !inFence;
|
||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'code' });
|
||
continue;
|
||
}
|
||
if (inFence) {
|
||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'code' });
|
||
continue;
|
||
}
|
||
if (line.trim() === '') {
|
||
flush();
|
||
continue;
|
||
}
|
||
if (HEADING_RE.test(line)) {
|
||
flush();
|
||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'heading' });
|
||
continue;
|
||
}
|
||
if (TABLE_RE.test(line)) {
|
||
flush();
|
||
blocks.push({ startLine: i + 1, endLine: i + 1, text: line, type: 'table' });
|
||
continue;
|
||
}
|
||
if (LIST_ITEM_RE.test(line)) {
|
||
// Structural exception 1: a paragraph ending in ':' is this list's stem —
|
||
// it introduces the items rather than standing alone, so it merges with
|
||
// them. Deleting a stem without its list is meaningless, and a stem often
|
||
// carries no literal of its own to protect it.
|
||
//
|
||
// A list item can be a stem too ("5. …alltid eksplisitt:" over its
|
||
// sub-bullets) — but only when the following item is nested deeper, or
|
||
// sibling bullets would glue together.
|
||
const isStem =
|
||
current &&
|
||
STEM_RE.test(current.text) &&
|
||
(current.type === 'paragraph' || indentOf(line) > current.indent);
|
||
if (isStem) {
|
||
current.type = 'list-item';
|
||
if (current.ordered === undefined) current.ordered = ORDERED_ITEM_RE.test(line);
|
||
current.endLine = i + 1;
|
||
current.text += '\n' + line;
|
||
current.stemMerged = true;
|
||
current.stemIndent = indentOf(line);
|
||
continue;
|
||
}
|
||
if (current && current.stemMerged && current.endLine === i && indentOf(line) >= current.stemIndent) {
|
||
// Subsequent items of the same stemmed list join it too.
|
||
current.endLine = i + 1;
|
||
current.text += '\n' + line;
|
||
continue;
|
||
}
|
||
// Otherwise a new list item always ends the previous block, even mid-list.
|
||
flush();
|
||
open('list-item', i, line);
|
||
current.ordered = ORDERED_ITEM_RE.test(line);
|
||
continue;
|
||
}
|
||
if (current && CONTINUATION_RE.test(line)) {
|
||
// Indented wrap — belongs to the block it continues.
|
||
current.endLine = i + 1;
|
||
current.text += '\n' + line;
|
||
continue;
|
||
}
|
||
if (current && current.type === 'paragraph') {
|
||
current.endLine = i + 1;
|
||
current.text += '\n' + line;
|
||
continue;
|
||
}
|
||
flush();
|
||
open('paragraph', i, line);
|
||
}
|
||
flush();
|
||
|
||
return blocks.sort((a, b) => a.startLine - b.startLine);
|
||
}
|
||
|
||
/** Blocks that can carry a deletable instruction at all. */
|
||
const isProse = (block) => block.type === 'paragraph' || block.type === 'list-item';
|
||
|
||
const wordCount = (s) => s.trim().split(/\s+/).filter(Boolean).length;
|
||
|
||
/**
|
||
* Does the block instruct behaviour at all? A declarative fact does not, however
|
||
* absolute its wording: "Haiku: aldri." and "Tone: direct and technical" both
|
||
* state a decision rather than correcting how the model works.
|
||
*/
|
||
function correctsBehaviour(text) {
|
||
if (IMPERATIVE_RE.test(text)) return true;
|
||
return ABSOLUTE_RE.test(text) && wordCount(text) >= ABSOLUTE_ONLY_MIN_WORDS;
|
||
}
|
||
|
||
/**
|
||
* Structural exception 2: an ordered list is a CONTRACT. Numbered steps are a
|
||
* sequence whose items reference each other, so a floor marker on any step
|
||
* floors the whole run — deleting step 2 of a five-step session protocol is not
|
||
* the same kind of act as deleting one bullet from a list of platitudes.
|
||
*
|
||
* Unordered lists deliberately do NOT inherit. B-32a and B-32b are opposite
|
||
* calls inside one bullet list, and "the container decides" is precisely the
|
||
* reasoning the fasit exists to refute.
|
||
*
|
||
* @returns {Set<number>} startLine of every block floored by inheritance
|
||
*/
|
||
function orderedContractFloor(blocks, floored) {
|
||
const inherited = new Set();
|
||
let run = [];
|
||
const closeRun = () => {
|
||
if (run.length > 1 && run.some((b) => floored.has(b.startLine))) {
|
||
for (const b of run) inherited.add(b.startLine);
|
||
}
|
||
run = [];
|
||
};
|
||
for (const block of blocks) {
|
||
const contiguous = run.length > 0 && block.startLine === run[run.length - 1].endLine + 1;
|
||
if (block.ordered && (run.length === 0 || contiguous)) {
|
||
run.push(block);
|
||
} else {
|
||
closeRun();
|
||
if (block.ordered) run.push(block);
|
||
}
|
||
}
|
||
closeRun();
|
||
return inherited;
|
||
}
|
||
|
||
/**
|
||
* Compensatory-phrasing candidates that survived floor-exclusion.
|
||
*
|
||
* @param {string} text
|
||
* @returns {Array<{lensCheck:string, registerId:string, mechanism:string,
|
||
* line:number, startLine:number, endLine:number, lineCount:number, text:string}>}
|
||
*/
|
||
export function subtractionCandidates(text) {
|
||
const detector = SUBTRACT_DETECTORS[0];
|
||
const out = [];
|
||
const blocks = splitLeafBlocks(text).filter(isProse);
|
||
|
||
// 1. The blocking floor veto, evaluated over the WHOLE leaf block — so a
|
||
// literal on a wrapped continuation line still protects its opening line.
|
||
const floored = new Set();
|
||
for (const block of blocks) {
|
||
if (floorMarker(block.text)) floored.add(block.startLine);
|
||
}
|
||
// 2. …then propagated across ordered-list contracts.
|
||
const inherited = orderedContractFloor(blocks, floored);
|
||
|
||
for (const block of blocks) {
|
||
// 3. Does it correct behaviour at all? A declarative local fact does not.
|
||
if (!correctsBehaviour(block.text)) continue;
|
||
if (floored.has(block.startLine) || inherited.has(block.startLine)) continue;
|
||
|
||
out.push({
|
||
lensCheck: detector.lensCheck,
|
||
registerId: detector.registerId,
|
||
mechanism: detector.mechanism,
|
||
line: block.startLine,
|
||
startLine: block.startLine,
|
||
endLine: block.endLine,
|
||
lineCount: block.endLine - block.startLine + 1,
|
||
text: block.text.trim(),
|
||
});
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Diagnostics for the floor gate: every prose block that fired the detector but
|
||
* was vetoed, with the marker that saved it. Not user-facing — this is how a
|
||
* later narrowing of the veto can be checked against the fasit.
|
||
*
|
||
* @param {string} text
|
||
* @returns {Array<{startLine:number, endLine:number, marker:string, why:string}>}
|
||
*/
|
||
export function floorExcluded(text) {
|
||
const blocks = splitLeafBlocks(text).filter(isProse);
|
||
const floored = new Set();
|
||
for (const block of blocks) {
|
||
if (floorMarker(block.text)) floored.add(block.startLine);
|
||
}
|
||
const inherited = orderedContractFloor(blocks, floored);
|
||
|
||
const out = [];
|
||
for (const block of blocks) {
|
||
if (!correctsBehaviour(block.text)) continue;
|
||
const marker = floorMarker(block.text);
|
||
if (marker) {
|
||
out.push({ startLine: block.startLine, endLine: block.endLine, marker: marker.name, why: marker.why });
|
||
} else if (inherited.has(block.startLine)) {
|
||
out.push({
|
||
startLine: block.startLine,
|
||
endLine: block.endLine,
|
||
marker: 'ordered-contract',
|
||
why: 'is a step of an ordered list whose sibling carries a local fact',
|
||
});
|
||
}
|
||
}
|
||
return out;
|
||
}
|