/** * 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 = '(?} * `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} 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; }