Anthropic documents that Claude Opus 5 verifies its own work, and that telling it to double-check or to delegate verification to a subagent causes over-verification -- token cost with no quality gain. The general subtraction detector (BP-SUB-001) already surfaces those blocks for every user, with no model-awareness at all. `optimize --subtract --for-model <name>` adds the missing half. It ANNOTATES a subset of the candidates --subtract already produced; it is not a second detector and can never widen the candidate set. A second SUBTRACT_DETECTORS entry would have collided with BP-SUB-001 on de-dup, and a prose-only signal in the agent prompt would have been untestable. There is no auto-detection, by measurement rather than omission: a CLAUDE.md has no frontmatter and no resolvable target model, and this operator's own `route` skill deliberately runs a different model per session -- the same file is read by whichever model comes next. So the model is named, and the citation is reported as conditional everywhere a human sees it (agent report copy, and the Step 7a listing that is the last surface before an approval file). Precision comes from the TARGET, not the verb list. Measured across the 409-file corpus: 392 BP-SUB-001 candidates, 31 (7.9%) carry a verify verb, and 0 also carry a reflexive or delegated target. Two independent raw-text greps found 0 as well, so the zero is the corpus rather than an over-narrow regex. Those 31 verb-only blocks -- "sjekk relevante config-filer", "Type-sjekk: pyright", "To verify plugin functionality" -- are exactly the false positives a verb-only version would have produced, which is BP-JUDG-001's 7/7 failure arriving one lens over. The numbers live in the register entry's note and are pinned by a test, because a session that cannot see the measurement reads the zero as a broken detector and loosens it. `recognized` is reported separately from `matchedCount`: a typo'd model name and a genuinely clean config both yield zero, and without the distinction the CLI would report a silent no-op as good news. Dogfooded on the real machine -- `opus-5` gives recognized:true/matchedCount:0, `oppus5` gives recognized:false. source.published is absent because the guide carries no visible publish date; its absence is asserted so a later session does not invent one to match the other entries' shape. Both quoted sentences were verified verbatim 2026-08-12. The payload stays additive -- forModel and per-candidate modelScope appear only under the flag, so a plain --subtract run is byte-identical to before (asserted on the serialized bytes, since a key set to undefined passes a shallow check). Suite 1724 -> 1752 (+28). The one remaining failure is the pre-existing drift-cli --output-file crash, untouched by this work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRuXt6tZyowi8QYNKLSHQm
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.
|
||
*/
|
||
export const LB = '(?<![\\wæøåÆØÅ])';
|
||
export 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;
|
||
}
|