UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

226 lines • 9.92 kB
/** * scripts/match — is a fenced block the program ONE call of this turn * submitted? Identity against the record, never evidence for a value. * * Pattern: pure function over the answer and this turn's rows. * Role: core/ layer. Asked inside the ONE judge closure the Route decider * builds (`stages/route.ts` · `judgeEvidence`, through `judge.ts`), * before the names-and-numbers extractor reads the text — so the * draft and the delivered candidate are read alike. Only under * `.namesAndNumbersFromEvidence({ code: 'match' })`. * Emits: N/A. * * ## The match rule: exact, after removing only the wrapper (design § 5.2) * * A block matches a call only when its content (`../evidence/codeRegions.ts`) * equals the program the call SUBMITTED to the tool, byte for byte. Nothing is * transformed: no trailing-space strip, no line-ending rewrite, no Unicode * normalization, no shape, no edit distance — each can change a literal and so * the program (`print(len("""A \nB"""))` prints 5, its copy without the two * spaces 3; `"é"` written with a combining mark is one more character than its * NFC form). The strings are compared as they hold the text (UTF-16 code * units): for well-formed text that is UTF-8 byte equality, and for a lone * surrogate it is stricter, the safe direction. * * - Against the rows of the CURRENT TURN only; earlier turns are counted * (`earlierTurnRows`), never compared, and a miss says "no submission of * this turn", never "no submission". * - A block over `MAX_SCRIPT_BYTES` is `over-size`: no row keeps a program * that long, so it is never compared. * - Two calls that submitted byte-identical programs: the block names the * LATEST, counts the rest (`alsoMatches`) and carries its label. * - No nearest submission is ever named: a near match is a different program. * * WHAT A MATCH YIELDS. The block is script content: it is cut out of the text * the extractor reads, so it yields no value candidates — and it grounds * nothing: the evidence corpus is untouched, so a value in the prose that also * sits in the script still needs a result. A MISS stays in the text and is * checked as prose, exactly as today. * * COST. One linear scan of the answer (`codeRegionsOf`); each of at most * `MAX_CODE_REGIONS` blocks is looked up once among at most `MAX_SCRIPT_ROWS` * programs of at most `MAX_SCRIPT_BYTES` — within the 512 comparisons per * judged answer of design § 4.4. Earlier turns add nothing. */ import { codeRegionsOf, MAX_CODE_REGIONS } from '../evidence/codeRegions.js'; import { utf8Length } from '../../../lib/utf8.js'; import { exitCodeFits, SCRIPT_LABELS } from './label.js'; import { MAX_SCRIPT_BYTES } from './rows.js'; /** The text with `cut` regions removed, whole lines each (fence lines included). */ function without(text, cut) { if (cut.length === 0) return text; const parts = []; let from = 0; for (const region of cut) { parts.push(text.slice(from, region.start)); from = region.end; } parts.push(text.slice(from)); return parts.join(''); } /** Whether a block's content is over the per-script bound — never compared then. */ function overSize(content) { // Every UTF-16 unit is at least one UTF-8 byte: a long string is over at once. return content.length > MAX_SCRIPT_BYTES || utf8Length(content) > MAX_SCRIPT_BYTES; } /** * Read `answer`'s fenced blocks against this turn's submissions. Pure; the * rows are `rows.ts` · `scriptRowsOfTurn`'s reading (last row per call). */ export function readShownScripts(answer, turn, scriptsDropped) { const { regions, dropped } = codeRegionsOf(answer); // The programs this turn submitted, each with every call that submitted it, // in the order the calls were made — the last is "the latest". const byProgram = new Map(); for (const row of turn.current) { if (row.text === undefined) continue; const calls = byProgram.get(row.text); if (calls === undefined) byProgram.set(row.text, [row]); else calls.push(row); } const blocks = []; const matched = []; for (const region of regions) { if (overSize(region.content)) { blocks.push({ block: region.block, label: 'unmatched', reason: 'over-size' }); continue; } const calls = byProgram.get(region.content); if (calls === undefined) { blocks.push({ block: region.block, label: 'unmatched', reason: 'no-submission-this-turn' }); continue; } const latest = calls[calls.length - 1]; matched.push(region); blocks.push({ block: region.block, label: latest.outcome, ...(latest.exitCode !== undefined && { exitCode: latest.exitCode }), toolCallId: latest.toolCallId, tool: latest.tool, dispatch: latest.dispatch, ...(calls.length > 1 && { alsoMatches: calls.length - 1 }), }); } return { prose: without(answer, matched), outsideCode: without(answer, regions), verdict: { blocks, earlierTurnRows: turn.earlier, regionsDropped: dropped, ...(scriptsDropped !== undefined && scriptsDropped > 0 && { scriptsDropped }), }, }; } /** * A verdict as detached plain data — field by field, never a reference into * state (a value read back through a TypedScope is a live view, and an event * payload must be plain data). */ export function copyScriptsVerdict(verdict) { return { blocks: verdict.blocks.map((b) => b.label === 'unmatched' ? { block: b.block, label: 'unmatched', reason: b.reason } : { block: b.block, label: b.label, ...(b.exitCode !== undefined && { exitCode: b.exitCode }), toolCallId: b.toolCallId, tool: b.tool, dispatch: b.dispatch, ...(b.alsoMatches !== undefined && { alsoMatches: b.alsoMatches }), }), earlierTurnRows: verdict.earlierTurnRows, regionsDropped: verdict.regionsDropped, ...(verdict.scriptsDropped !== undefined && { scriptsDropped: verdict.scriptsDropped }), }; } // ─── Reading a record back ──────────────────────────────────────────── // // A saved verdict is JSON a reader is handed, not a value this library holds. // ONE rule reads its fields for every reader of the shape — the answer // account (`evidence_checked.scripts`) and the checkpoint door's // `script-shown` witness row (`assessment/witness.ts`) — and it refuses // exactly what no writer of this library files. /** Every reason a block can be unmatched for. */ export const UNMATCHED_REASONS = Object.freeze([ 'no-submission-this-turn', 'over-size', 'kept-out', ]); const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); const isPositive = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 1; /** * One block verdict's fields, checked as {@link readShownScripts} writes them * — a fresh copy, or `undefined` when any field is one no writer of this * library files: a position or a dispatch that is not a whole number from 1, * a label outside the vocabulary, an exit code its label does not carry * (`label.ts` · `exitCodeFits`), an unmatched block that names a call, a * matched one without its call. Fields beside these (a witness row's `kind`, * `turn`, `iteration`) are not read. */ export function blockVerdictOf(value) { if (!isObject(value)) return undefined; const { block, label, reason, toolCallId, tool, dispatch, exitCode, alsoMatches } = value; if (!isPositive(block)) return undefined; if (label === 'unmatched') { const named = toolCallId !== undefined || tool !== undefined || dispatch !== undefined || exitCode !== undefined || alsoMatches !== undefined; if (named || !UNMATCHED_REASONS.includes(reason)) return undefined; return { block, label: 'unmatched', reason: reason }; } if (reason !== undefined || !SCRIPT_LABELS.includes(label)) { return undefined; } if (!exitCodeFits(label, exitCode)) return undefined; if (typeof toolCallId !== 'string' || typeof tool !== 'string') return undefined; if (!isPositive(dispatch)) return undefined; if (alsoMatches !== undefined && !isPositive(alsoMatches)) return undefined; return { block, label: label, ...(exitCode !== undefined && { exitCode: exitCode }), toolCallId, tool, dispatch, ...(alsoMatches !== undefined && { alsoMatches }), }; } /** * A saved `evidence_checked.scripts` — THE reader of the shape for a reader * of a record. `undefined` when the event carries no scripts arm. Bounded by * the writer's cap: no reader iterates more verdicts than a writer files. */ export function recordedBlocks(value) { if (value === undefined) return undefined; if (!isObject(value) || !Array.isArray(value.blocks)) return { blocks: [], unread: 1 }; const all = value.blocks; const blocks = []; let unread = Math.max(0, all.length - MAX_CODE_REGIONS); for (let index = 0; index < Math.min(all.length, MAX_CODE_REGIONS); index++) { const verdict = blockVerdictOf(all[index]); if (verdict === undefined) unread += 1; else blocks.push({ index, verdict }); } return { blocks, unread }; } //# sourceMappingURL=match.js.map