ms-ai-architect/scripts/kb-eval/lib/fix-op.mjs
Kjell Tore Guttormsen b0b5890703 feat(ms-ai-architect): R11 pilot kjørt — §4-invarianten falsifisert, 9 provbare swaps i hele korpuset [skip-docs]
§10-målingen er gjennomført mot live ledger (243 records). Ingen KB-fil er
redigert og ingen ledger-record er skrevet — §8s single-writer-state er urørt.

Instrumentet ER O1-driveren med writes av (scripts/kb-eval/lib/fix-op.mjs, 30
tester). Måling #1 og #3 kommer dermed ut av mekanismen som senere skal ta på
korpuset, ikke ut av en proxy-heuristikk.

HOVEDFUNN — §4 som skrevet er utilstrekkelig, målt:
Kjørt eksakt som spesifisert slapp den gjennom 6 swaps på piloten, hvorav 4 er
GALE editer (presisjon 2/6):
- 30-dagers → 24-dagers   (enhets-kryssing: kilden sier 24 HOURS)
- 3000 req/sek → 50       (metrikk-kryssing: query-throttle vs indexing-rate)
- Microsoft Agent 365 → 7 (identifikator lemlestet, «7» høstet fra «E7»)
- text-embedding-ada-002 → ada-2 (identifikator lemlestet)
§4 binder proveniensen til verdien og formen på editen — ingenting om at de to
tokenene betegner SAMME STØRRELSE. Påstanden om at invarianten er «deliberately
stronger than human review at scale» holder ikke.

TILLEGG: contextCorresponds() krever samme label eller samme enhet på begge
sider. Bevisst leksikalsk, UTEN oversettelsestabell — «dokumenter» læres ikke å
være «documents», fordi en synonymtabell innfører en ny faktakilde og er en
operatørbeslutning. Konsekvensen er målt: swap er provbar praktisk talt bare der
konteksten er språknøytral (URL, kodeeksempel, parameternøkkel).

TALLENE:
- Pilot (≥7): 24 filer / 202 flagg → O1 = 2 (1,0 %), O3 = 200 (99,0 %)
- Hele korpuset: 218 filer / 776 flagg → 15 sluppet gjennom, 9 korrekte
- Kun iso_date (api-version-bump) overlever hånd-verifisering: 9/9.
  number/version lemlester identifikatorer (AI-900 → AI-901, gpt-4o → gpt-5.1o
  ×2, Java-agent 3.7.5 → 3.4.0 = nedgradering) og skal IKKE påføres.
- Kun 7 av 200 aborter (3,5 %) er en fiksbar engineering-gap. Mer locator-
  arbeid kan ikke flytte O1-tallet vesentlig.

Måling #2 (R8 → O2) er IKKE besvart og kan ikke besvares maskinelt: R8 gir null
O1, og hvilke av de 46 enumerasjonene som subtraherer rent avhenger av dommerens
PROSA-reason. Måling #4 (review-throughput) er ikke målt — det krever
menneskelige review-økter som ikke har skjedd. Begge står som ikke-målt, ikke
som antatt.

VIDERE FUNN: subtraksjon kan etterlate en misvisende rest (§5 sier den «cannot
introduce a new error» — sant om setningen, usant om leserens slutning), og kan
ødelegge sann informasjon (prebuilt-check → finnes, heter prebuilt-check.us).
`disposition` er `outdated` på 202/202 og bærer null informasjon, i strid med
flagg-formatspesifikasjonen. `claim` matcher fillinjen ordrett i 0 av 202.

Full oppskrift og åpne operatørbeslutninger: docs/r11-pilot-results.md
2026-08-03 16:30:31 +02:00

297 lines
13 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 would satisfy §4 literally while
// pasting English prose into a Norwegian table, so the class aborts and is put to
// the operator as a design question (a ratified synonym table, or permanent O3).
const STATUS_RE =
/\b(?:GA|generally available|allment tilgjengelig|public preview|private preview|preview|deprecated|utfaset|retired|avviklet)\b/i;
/**
* 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) {
return abort(hasStatusWord(claim) ? ABORT_CODES.STATUS_SYNONYM : ABORT_CODES.NO_VALUE_TOKEN);
}
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,
},
};
}