Deterministisk halvdel av konsum-skillen (ordre 20260826T123330Z-1842820111): tokenisering, feltvekting, mekanisk supersesjonsregel og budsjett-kutt med [unread]-kvittering mot hele kandidatnevneren. Eval-driver mot bake-off-korpuset. Målt, budsjett 12 000 tegn, nevner 40 gullspørsmål: B1 kun indekslabel 6/40 feller 4/20 B2 konform strategi 12/40 feller 0/20 B3 kontroll uten OKF 12/40 feller 1/20 B2+/B3+ (POST-HOC) 13/40 / 14/40 B2 == B3 er R3, den ene forhåndsregistrerte sammenligningen: med denne rangereren gir OKF-metadataen ingen målbar gevinst på primærmetrikken. Differansen mot bake-offens A3 (40/40, forfatter-informert velger = øvre grense) tilskrives per låst regel R5 RANGEREREN, ikke OKF som teknologi. Instrumentvalidering reproduserer eksakt: A1=31343, A2a=2884, hele bundlen=45580. Suite 1097/1097 (1070 sporet + 27 nye). Negativ kontroll kjørt: tre mutasjoner i bundle-cut.mjs, alle drept (1/1/3 feil), treet restaurert byte-identisk. Konformitet mot §4: 8 av 10 oppfylt, punkt 3 delvis (falsifisering ikke skrevet før kjøring), punkt 5 ikke oppfylt (markeringssett ikke deklarert). [skip-docs]: ren intern måle- og evalueringsmekanikk. Ingen ny kommando, agent eller skill; ingen brukervendt flate endret, så README/CLAUDE.md er uberørt.
208 lines
8.1 KiB
JavaScript
208 lines
8.1 KiB
JavaScript
// bundle-cut.mjs — the DETERMINISTIC half of the OKF bundle consumption skill.
|
|
//
|
|
// Consumption contract (llm-ingestion-okf, docs/plan/okf-bundle-consumption-contract.md
|
|
// @ 01e4170) C4: "the script cuts, the agent judges". This module is the script.
|
|
// It reads, ranks and CUTS a bundle to a bounded context, and it always hands
|
|
// back a receipt for what it held back — a silent cut is C3's failure with
|
|
// extra steps.
|
|
//
|
|
// BOUNDARY against llm-ingestion-okf: nothing here writes, indexes, checks or
|
|
// retrieves in the sense that package owns. The OKF *envelope* is parsed by the
|
|
// package itself (materialize.parse_frontmatter, called as a subprocess by the
|
|
// driver); what this module parses out of the document BODY is the user's own
|
|
// corpus frontmatter, which OKF preserves verbatim but does not define.
|
|
//
|
|
// Pure functions only: no fs, no spawn, no clock. Everything is handed in.
|
|
|
|
// Weights locked in the pre-registration BEFORE any arm was run. Changing one
|
|
// invalidates the measurement, not just the code.
|
|
export const FIELD_WEIGHTS = Object.freeze({ tittel: 3, h2: 2, sjanger: 2, label: 1 });
|
|
|
|
// The bound the cut targets, in characters: 3x the plugin's existing 4000-char
|
|
// org-context budget (DEFAULT_MAX_TOTAL_LEN in kb-update/lib/user-data.mjs),
|
|
// because this cut delivers WHOLE documents rather than one-line field summaries.
|
|
export const AGENT_BUDGET = 12000;
|
|
|
|
// Same window A3-max used in the bake-off, so the arms stay comparable.
|
|
export const HEAD_SCAN_LINES = 16;
|
|
|
|
// Minimum shared prefix for two tokens to count as the same word. 4 is what
|
|
// Norwegian inflection needs (vedtak/vedtaket) without collapsing vei/veileder.
|
|
const MIN_PREFIX = 4;
|
|
|
|
// Standard Norwegian stop words (bokmaal + nynorsk). Only the 4-char-and-longer
|
|
// entries can ever fire, since shorter tokens are dropped on length anyway.
|
|
export const NORSKE_STOPPORD = new Set([
|
|
'alle', 'andre', 'annet', 'bare', 'begge', 'blir', 'blitt', 'bare', 'begge',
|
|
'denne', 'dere', 'deres', 'deim', 'deira', 'deires', 'dette', 'disse', 'ditt',
|
|
'dykk', 'dykkar', 'eller', 'elles', 'etter', 'fordi', 'hadde', 'hans', 'henne',
|
|
'hennar', 'hennes', 'here', 'hoss', 'hossen', 'hvem', 'hvilke', 'hvilken',
|
|
'hvis', 'hvor', 'hvordan', 'hvorfor', 'ikke', 'ikkje', 'ingen', 'ingi', 'inkje',
|
|
'inni', 'kine', 'korleis', 'korso', 'kunne', 'kvar', 'kvarhelst', 'kvifor',
|
|
'mange', 'medan', 'meget', 'mellom', 'mine', 'mitt', 'mykje', 'noen', 'noka',
|
|
'nokon', 'nokor', 'nokre', 'over', 'samme', 'siden', 'sidan', 'sine', 'sitt',
|
|
'skal', 'skulle', 'slik', 'somme', 'somt', 'sånn', 'uten', 'vart', 'varte',
|
|
'vere', 'verte', 'vore', 'vors', 'vort', 'ville', 'være', 'vært',
|
|
]);
|
|
|
|
/**
|
|
* NFC -> lowercase -> split on anything that is not a letter or digit ->
|
|
* drop tokens under MIN_PREFIX chars -> drop stop words.
|
|
*/
|
|
export function tokenize(text) {
|
|
if (!text) return [];
|
|
return String(text)
|
|
.normalize('NFC')
|
|
.toLowerCase()
|
|
.split(/[^\p{L}\p{N}]+/u)
|
|
.filter((t) => t.length >= MIN_PREFIX && !NORSKE_STOPPORD.has(t));
|
|
}
|
|
|
|
/** Two tokens are the same word if one prefixes the other by at least MIN_PREFIX. */
|
|
export function tokensMatch(a, b) {
|
|
if (!a || !b) return false;
|
|
const [short, long] = a.length <= b.length ? [a, b] : [b, a];
|
|
if (short.length < MIN_PREFIX) return false;
|
|
return long.startsWith(short);
|
|
}
|
|
|
|
// POST-HOC (pre-registration amendment 1): Norwegian is a compounding language,
|
|
// so "vektorindeks" and "vektorrepresentasjoner" are the same subject while
|
|
// neither prefixes the other. This matcher adds a shared-substring rule on top
|
|
// of the pre-registered prefix rule. It is a SUPERSET of tokensMatch and is
|
|
// labelled post-hoc everywhere it is reported.
|
|
const MIN_COMMON_SUBSTRING = 6;
|
|
|
|
export function tokensMatchCompound(a, b) {
|
|
if (tokensMatch(a, b)) return true;
|
|
if (!a || !b) return false;
|
|
for (let len = Math.min(a.length, b.length); len >= MIN_COMMON_SUBSTRING; len -= 1) {
|
|
for (let i = 0; i + len <= a.length; i += 1) {
|
|
if (b.includes(a.slice(i, i + len))) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Read the bundle index. DEFAULT's link template is '- [{label}]({target})';
|
|
* the policy allows prose, so non-matching lines are skipped rather than an error.
|
|
*
|
|
* The index is read because DEFAULT sets entries_match_directory=False — the
|
|
* index is AUTHORED, so the directory is not its denominator and a listing is
|
|
* not a check (contract paragraph 2, checklist item 9).
|
|
*/
|
|
export function parseIndex(indexMd) {
|
|
const re = /^- \[([^\]]*)\]\(([^)]+)\)$/;
|
|
return String(indexMd ?? '')
|
|
.split('\n')
|
|
.map((line) => re.exec(line.trim()))
|
|
.filter(Boolean)
|
|
.map((m) => ({ label: m[1], target: m[2] }));
|
|
}
|
|
|
|
// The OKF envelope is recognised by a key only it writes. Recognising it by
|
|
// "starts with ---" would eat the user's own frontmatter on a non-bundle file.
|
|
const OKF_ENVELOPE_KEY = /^source_sha256:/m;
|
|
|
|
const PRESERVED_KEYS = ['id', 'tittel', 'sjanger', 'dato', 'status', 'superseded_by'];
|
|
|
|
/**
|
|
* Lift the user's preserved frontmatter and H2 headings out of the first
|
|
* HEAD_SCAN_LINES lines of the document BODY (the OKF envelope, if present,
|
|
* is stripped first and does not count against the window).
|
|
*
|
|
* A key that is absent is ABSENT — it is never defaulted. C5: absence of a
|
|
* conditionally-written field is a measurement, not the negation of what the
|
|
* field asserts.
|
|
*/
|
|
export function headScan(text, lines = HEAD_SCAN_LINES) {
|
|
let rest = String(text ?? '');
|
|
if (rest.startsWith('---\n')) {
|
|
const end = rest.indexOf('\n---', 3);
|
|
if (end !== -1) {
|
|
const block = rest.slice(0, end);
|
|
if (OKF_ENVELOPE_KEY.test(block)) rest = rest.slice(end + 4);
|
|
}
|
|
}
|
|
const window = rest.split('\n').slice(0, lines);
|
|
const out = { h2: [] };
|
|
for (const line of window) {
|
|
const h2 = /^##\s+(.+?)\s*$/.exec(line);
|
|
if (h2) { out.h2.push(h2[1]); continue; }
|
|
const kv = /^([a-zA-Z_]+):\s*(.+?)\s*$/.exec(line);
|
|
if (kv && PRESERVED_KEYS.includes(kv[1])) out[kv[1]] = kv[2];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Score = sum over DISTINCT query tokens of the highest field weight that token
|
|
* hit. Distinct, so a repeated word cannot inflate a document's rank.
|
|
*/
|
|
export function scoreCandidate(queryTokens, candidate, matcher = tokensMatch) {
|
|
const fields = [
|
|
[FIELD_WEIGHTS.tittel, tokenize(candidate.tittel)],
|
|
[FIELD_WEIGHTS.h2, (candidate.h2 ?? []).flatMap((h) => tokenize(h))],
|
|
[FIELD_WEIGHTS.sjanger, tokenize(candidate.sjanger)],
|
|
[FIELD_WEIGHTS.label, tokenize(candidate.label)],
|
|
];
|
|
let total = 0;
|
|
for (const q of new Set(queryTokens)) {
|
|
let best = 0;
|
|
for (const [weight, tokens] of fields) {
|
|
if (weight > best && tokens.some((t) => matcher(q, t))) best = weight;
|
|
}
|
|
total += best;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Take scored candidates and cut them to the budget, newest-first on ties.
|
|
*
|
|
* Returns the delivered documents AND the receipt: how many bundle documents
|
|
* were not delivered, out of how many there were. The denominator is the whole
|
|
* candidate set, never the shortlist — reporting against the shortlist is how a
|
|
* cut launders itself into looking like a complete read.
|
|
*/
|
|
export function cutToBudget({ candidates, budget = AGENT_BUDGET, k = Infinity, applySupersession = false }) {
|
|
const denominator = candidates.length;
|
|
let superseded = 0;
|
|
let statusUnknown = 0;
|
|
|
|
const eligible = candidates.filter((c) => {
|
|
if (applySupersession) {
|
|
if (c.status === 'erstattet') { superseded += 1; return false; }
|
|
if (c.status === undefined) statusUnknown += 1;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const ranked = eligible
|
|
.map((c, i) => ({ c, i }))
|
|
.filter(({ c }) => (c.score ?? 0) > 0)
|
|
.sort((a, b) => (b.c.score - a.c.score)
|
|
|| String(b.c.dato ?? '').localeCompare(String(a.c.dato ?? ''))
|
|
|| (a.i - b.i))
|
|
.map(({ c }) => c);
|
|
|
|
const delivered = [];
|
|
let agentChars = 0;
|
|
for (const c of ranked) {
|
|
if (delivered.length >= k) break;
|
|
const size = (c.body ?? '').length;
|
|
if (agentChars + size > budget) break;
|
|
delivered.push(c);
|
|
agentChars += size;
|
|
}
|
|
|
|
return {
|
|
delivered,
|
|
agentChars,
|
|
denominator,
|
|
unread: denominator - delivered.length,
|
|
superseded,
|
|
statusUnknown,
|
|
};
|
|
}
|