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
TypeScript
/**
* 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;