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.
481 lines
20 KiB
JavaScript
481 lines
20 KiB
JavaScript
// 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} |