ms-ai-architect/scripts/kb-eval/lib/fix-op.mjs
Kjell Tore Guttormsen e4925c6b28 feat(ms-ai-architect): R11 §4b implementert — status-synonymtabellen målt, 8 forslag hånd-dømt [skip-docs]
Implementerer den ratifiserte §4b-tabellen i lib/fix-op.mjs (19 nye tester,
suite 996/996). Alle tre skrankene har egne tester: tabellen er LUKKET, fil-
tokenet må være en KOMPLETT livssyklus-etikett, og verdien som skrives er den
korpus-side ekvivalenten med filas egen markup bevart.

To implementasjonsvalg den ratifiserte teksten lot stå åpne, begge løst mot
fail-closed: status-lokatoren er LINJE-scopet (livssyklus-vokabular gjentas
nedover hver kolonne i en statustabell, så et blokkvindu er tvetydig ved
konstruksjon), og et sitat som hevder to rader aborterer.

MÅLT: 15 pilot / 54 korpus-brede flagg -> 5 og 8 provbare. Alle 8 hånd-dømt
mot kilden (r11-pilot-results.md appendiks B): 5 korrekte, 1 ubevist, 2 GALE.

De tre defektene er én familie: §4b binder tabellen, etikettens fullstendighet
og verdien som skrives — og INGENTING om hvorvidt kilde-frasen refererer til
radens eget subjekt. Samme proveniens-uten-referent-defekt som falsifiserte §4.
Klassen er derfor REVIEW-grade, ikke apply-grade: `status` står bevisst utenfor
o1_recommended, ingen driver applikerer den.

To kandidatvilkår er kostnadsberegnet over de åtte (begge dreper gale forslag
og null korrekte) men IKKE implementert — å utvide en tabell operatøren
ratifiserte som lukket er en operatørbeslutning, slik vilkår 5 var i §4a.

Rettet samtidig 2 NUL-bytes i testfila (pre-eksisterende, fra en tidligere
økt) som gjorde at git behandlet hele fila som binær og blokkerte diff-
gjennomgang før commit.
2026-08-03 17:13:05 +02:00

481 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// fix-op.mjs — R11 fix-operation classifier over judge-pass flags.
//
// Contract: docs/r11-tiered-fix-design.md §3 (the O1/O2/O3 partition is by
// OPERATION, not by rule code) and §4 (the O1 invariant).
//
// This module IS the O1 driver with writes disabled. It attempts the value swap
// and checks §4's three conditions; an item it cannot prove is O3 with a typed
// abort code. That is deliberate: a proxy heuristic would have measured
// something other than the mechanism that will later touch a public corpus.
//
// Two properties the callers depend on:
// - PURE. No fs, no network, no mutation of the input flag. The caller reads
// the file and passes its text.
// - FAILS CLOSED. Every path returns O1-with-proof or O3-with-a-known-code.
// A misrouted O3 costs one human review; a misrouted O1 ships a wrong edit
// to a publicly distributed file.
//
// What this module deliberately does NOT do: decide O2. Subtraction candidacy
// turns on which sub-assertion the judge's prose `reason` names as failing, and
// no regex reads prose. O2 requires operator ratification (§5) before it exists
// as a class at all; until then every non-O1 item is O3 by design.
/**
* Abort codes. The taxonomy is part of the contract, not diagnostics: the pilot's
* measurement #3 (§10) is the DISTRIBUTION of these, because "abort rate 85 %"
* is not actionable while "60 % LOCATOR_MISS" is an engineering gap and "60 %
* NOT_VERBATIM" is intrinsic to the corpus.
*/
export const ABORT_CODES = {
MULTI_PART_CLAIM: 'MULTI_PART_CLAIM', // enumeration / several assertions in one claim (§3, the R8 class)
NO_VALUE_TOKEN: 'NO_VALUE_TOKEN', // nothing swappable — the claim asserts prose
STATUS_SYNONYM: 'STATUS_SYNONYM', // GA/Preview class: file vocabulary != source vocabulary (operator question)
MULTI_VALUE_TOKEN: 'MULTI_VALUE_TOKEN', // several distinct values — which one is wrong is a judgement
LOCATOR_MISS: 'LOCATOR_MISS', // value not found in the block the flag points at
LOCATOR_AMBIGUOUS: 'LOCATOR_AMBIGUOUS', // value occurs more than once in that block
NOT_VERBATIM: 'NOT_VERBATIM', // no same-type replacement occurs verbatim in evidence_quote (§4.1)
MULTI_REPLACEMENT: 'MULTI_REPLACEMENT', // quote offers several candidate values
CONTEXT_MISMATCH: 'CONTEXT_MISMATCH', // §4 held but the tokens do not denote the same quantity (see below)
INVARIANT_FAIL: 'INVARIANT_FAIL', // swap constructed but §4 did not hold — must never happen silently
};
/** Verdict code for a proven swap. Kept out of ABORT_CODES so `op === 'O1' <=> code === 'PROVEN'`. */
export const PROVEN = 'PROVEN';
// Value types, most specific first. Matching is non-overlapping and priority
// ordered, so `2.3.0` is one version rather than two numbers, and `20 %` is a
// percent rather than the number 20. Types never cross in a swap: a percent may
// only be replaced by a percent.
const TOKEN_PATTERNS = [
['iso_date', /\d{4}-\d{2}-\d{2}/g],
['percent', /\d+(?:[.,]\d+)?\s?%/g],
['version', /v?\d+\.\d+\.\d+/g],
['number', /\d+(?:[.,]\d+)?/g],
];
// Lifecycle vocabulary. Present in a claim without any numeric token, this is the
// GA/Preview class: the corpus writes `**Preview**` / `**GA**` while the cited
// source writes "generally available". A swap satisfies §4 literally while
// pasting English prose into a Norwegian table, which is why the class was put to
// the operator as a design question — answered by the §4b table below.
const STATUS_RE =
/\b(?:GA|generally available|allment tilgjengelig|public preview|private preview|preview|deprecated|utfaset|retired|avviklet)\b/i;
// ------------------------------------------------------- §4b synonym table
//
// RATIFIED 2026-08-03 (docs/r11-tiered-fix-design.md §4b). This is the one place
// where the value written into the file does NOT appear verbatim in the quote —
// §4 condition 1 is structurally unsatisfiable for it, because the corpus writes
// a label and the source writes a phrase. The table is what the operator ratified
// in its place, and it is CLOSED: a pair not listed here aborts, and nothing
// extends it at run time.
//
// `corpus[0]` is the canonical value written back. The order is the table's own:
// the row's least specific label wins, so a source that says only "preview" can
// never produce the more specific "Public Preview" — that would assert something
// the source does not.
export const STATUS_TABLE = [
{ row: 'GA', corpus: ['GA'], source: ['generally available', 'general availability'] },
{ row: 'PREVIEW', corpus: ['Preview', 'Public Preview'], source: ['public preview', 'preview'] },
{ row: 'PRIVATE_PREVIEW', corpus: ['Private Preview'], source: ['private preview'] },
{ row: 'DEPRECATED', corpus: ['Deprecated', 'Utfaset'], source: ['deprecated', 'retired'] },
];
// Longest first, so "private preview" is consumed as its own row before the
// "preview" row can claim the tail of it.
const SOURCE_PHRASES = STATUS_TABLE.flatMap((r) => r.source.map((p) => ({ row: r.row, phrase: p }))).sort(
(a, b) => b.phrase.length - a.phrase.length,
);
const CORPUS_LABELS = new Map(
STATUS_TABLE.flatMap((r) => r.corpus.map((label) => [label.toLowerCase(), r])),
);
// A hyphen counts as a word character HERE, unlike WORD elsewhere in this module:
// `2025-11-15-preview` is an api-version identifier, not a statement that the
// feature is in preview. Treating `-` as a boundary would harvest lifecycle rows
// out of URLs and code samples.
const isPhraseChar = (ch) => ch !== undefined && /[A-Za-z0-9-]/.test(ch);
/**
* Which lifecycle rows does the cited quote assert, per the closed §4b table?
* Matching is case-insensitive, non-overlapping, longest phrase first.
* @returns {string[]} distinct row keys, in table order
*/
export function sourceStatusRows(quote) {
const text = (quote || '').toLowerCase();
const taken = [];
const rows = new Set();
for (const { row, phrase } of SOURCE_PHRASES) {
let from = 0;
for (;;) {
const at = text.indexOf(phrase, from);
if (at === -1) break;
const end = at + phrase.length;
from = end;
if (taken.some(([s, e]) => at < e && end > s)) continue;
if (isPhraseChar(text[at - 1]) || isPhraseChar(text[end])) continue;
taken.push([at, end]);
rows.add(row);
}
}
return STATUS_TABLE.map((r) => r.row).filter((r) => rows.has(r));
}
// Markup wrappers the corpus actually uses around a lifecycle label. The label is
// replaced INSIDE the wrapper, which is how §4b constraint 3 ("the file's own
// markup preserved") is satisfied without any markup handling at write time.
const WRAPPERS = ['**', '__', '*', '_', '`'];
/**
* Is this segment exactly a lifecycle label, once whitespace and one or more
* markup wrappers are peeled off? Returns the label's span in the ORIGINAL line.
*/
function labelInSegment(segment, segStart) {
let text = segment;
let off = 0;
const trim = () => {
const lead = text.length - text.trimStart().length;
off += lead;
text = text.trim();
};
trim();
for (;;) {
const w = WRAPPERS.find((x) => text.length > 2 * x.length && text.startsWith(x) && text.endsWith(x));
if (!w) break;
off += w.length;
text = text.slice(w.length, text.length - w.length);
trim();
}
const rec = CORPUS_LABELS.get(text.toLowerCase());
return rec ? { row: rec.row, label: text, index: segStart + off, length: text.length } : null;
}
// An emphasised run anywhere on the line. `|` is excluded from the inner text so a
// run can never span two table cells.
const EMPHASIS_RE = /(\*\*|__|\*|_|`)([^*_`|]+?)\1/g;
/**
* The single complete lifecycle label on `line`, or null when there is none or
* more than one (§4b constraint 2: a whole table cell or an emphasised token,
* never a substring of a longer sentence).
*
* Deliberately LINE-scoped rather than block-scoped, unlike the numeric locator.
* Lifecycle vocabulary repeats down every column of a status table, so a block
* window is ambiguous by construction — and all 15 pilot flags in this class
* point at the row that carries the claim, not at the table header.
*
* @returns {{row: string, label: string, index: number, length: number}|null}
*/
export function fileStatusLabel(line) {
const text = line || '';
const hits = [];
if (text.includes('|')) {
let at = 0;
for (const cell of text.split('|')) {
const hit = labelInSegment(cell, at);
if (hit) hits.push(hit);
at += cell.length + 1;
}
}
EMPHASIS_RE.lastIndex = 0;
let m;
while ((m = EMPHASIS_RE.exec(text)) !== null) {
const hit = labelInSegment(m[0], m.index);
if (hit && !hits.some((h) => h.index === hit.index)) hits.push(hit);
}
return hits.length === 1 ? hits[0] : null;
}
/**
* §4b: classify a status-vocabulary flag against the ratified table.
*
* Every abort keeps the STATUS_SYNONYM code and names its cause in `detail.reason`
* — the top-level abort taxonomy is the pilot's measurement #3 and stays
* comparable across the implementation, with the reasons reported as a
* sub-distribution.
*/
function classifyStatusSynonym(flag, lines) {
const detail = (reason, extra = {}) => abort(ABORT_CODES.STATUS_SYNONYM, { detail: { reason, ...extra } });
if (flag.line < 1 || flag.line > lines.length) return detail('LINE_OUT_OF_RANGE', { line: flag.line });
const rows = sourceStatusRows(flag.evidence_quote || '');
if (rows.length === 0) return detail('NO_SOURCE_STATUS');
if (rows.length > 1) return detail('SOURCE_STATUS_AMBIGUOUS', { rows });
const before = lines[flag.line - 1];
const hit = fileStatusLabel(before);
if (!hit) return detail('NO_COMPLETE_FILE_LABEL');
if (hit.row === rows[0]) return detail('FILE_ALREADY_MATCHES', { row: hit.row });
const target = STATUS_TABLE.find((r) => r.row === rows[0]);
const replacement = target.corpus[0];
const after = before.slice(0, hit.index) + replacement + before.slice(hit.index + hit.length);
// §4 conditions 2 and 3 still bind. Condition 1 is replaced by the table: what
// must appear verbatim in the quote is the SOURCE phrasing, not the written value.
const rest =
before.slice(0, hit.index) === after.slice(0, hit.index) &&
before.slice(hit.index + hit.length) === after.slice(hit.index + replacement.length);
const rebuilt = lines.slice();
rebuilt[flag.line - 1] = after;
const changedLines = rebuilt.reduce((n, l, i) => n + (l === lines[i] ? 0 : 1), 0);
if (!rest || changedLines !== 1) {
return abort(ABORT_CODES.INVARIANT_FAIL, { detail: { restIdentical: rest, changedLines } });
}
return {
op: 'O1',
code: PROVEN,
proposal: {
file: flag.file,
line: flag.line,
token: hit.label,
replacement,
type: 'status',
status_row_from: hit.row,
status_row_to: rows[0],
before,
after,
evidence_url: flag.evidence_url,
evidence_quote: flag.evidence_quote,
},
};
}
/**
* Extract swappable value tokens, non-overlapping and priority ordered.
* Status words are NOT value tokens — see STATUS_RE.
* @returns {Array<{type: string, value: string, index: number}>} in order of appearance
*/
export function extractValueTokens(text) {
if (!text) return [];
const taken = []; // [start, end) ranges already consumed by a higher-priority type
const out = [];
for (const [type, re] of TOKEN_PATTERNS) {
re.lastIndex = 0;
let m;
while ((m = re.exec(text)) !== null) {
const start = m.index;
const end = start + m[0].length;
if (taken.some(([s, e]) => start < e && end > s)) continue;
taken.push([start, end]);
out.push({ type, value: m[0], index: start });
}
}
return out.sort((a, b) => a.index - b.index);
}
/** True if the text carries lifecycle-status vocabulary. */
export function hasStatusWord(text) {
return STATUS_RE.test(text || '');
}
/**
* The contiguous non-blank block containing `line` (1-indexed).
*
* This is the search window, and it is structural rather than a magic ±N: claims
* are LLM-extracted restatements whose `line` often points at a table HEADER
* while the asserted value sits in a row below. A block is exactly that table,
* list, or paragraph. A blank line degenerates to itself.
*/
export function blockWindow(lines, line) {
if (line < 1 || line > lines.length) return { start: line, end: line };
if (lines[line - 1].trim() === '') return { start: line, end: line };
let start = line;
let end = line;
while (start > 1 && lines[start - 2].trim() !== '') start -= 1;
while (end < lines.length && lines[end].trim() !== '') end += 1;
return { start, end };
}
// ---------------------------------------------------------- context condition
//
// MEASURED, NOT ASSUMED: §4 alone admits wrong edits. On the pilot it proved six
// swaps of which four were false — "30-dagers" -> "24" from a quote saying 24
// HOURS (unit crossing), an indexing rate replaced by a query throttle (metric
// crossing), and two identifiers mutilated by digits harvested out of "E7" and a
// table cell ("Microsoft Agent 365" -> "Agent 7", "text-embedding-ada-002" ->
// "ada-2"). §4 constrains where the new value CAME FROM and what the edit LOOKS
// LIKE; it constrains nothing about whether the two tokens denote the same
// quantity.
//
// The condition below adds that, and it is deliberately lexical rather than
// semantic: the token must sit under the same label, or the same trailing unit,
// on both sides. No translation table — "dokumenter" is not taught to equal
// "documents", because a synonym/translation table introduces a new fact source
// and is an operator decision (§5-class), not an engineering one. The consequence
// is measured and reported: a swap is provable essentially only where the context
// is language-neutral (a URL, a code sample, a parameter key).
const WORD = /[A-Za-z0-9_.\-æøåÆØÅ]/;
/** Normalise a context run for comparison: lowercase, punctuation stripped. */
const normContext = (s) => s.toLowerCase().replace(/[^a-z0-9æøå]/g, '');
/** The word run immediately left of [index], skipping any separator run first. */
function leftContext(text, index) {
let i = index - 1;
// A separator run may be skipped; a word character adjacent to the token may
// NOT be — that adjacency is what makes "7" part of the identifier "E7".
if (i >= 0 && !WORD.test(text[i])) {
while (i >= 0 && !WORD.test(text[i])) i -= 1;
}
let end = i + 1;
while (i >= 0 && WORD.test(text[i])) i -= 1;
return normContext(text.slice(i + 1, end));
}
/** The word run immediately right of [index], skipping any separator run first. */
function rightContext(text, index) {
let i = index;
if (i < text.length && !WORD.test(text[i])) {
while (i < text.length && !WORD.test(text[i])) i += 1;
}
const start = i;
while (i < text.length && WORD.test(text[i])) i += 1;
return normContext(text.slice(start, i));
}
/**
* Do the two occurrences sit in corresponding context? True when a non-empty
* label matches on the left, or a non-empty unit matches on the right.
*/
export function contextCorresponds(fileLine, fileIndex, fileLen, quote, quoteIndex, quoteLen) {
const lf = leftContext(fileLine, fileIndex);
const lq = leftContext(quote, quoteIndex);
if (lf && lf === lq) return true;
const rf = rightContext(fileLine, fileIndex + fileLen);
const rq = rightContext(quote, quoteIndex + quoteLen);
return Boolean(rf) && rf === rq;
}
/** Distinct by type+value, preserving order. */
function distinct(tokens) {
const seen = new Set();
return tokens.filter((t) => {
const k = `${t.type}${t.value}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
function abort(code, detail = {}) {
return { op: 'O3', code, ...detail };
}
/**
* Classify one flag record into a fix operation.
*
* @param {object} flag flag record per docs/r11-flag-format-2026-07.md
* @param {string} fileText current content of flag.file
* @param {{contextCheck?: boolean}} [opts] contextCheck:false reproduces §4 exactly as
* written — used to MEASURE what the context condition adds, never to ship edits.
* @returns {{op: 'O1'|'O3', code: string, proposal?: object, detail?: object}}
*/
export function classifyFlag(flag, fileText, opts = {}) {
const contextCheck = opts.contextCheck !== false;
const claim = flag.claim || '';
const lines = (fileText || '').split('\n');
// §3: an enumeration is not a value swap even when it contains a number. The
// structure decides, not the rule code — R8 is a signal, and the run records
// it, but it is not the partition.
if (claim.includes(' | ') || (claim.match(/,/g) || []).length >= 3) {
return abort(ABORT_CODES.MULTI_PART_CLAIM);
}
const tokens = distinct(extractValueTokens(claim));
if (tokens.length === 0) {
if (!hasStatusWord(claim)) return abort(ABORT_CODES.NO_VALUE_TOKEN);
return classifyStatusSynonym(flag, lines); // §4b — the ratified table, not a swap of prose
}
if (tokens.length > 1) {
return abort(ABORT_CODES.MULTI_VALUE_TOKEN, { detail: { candidates: tokens.map((t) => t.value) } });
}
const wrong = tokens[0];
// Locate the value in the block the flag points at. Unique hit or nothing:
// a locator that guesses is how a wrong edit reaches a public file.
if (flag.line < 1 || flag.line > lines.length) {
return abort(ABORT_CODES.LOCATOR_MISS, { detail: { reason: 'line out of range', line: flag.line } });
}
const win = blockWindow(lines, flag.line);
const hits = [];
for (let n = win.start; n <= win.end; n += 1) {
const text = lines[n - 1];
let from = 0;
for (;;) {
const at = text.indexOf(wrong.value, from);
if (at === -1) break;
hits.push({ line: n, index: at });
from = at + wrong.value.length;
}
}
if (hits.length === 0) return abort(ABORT_CODES.LOCATOR_MISS, { detail: { token: wrong.value, window: win } });
if (hits.length > 1) {
return abort(ABORT_CODES.LOCATOR_AMBIGUOUS, { detail: { token: wrong.value, hits: hits.length, window: win } });
}
// §4.1: the replacement must occur verbatim in the quote the judge actually
// cited. Same type only, and a quote that merely restates the claim's own value
// offers no replacement at all.
const replacements = distinct(extractValueTokens(flag.evidence_quote || '')).filter(
(t) => t.type === wrong.type && t.value !== wrong.value,
);
if (replacements.length === 0) return abort(ABORT_CODES.NOT_VERBATIM, { detail: { token: wrong.value, type: wrong.type } });
if (replacements.length > 1) {
return abort(ABORT_CODES.MULTI_REPLACEMENT, { detail: { candidates: replacements.map((t) => t.value) } });
}
const right = replacements[0];
const hit = hits[0];
const before = lines[hit.line - 1];
// The condition §4 is missing: same label or same unit on both sides.
if (
contextCheck &&
!contextCorresponds(before, hit.index, wrong.value.length, flag.evidence_quote || '', right.index, right.value.length)
) {
return abort(ABORT_CODES.CONTEXT_MISMATCH, {
detail: {
token: wrong.value,
replacement: right.value,
would_have_been: before.slice(0, hit.index) + right.value + before.slice(hit.index + wrong.value.length),
},
});
}
const after = before.slice(0, hit.index) + right.value + before.slice(hit.index + wrong.value.length);
// Re-check §4 against the constructed edit rather than trusting construction.
const quoteHasValue = (flag.evidence_quote || '').includes(right.value);
const restIdentical =
before.slice(0, hit.index) === after.slice(0, hit.index) &&
before.slice(hit.index + wrong.value.length) === after.slice(hit.index + right.value.length);
const rebuilt = lines.slice();
rebuilt[hit.line - 1] = after;
const changedLines = rebuilt.reduce((n, l, i) => n + (l === lines[i] ? 0 : 1), 0);
if (!quoteHasValue || !restIdentical || changedLines !== 1) {
return abort(ABORT_CODES.INVARIANT_FAIL, { detail: { quoteHasValue, restIdentical, changedLines } });
}
return {
op: 'O1',
code: PROVEN,
proposal: {
file: flag.file,
line: hit.line,
token: wrong.value,
replacement: right.value,
type: wrong.type,
before,
after,
evidence_url: flag.evidence_url,
evidence_quote: flag.evidence_quote,
},
};
}