UNPKG

agentfootprint

Version:

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

182 lines (181 loc) • 9.62 kB
/** * figures — a number wearing a unit is data, and a number no result carried * is asked whether it DERIVES from the numbers one did, before it is flagged. * * Pattern: two pure folds — over the answer's text (`figureNumbersOf`) and * over the tool results (`figureBasisOf`) — and one question * (`explainFigure`). Deterministic arithmetic, no model. * Role: core/ layer, loaded only under `figures` or `decimalRounding`. * Both off, nothing here runs and ordinary verdicts are byte-identical. * Emits: N/A. * * ## The hole this closes * * "How full is the cluster?" — a small model answered "53.2% used, 6.3 TB * free" and, on a second run, "64.5% used, 24.8 TB usable". The tool's rows * said 61.1% / 435.5 TB and 57.7% / 1,090.8 TB; neither answer's figures are * anywhere in them. The gate passed both answers CLEAN: `53.2`, `6.3`, `64.5` * and `24.8` have under `minDigits` (4) digits, so the extractor read them as * prose ("3 issues", "24 hours") and never looked them up. The bias is right * for a count in a sentence and wrong for a MEASUREMENT: a number with a unit * of measure stuck to it is a reading off a screen, whatever its length. * * And the reverse: the honest answer to the same question is a CLUSTER total * — "1,526.3 TB usable" — which is a sum, and appears in no result. The gate * flagged it as invented. A derived figure is neither read nor invented. * * ## The rule * * 1. **A figure is a number wearing a unit** (`FIGURE_UNITS`: `%`, `percent`, * byte sizes, IOPS, throughput, `ms`/`us`), glued or one space apart. It is * a candidate whatever its digit count. A whole number with no unit stays * under `minDigits`' rule. * 2. **A number no result carried is asked for a derivation** — the closed set * `FigureDerivation`, each over numbers the results DID carry, compared at * the answer's own precision (`61%` matches 61.1; `61.0%` does not): * • `rounded` — a carried number, rounded; * • `unit-scale` — a carried number ÷ 1000^k or 1024^k (k = 1…5): * bytes stated as TB/TiB; * • `column-sum` — the sum of one numeric field over the rows of * one list (an array of objects, or a map of * objects), or over the rows sharing one value * of a text field (`cluster = A`); also scaled; * • `column-ratio` — 100 × one column sum ÷ another, both fields * wearing the same unit suffix (`used_tb` / * `total_tb`) — a percentage of totals; * • `column-difference` — one like-unit column sum minus another * (`total_tb` − `used_tb`), when positive; * • `complement` — 100 − a carried percentage (a field whose name * ends in `pct`/`percent`) or 100 − a ratio * above. * A number that is one of these is COMPUTED (`EvidenceVerdict.computed`, * with the derivation named); one that is none of them is UNSUPPORTED, and * the sentences say so: no value, and no derivation. * * ## What it is not * * Not proof the arithmetic answered the question: "59.2% used" is accepted * as the ratio of two columns even if the person asked about one pool. Not * open-ended: a product, an average, a chain of two derivations, or a sum * over an arbitrary subset of rows is not reconstructed and is flagged — the * set is closed on purpose, because every derivation added is a window an * invented number can fall through by coincidence. The coincidence is real * and bounded by precision: a whole-number figure matches anything within * ±0.5 of it, a one-decimal figure ±0.05. The basis is bounded too * (`MAX_BASIS_NUMBERS`, `MAX_DERIVED`); past a bound the remaining numbers * are not asked about, which can only flag MORE, never fewer. */ import type { LLMMessage } from '../../../provider-contract/types.js'; import { LIBRARY_NOTE_OPENING } from '../../../lib/saidByPerson.js'; import type { EvidenceShape, FigureDerivation, UnsupportedValue } from './types.js'; /** Units that make a number a figure. Lower-case; `/s` may follow a size. */ export { FIGURE_UNITS } from './figureUnits.js'; /** How many carried numbers the basis keeps. */ export declare const MAX_BASIS_NUMBERS = 50000; /** How many column-derived values (sums, ratios, differences, complements) the basis keeps. */ export declare const MAX_DERIVED = 50000; /** The normalized numbers the answer writes as figures (rule 1). */ export declare function figureNumbersOf(answer: string): ReadonlySet<string>; /** One carried number, and where it was. */ interface Carried { readonly x: number; readonly at: string; /** New-dial metadata, read before Number conversion; absent for legacy folds. */ readonly roundingSource?: { readonly spelling?: string; readonly allowed: boolean; }; /** The field it sat under, when it sat under one. */ readonly key?: string; /** * Read off a scientific spelling in text under `decimalRounding` * (`1.12e-02`): a source for the exact decimal rule only. The figures * dial's own derivations compare doubles and never see it. */ readonly decimalOnly?: true; } /** One reconstructed value. */ interface Derived { readonly x: number; readonly derivation: FigureDerivation; readonly from: string; } /** What the results carry, folded for the derivation question. */ export interface FigureBasis { readonly carried: readonly Carried[]; readonly derived: readonly Derived[]; /** True when a bound cut the fold — some numbers were never asked about. */ readonly truncated: boolean; } /** * Fold every `role: 'tool'` message in `history` (the same messages, read the * same way, as the evidence corpus — `evidenceIndex.ts` · `readResult` over * `lib/toolBytes.ts` · `toolBytesOf`) into the numbers they carry and the * column derivations over their lists. */ export declare function figureBasisOf(history: readonly LLMMessage[], deriveColumns?: boolean, roundingShapes?: readonly EvidenceShape[]): FigureBasis; /** What the gate is handed under the dial: built once per judgement. */ export interface FigureCheck { /** The normalized numbers the answer writes as figures (rule 1). */ readonly numbers: ReadonlySet<string>; /** Opt-in unitless decimal rounding, using the written precision. */ readonly explainDecimal?: (value: string) => { readonly derivation: 'rounded'; readonly from: string; } | undefined; /** The derivation question over the results' numbers (rule 2). */ readonly explain: (value: string) => { readonly derivation: FigureDerivation; readonly from: string; } | undefined; } /** * The gate's numeric input for one judgement — the answer's figures and the * results' basis. This module loads through `import()` under the dial only * (`stages/route.ts` · `loadFigures`), so a plain agent's graph never carries it. */ export declare function figureCheckOf(answer: string, history: readonly LLMMessage[], options?: { readonly figures: boolean; readonly decimalRounding?: boolean; readonly shapes?: readonly EvidenceShape[]; }): FigureCheck; /** A plain decimal may round ONE carried number, never a column derivation. */ export declare function explainRoundedDecimal(value: string, basis: FigureBasis): { readonly derivation: 'rounded'; readonly from: string; } | undefined; /** * Is `value` (a normalized number from the answer) a declared derivation of * the numbers in `basis`? The first derivation found, or `undefined`. Order: * rounded, unit-scale, then the column derivations (sum, ratio, difference, * complement) — each also unit-scaled for a sum. */ export declare function explainFigure(value: string, basis: FigureBasis): { readonly derivation: FigureDerivation; readonly from: string; } | undefined; /** * WHO says the late line — the library, not the person — in the exact words * the time line measured (`arguments/serve.ts` · `TIME_LINE_SOURCE`: an * unmarked request-only `user` line drew "You're right… I apologize" on 37 of * 37 answers). Both are the ONE copy in the authorship registry * (`lib/saidByPerson.ts`, a zero-import leaf — so this folder still never * loads the time layer's module graph), which is what makes `isSaidByPerson` * false for this line (G17). */ export { LIBRARY_NOTE_OPENING }; /** * The library's conclusion about the numbers it flagged under the figures * dial, as ONE line for the end of the revision request. `undefined` when no * flagged value is a number (an identifier is not a figure; its correction * is the instruction alone). * * Why a late line and not one more clause in the instruction: the instruction * sits in the system prompt, the top of a long context; the measured failure * of a served fact is recency (a library fact served late moved a bench from * 74 to 20 of 80), so the conclusion goes where the model decides. Worded as * the framework's finding — never as the person's correction, which a model * thanks and apologises to. It names the draft's own flagged numbers, which * the instruction already quotes, and tells the model not to state them. */ export declare function figuresConclusionLine(values: readonly UnsupportedValue[]): string | undefined;