UNPKG

agentfootprint

Version:

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

378 lines • 17.5 kB
"use strict"; /** * extract — which tokens in an answer are DATA, and therefore have to be * grounded in a tool result. * * Pattern: a pure classifier over normalized tokens and the small shared * range/precision leaves, so it can be unit-tested on strings with no agent, no * chart and no model. * Role: core/ layer. The hard half of `namesAndNumbersFromEvidence`. * Emits: N/A. * * ## The rule, and why every part of it is there * * A naive "flag every number" extractor makes the feature worse than useless. * It flags `24 hours`, `3 issues` and `first`, and under `guard` it then spends * a real turn asking the model to justify the word "three" — which is the * retry loop this library exists to remove. So the default is CONSERVATIVE: it * would rather miss a fabricated value than accuse a correct answer. * * A token is a candidate only if: * * 1. **It contains a digit.** English prose is alphabetic. Requiring a digit * is the single cheapest separator between "data someone read off a * screen" and "a word". It is also the rule's biggest blind spot, stated * plainly: a fabricated all-letters name (`esxi-host-alpha`) is invisible * to the default and needs a declared shape. * 2. **It is distinctive.** Either * • an IDENTIFIER — digits mixed with letters or with structural * punctuation (`:` `_` `-` `/` `.`), at least 4 characters: * `0xef0101`, `fc1/3`, `21:00:00:24:ff:4a:12:03`, `UCSB-B200-M5`; or * • a NUMBER with at least `minDigits` (default 4) digits: `41,200`, * `18450`, `786432`. * 3. **It is not prose wearing a number.** Three exclusions, each from a * real sentence in the material: * • a number with a short unit or word glued to it — `32G`, `100MB`, * `47th`, `2h`, `48-port`, `$20/month` — is judged on its NUMBER * alone, so `48-port switch` never trips while `41200iops` still * does; * • a bare `N/M` of one or two digits each — `24/7`, `1/2` — is a ratio * in prose. A two-number port id is spelled the same way, so the * conservative reading wins and a domain that needs it declares a * shape; * • anything the caller declared exempt. * * Declared shapes are tested FIRST and win: an app that says "this is what my * identifiers look like" has better information than these heuristics. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.readAnswer = exports.extractCandidates = exports.candidateForms = exports.classifyToken = exports.isDeclaredExempt = void 0; const normalize_js_1 = require("../../../lib/normalize.js"); const numericRanges_js_1 = require("./numericRanges.js"); const numberPrecision_js_1 = require("./numberPrecision.js"); /** Structural punctuation an identifier is allowed to be built from. */ const STRUCTURAL = /[:_\-/.]/; /** At least one ASCII letter. */ const HAS_LETTER = /[a-z]/; /** * `<number><short tail>` — a quantity with its unit or its adjective stuck to * it. The tail is capped at 5 letters because real units are short (`mbps`, * `gbps`, `hours`) while an identifier's alphabetic run is usually not, and an * optional `/word` covers `20/month` and `100mb/s`. */ const NUMBER_WITH_TAIL = /^([-+]?\d+(?:\.\d+)?)[-/]?[a-z]{1,5}(?:\/[a-z]{1,5})?$/; /** A plain number, already canonicalised by `normalizeToken`. */ const BARE_NUMBER = /^[-+]?\d+(?:\.\d+)?$/; /** `24/7`, `1/2` — a ratio in prose, not an identifier. */ const PROSE_RATIO = /^\d{1,2}\/\d{1,2}$/; /** Ceiling on how many tokens one answer contributes. A model that pastes a * 10 MB table into its answer must not turn the gate into the run's cost. * Past it nothing is classified or looked up — only COUNTED * (`AnswerReading.unread`), so a reader never mistakes a partial reading for * a clean one. */ const MAX_ANSWER_TOKENS = 20_000; /** True when the caller declared this token exempt. */ function isDeclaredExempt(token, gate) { if (gate.exemptValues.has(token)) return true; for (const p of gate.exemptPatterns) if (p.test(token)) return true; return false; } exports.isDeclaredExempt = isDeclaredExempt; /** * Classify ONE normalized token. Returns the candidate it produces, or * `undefined` when the token is prose. * * Exported for the unit tests, which is the whole reason the classifier is a * function over a string rather than a loop body. */ function classifyToken(token, gate, /** Under the figures dial: the numbers the answer writes wearing a unit * (`figures.ts` · `figureNumbersOf`). A number in it is a candidate * whatever its digit count — `53.2%` and `6.3 TB` are readings. */ figures) { if (token === '') return undefined; if (isDeclaredExempt(token, gate)) return undefined; // Declared shapes first — the app knows its own domain better than the // heuristics below, including when a shape has no digits at all. for (const shape of gate.shapes) { if (shape.match.test(token)) return { value: token, shape: shape.name }; } // Rule 1 — no digit, no candidate. Prose is alphabetic. if ((0, normalize_js_1.countDigits)(token) === 0) return undefined; // Rule 3 — prose wearing a number. if (PROSE_RATIO.test(token)) return undefined; const withTail = NUMBER_WITH_TAIL.exec(token); const numeric = withTail ? (0, normalize_js_1.normalizeToken)(withTail[1]) : BARE_NUMBER.test(token) ? token : undefined; if (numeric !== undefined) { // A quantity — judged on its digits only, so `32G` and `47th` are prose // while `41200iops` is still a reading. The glued spelling rides along // so the lookup can meet a result that carried it that way. if ((0, normalize_js_1.countDigits)(numeric) < gate.minDigits) { // A FIGURE — a number wearing a unit of measure — is a reading off a // screen whatever its length (`figures.ts`, rule 1). Off, never reached. if (figures?.has(numeric) !== true) return undefined; return withTail !== null && token !== numeric ? { value: numeric, shape: 'figure', token } : { value: numeric, shape: 'figure' }; } return withTail !== null && token !== numeric ? { value: numeric, shape: 'number', token } : { value: numeric, shape: 'number' }; } // Rule 2 — an identifier: digits mixed with letters or structure, long // enough to be distinctive. `po1` and `a1` are under the bar on purpose. if (token.length < 4) return undefined; if (!HAS_LETTER.test(token) && !STRUCTURAL.test(token)) return undefined; return { value: token, shape: 'identifier' }; } exports.classifyToken = classifyToken; /** A JSON number literal, read where it stands (sticky). */ const JSON_NUMBER = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y; /** * The texts an answer states, each read on its own. Prose is one text. A * typed answer — the whole answer one JSON object or array, which is what * `.outputSchema()` accepts (`JSON.parse` of the answer) — is read value by * value: every string as text (and every key, unless `valuesOnly`), every * number as the literal the answer WROTE. Read whole as text, compact JSON * glues its punctuation onto the values (`[5,100]` read as the * thousands-separated `5100`, `[0.45,0.12]` as one identifier, `[1200,9750]` * as no value at all); read through the parsed value, a number is the double * it parses to (`9007199254740993` is `9007199254740992`, `5.20` is `5.2` and * loses the precision the rounding rule judges). So the text is checked with * `JSON.parse` and then read ONCE, left to right: string literals decoded, * number literals kept as written. * * One pass, no recursion (an answer is model output: its nesting is not a * call depth), yielded lazily: the reader decides how far it classifies and * counts the rest (`MAX_ANSWER_TOKENS`). */ function* answerTexts(answer, valuesOnly) { const opening = answer.trimStart()[0]; if (opening !== '{' && opening !== '[') { yield answer; return; } try { JSON.parse(answer); // the grammar check — the values are read from the text below } catch { yield answer; // not one JSON value: prose, as it always was return; } yield* jsonLiterals(answer, valuesOnly); } /** * The keys (unless `valuesOnly`), strings (decoded) and number literals (as * written) of a text `JSON.parse` accepted, left to right. Total on any input * — bounded by the text's length — though only ever handed valid JSON. */ function* jsonLiterals(json, valuesOnly) { let at = 0; while (at < json.length) { const ch = json.charCodeAt(at); if (ch === 0x22 /* " */) { // The literal ends at the first quote no backslash escapes. let end = at + 1; while (end < json.length && json.charCodeAt(end) !== 0x22) { end += json.charCodeAt(end) === 0x5c /* \ */ ? 2 : 1; } const literal = json.slice(at, end + 1); at = end + 1; // In JSON a string followed by `:` is a key, and only a key is. if (valuesOnly && isKeyEnd(json, at)) continue; yield JSON.parse(literal); } else if (ch === 0x2d /* - */ || (ch >= 0x30 && ch <= 0x39)) { // Outside a string, a digit or a minus only ever starts a number. JSON_NUMBER.lastIndex = at; const literal = JSON_NUMBER.exec(json)?.[0] ?? json.charAt(at); at += literal.length; yield literal; } else { at += 1; // structure, whitespace, true / false / null } } } /** The next character after `at` that is not JSON whitespace is `:`. */ function isKeyEnd(json, at) { let next = at; while (next < json.length) { const ch = json.charCodeAt(next); // JSON whitespace: space, tab, line feed, carriage return. if (ch !== 0x20 && ch !== 0x09 && ch !== 0x0a && ch !== 0x0d) break; next += 1; } return json.charCodeAt(next) === 0x3a /* : */; } /** * The spellings a candidate is looked up under: its value's forms * (`lookupForms` — the `0x` rule), plus the glued-unit token's when the * value was read off one. The LOOKUP side widens, the index never does: a * result carrying `1007us` grounds an answer's `1007us`, while a result * carrying `latency 2024ms` does not ground an answer's prose year `2024` * — that candidate came from a bare token and asks for `2024` alone. The * one place both the gate (`gate.ts · checkAnswer`) and the contingent * check (`findings/contingent.ts`) take a candidate's forms from. */ function candidateForms(candidate) { const forms = [...(0, normalize_js_1.lookupForms)(candidate.value)]; for (const token of [candidate.token, candidate.rangeToken]) { if (token === undefined) continue; for (const form of (0, normalize_js_1.lookupForms)(token)) if (!forms.includes(form)) forms.push(form); } return forms; } exports.candidateForms = candidateForms; /** * Under `decimalRounding`: the number a standalone scientific spelling states * (`numberPrecision.ts` · `scientificDecimalOf`), as a number candidate — * whatever its digit count, like a plain decimal under the dial. It is looked * up under its exact decimal (`0.0112` for `1.12e-2`) and under its own * spelling (`token`, which a result may have printed as it is), and, when it * is a fraction, rounded at the precision it was written with (`1.12e-2` is * four places, `0.0112`). The spelling is read with its `%` kept, so a * percentage is never read as the fraction (`1.12e-2%`). `undefined` for any * other spelling, and for one outside the bounds: it stays what the * classifier makes of it. */ function scientificCandidateOf(spelling, token) { const decimal = (0, numberPrecision_js_1.scientificDecimalOf)(spelling); if (decimal === undefined) return undefined; const value = (0, normalize_js_1.normalizeToken)(decimal.exact); return { value, shape: 'number', ...(token !== value && { token }), ...(decimal.written !== undefined && { decimalSpelling: decimal.written }), }; } /** * Every distinct value the answer asserts, in first-appearance order. * * De-duplicated by value: a port named six times is one claim to ground, and * a correction that lists it six times reads like noise. A typed answer is * read value by value (`answerTexts`). */ function extractCandidates(answer, gate, /** Under the figures dial: the numbers the answer writes wearing a unit * (`figures.ts` · `figureNumbersOf`, computed by the caller that loaded * it — the module stays off a plain agent's graph). */ figures, /** A read-only exact-token view supplied by the caller's corpus. Whole * carried values win before a numeric range is interpreted as endpoints. */ exactTokens) { return readAnswer(answer, gate, { figures, exactTokens }).candidates; } exports.extractCandidates = extractCandidates; /** * Read one answer: the values it asserts (`extractCandidates`), and how many * of its words and numbers lay past the ceiling, never read. The reading * stops CLASSIFYING at `MAX_ANSWER_TOKENS`; it never stops counting. */ function readAnswer(answer, gate, options = {}) { const { figures, exactTokens, typed = false } = options; const seen = new Map(); const out = []; let budget = MAX_ANSWER_TOKENS; let unread = 0; const record = (candidate) => { if (candidate === undefined) return; const prior = seen.get(candidate.value); if (prior !== undefined) { // One value may be written at two precisions. Check the stricter one; // an earlier coarse spelling cannot launder a later overprecise claim. const previous = out[prior]; if (candidate.decimalSpelling !== undefined && (0, numberPrecision_js_1.decimalsOf)(candidate.decimalSpelling) > (0, numberPrecision_js_1.decimalsOf)(previous?.decimalSpelling ?? '')) out[prior] = candidate; return; } seen.set(candidate.value, out.length); out.push(candidate); }; const add = (spelling, rangeToken) => { if (budget <= 0) { unread += 1; // a range's second endpoint, past the line return; } budget -= 1; const token = (0, normalize_js_1.normalizeToken)(spelling); let candidate = classifyToken(token, gate, figures); // Declared shapes and exemptions win, including over precision promotion. if (gate.decimalRounding === true) { const promoted = /^[-+]?\d+\.\d+$/.test(spelling) ? { value: token, shape: 'number', decimalSpelling: spelling } : scientificCandidateOf(spelling, token); if (promoted !== undefined && !isDeclaredExempt(token, gate) && !gate.shapes.some((shape) => shape.match.test(token))) { // An exempt value stays exempt in another spelling (`1.12e-2` for `0.0112`). candidate = isDeclaredExempt(promoted.value, gate) ? undefined : promoted; } } record(candidate !== undefined && rangeToken !== undefined ? { ...candidate, rangeToken } : candidate); }; for (const text of answerTexts(answer, typed)) { const spellings = (0, normalize_js_1.answerTokenSpellings)(text); if (budget <= 0) { unread += spellings.length; // past the line: counted, never classified continue; } for (let index = 0; index < spellings.length; index += 1) { if (budget <= 0) { unread += spellings.length - index; break; } const spelling = spellings[index] ?? ''; const token = (0, normalize_js_1.normalizeToken)(spelling); // Test app-owned ID declarations on the intact token before heuristics. if (isDeclaredExempt(token, gate) || gate.shapes.some((shape) => shape.match.test(token))) { add(spelling); continue; } const range = (0, numericRanges_js_1.splitNumericRange)(spelling, spellings[index + 1]); if (range !== undefined && exactTokens?.has(token) === true) { budget -= 1; record({ value: token, shape: 'identifier' }); continue; } if (range !== undefined) { const suffix = range.unit ?? ''; add(`${range.first}${suffix}`, token); add(`${range.last}${suffix}`, token); continue; } // An en dash outside our narrow grammar retains the tokenizer's old meaning. for (const piece of spelling.includes('–') ? (0, normalize_js_1.tokenize)(spelling) : [spelling]) add(piece); } } return { candidates: out, unread }; } exports.readAnswer = readAnswer; //# sourceMappingURL=extract.js.map