agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
487 lines • 22.8 kB
JavaScript
;
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.figuresConclusionLine = exports.LIBRARY_NOTE_OPENING = exports.explainFigure = exports.explainRoundedDecimal = exports.figureCheckOf = exports.figureBasisOf = exports.figureNumbersOf = exports.MAX_DERIVED = exports.MAX_BASIS_NUMBERS = exports.FIGURE_UNITS = void 0;
const saidByPerson_js_1 = require("../../../lib/saidByPerson.js");
Object.defineProperty(exports, "LIBRARY_NOTE_OPENING", { enumerable: true, get: function () { return saidByPerson_js_1.LIBRARY_NOTE_OPENING; } });
const toolBytes_js_1 = require("../../../lib/toolBytes.js");
const evidenceIndex_js_1 = require("./evidenceIndex.js");
const normalize_js_1 = require("../../../lib/normalize.js");
/** Units that make a number a figure. Lower-case; `/s` may follow a size. */
var figureUnits_js_1 = require("./figureUnits.js");
Object.defineProperty(exports, "FIGURE_UNITS", { enumerable: true, get: function () { return figureUnits_js_1.FIGURE_UNITS; } });
const figureUnits_js_2 = require("./figureUnits.js");
const numericRanges_js_1 = require("./numericRanges.js");
const numberPrecision_js_1 = require("./numberPrecision.js");
/** A number (thousands separators allowed) followed, glued or one space apart, by a unit. */
const FIGURE_RE = new RegExp(String.raw `(?<![\w.,])([-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?)\s?(%|(?:` +
figureUnits_js_2.FIGURE_UNITS.filter((u) => u !== '%').join('|') +
String.raw `)(?:\/s)?(?![a-z0-9]))`, 'gi');
/** How many carried numbers the basis keeps. */
exports.MAX_BASIS_NUMBERS = 50_000;
/** How many column-derived values (sums, ratios, differences, complements) the basis keeps. */
exports.MAX_DERIVED = 50_000;
/** Rows per list, fields per list and groups per text field the basis reads. */
const MAX_ROWS = 5_000;
const MAX_FIELDS = 64;
const MAX_GROUPS = 50;
const MAX_GROUP_FIELDS = 8;
/** The normalized numbers the answer writes as figures (rule 1). */
function figureNumbersOf(answer) {
const out = new Set();
for (const m of answer.matchAll(FIGURE_RE)) {
const n = (0, normalize_js_1.normalizeToken)(m[1] ?? '');
if (n !== '')
out.add(n);
}
for (const range of (0, numericRanges_js_1.numericRangesOf)(answer)) {
if (range.unit === undefined)
continue;
out.add((0, normalize_js_1.normalizeToken)(range.first));
out.add((0, normalize_js_1.normalizeToken)(range.last));
}
return out;
}
exports.figureNumbersOf = figureNumbersOf;
/** A carried number the figures dial's derivations may use. */
const figureSource = (carried) => carried.decimalOnly !== true;
/** A finite number from a JSON leaf: a number, or a string that is one plain number. */
function numberOf(leaf) {
if (typeof leaf === 'number')
return Number.isFinite(leaf) ? leaf : undefined;
if (typeof leaf !== 'string')
return undefined;
const n = (0, normalize_js_1.normalizeToken)(leaf);
if (!/^[-+]?\d+(?:\.\d+)?$/.test(n))
return undefined;
const x = Number(n);
return Number.isFinite(x) ? x : undefined;
}
function isPlainObject(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
/** The unit suffix of a field name: after its last `_` or `-`, lower-cased. */
function unitOf(field) {
const m = /[_-]([A-Za-z]+)$/.exec(field);
return m?.[1]?.toLowerCase();
}
const PERCENT_FIELD = /(?:pct|percent|percentage)$/i;
/** Rows of a list: an array whose elements are objects, or a map whose values are. */
function rowsOf(node) {
if (Array.isArray(node)) {
if (node.length === 0 || !node.every(isPlainObject))
return undefined;
return node.slice(0, MAX_ROWS);
}
if (isPlainObject(node)) {
const values = Object.values(node);
if (values.length < 2 || !values.every(isPlainObject))
return undefined;
return values.slice(0, MAX_ROWS);
}
return undefined;
}
/** Source spellings are bounded and checked before a lossy Number conversion. */
function roundingSourceOf(leaf, shapes) {
if (shapes === undefined)
return undefined;
const spelling = (0, normalize_js_1.normalizeToken)(String(leaf));
if (spelling.length > numberPrecision_js_1.MAX_ROUNDING_SPELLING_CHARS)
return { allowed: false };
if (typeof leaf === 'string' && !/^[-+]?\d+(?:\.\d+)?$/.test(spelling))
return undefined;
return { spelling, allowed: !shapes.some((shape) => shape.match.test(spelling)) };
}
class Fold {
deriveColumns;
roundingShapes;
constructor(deriveColumns = true, roundingShapes) {
this.deriveColumns = deriveColumns;
this.roundingShapes = roundingShapes;
}
carried = [];
derived = [];
truncated = false;
addCarried(c) {
if (this.carried.length >= exports.MAX_BASIS_NUMBERS) {
this.truncated = true;
return;
}
this.carried.push(c);
}
addDerived(d) {
if (!Number.isFinite(d.x))
return;
if (this.derived.length >= exports.MAX_DERIVED) {
this.truncated = true;
return;
}
this.derived.push(d);
}
walk(node, at, key) {
const roundingSource = typeof node === 'string' || typeof node === 'number'
? roundingSourceOf(node, this.roundingShapes)
: undefined;
const x = numberOf(node);
if (x !== undefined) {
this.addCarried({
x,
at,
...(key !== undefined && { key }),
...(roundingSource !== undefined && { roundingSource }),
});
return;
}
if (typeof node === 'string') {
// A sentence inside a JSON leaf: its numbers are carried too.
for (const t of (0, normalize_js_1.tokenize)(node)) {
const tokenSource = roundingSourceOf(t, this.roundingShapes);
const n = numberOf(t);
if (n !== undefined)
this.addCarried({
x: n,
at,
...(tokenSource !== undefined && { roundingSource: tokenSource }),
});
else if (this.roundingShapes !== undefined)
this.addScientific(t, at, this.roundingShapes);
}
return;
}
const rows = rowsOf(node);
if (rows !== undefined && this.deriveColumns)
this.columns(rows, at);
if (Array.isArray(node)) {
node.forEach((el, i) => this.walk(el, `${at}[${i}]`));
}
else if (isPlainObject(node)) {
for (const [k, v] of Object.entries(node))
this.walk(v, at === '' ? k : `${at}.${k}`, k);
}
}
/**
* Under `decimalRounding`: a standalone scientific token in text
* (`numberPrecision.ts` · `scientificDecimalOf`) is a number this result
* carries, for the exact decimal rule only (`decimalOnly`). Its own
* spelling is the rounding source — `roundsTo` reads the exponent exactly —
* and a declared ID shape keeps it out of rounding, as for any source.
* Within the bounds the double it parses to is finite.
*/
addScientific(token, at, shapes) {
if ((0, numberPrecision_js_1.scientificDecimalOf)(token) === undefined)
return;
this.addCarried({
x: Number(token),
at,
roundingSource: {
spelling: token,
allowed: !shapes.some((shape) => shape.match.test(token)),
},
decimalOnly: true,
});
}
/** The column derivations of one list. */
columns(rows, at) {
const numeric = new Map();
const text = new Map();
for (const row of rows) {
for (const [field, v] of Object.entries(row)) {
if (typeof v === 'number' && Number.isFinite(v)) {
if (!numeric.has(field) && numeric.size >= MAX_FIELDS)
continue;
const list = numeric.get(field);
if (list === undefined)
numeric.set(field, [v]);
else
list.push(v);
}
else if (typeof v === 'string' && v !== '') {
const seen = text.get(field);
if (seen === undefined)
text.set(field, new Set([v]));
else
seen.add(v);
}
}
}
const name = at === '' ? 'result' : at;
this.sums(rows, numeric, `${name}[]`);
let groupFields = 0;
for (const [field, values] of text) {
if (values.size < 2 || values.size > MAX_GROUPS || values.size >= rows.length)
continue;
if (groupFields++ >= MAX_GROUP_FIELDS)
break;
for (const value of values) {
const members = rows.filter((r) => r[field] === value);
const cols = new Map();
for (const f of numeric.keys()) {
cols.set(f, members.flatMap((r) => typeof r[f] === 'number' && Number.isFinite(r[f]) ? [r[f]] : []));
}
this.sums(members, cols, `${name}[${field}=${value}]`);
}
}
}
/** Sums of each field over `rows`, and the like-unit ratios, differences and complements. */
sums(rows, numeric, label) {
const totals = new Map();
for (const [field, values] of numeric) {
if (values.length === 0)
continue;
const sum = values.reduce((a, b) => a + b, 0);
totals.set(field, sum);
// A one-row sum is the value itself — already carried.
if (rows.length > 1) {
this.addDerived({ x: sum, derivation: 'column-sum', from: `sum of ${label}.${field}` });
}
}
for (const [a, sa] of totals) {
const ua = unitOf(a);
if (ua === undefined || PERCENT_FIELD.test(a))
continue;
for (const [b, sb] of totals) {
if (a === b || unitOf(b) !== ua || PERCENT_FIELD.test(b))
continue;
if (sb !== 0) {
const ratio = (100 * sa) / sb;
const what = `100 × ${label}.${a} ÷ ${label}.${b}`;
this.addDerived({ x: ratio, derivation: 'column-ratio', from: what });
if (ratio >= 0 && ratio <= 100) {
this.addDerived({ x: 100 - ratio, derivation: 'complement', from: `100 − (${what})` });
}
}
if (sb - sa > 0) {
this.addDerived({
x: sb - sa,
derivation: 'column-difference',
from: `${label}.${b} − ${label}.${a}`,
});
}
}
}
}
}
// FOLD · the numbers this run's tool results carry, for the derivation question
// consumers read this and never re-derive it: gate.ts · checkAnswer, handed it by stages/route.ts · judgeEvidence
// detached: yes — never stored; rebuilt per judgement, and the verdict is committed as plain data.
/**
* 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.
*/
function figureBasisOf(history, deriveColumns = true, roundingShapes) {
const fold = new Fold(deriveColumns, roundingShapes);
for (const msg of history) {
if (msg.role !== 'tool')
continue;
const read = (0, evidenceIndex_js_1.readResult)((0, toolBytes_js_1.toolBytesOf)(msg));
if ('text' in read) {
fold.walk(read.text, '');
continue;
}
fold.walk(read.parsed, '');
if (read.tail !== undefined)
fold.walk(read.tail, '');
}
return { carried: fold.carried, derived: fold.derived, truncated: fold.truncated };
}
exports.figureBasisOf = figureBasisOf;
/**
* 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.
*/
function figureCheckOf(answer, history, options) {
const basis = figureBasisOf(history, options?.figures !== false, options?.decimalRounding === true ? options.shapes ?? [] : undefined);
return {
numbers: options?.figures === false ? new Set() : figureNumbersOf(answer),
explain: (value) => explainFigure(value, basis),
...(options?.decimalRounding === true && {
explainDecimal: (value) => explainRoundedDecimal(value, basis),
}),
};
}
exports.figureCheckOf = figureCheckOf;
/** A plain decimal may round ONE carried number, never a column derivation. */
function explainRoundedDecimal(value, basis) {
if (!/^[-+]?\d+\.\d+$/.test(value) || value.replace(/[^0-9]/g, '').length > 15) {
return undefined;
}
return roundedFrom(value, basis, (source) => Math.abs(source.x) <= Number.MAX_SAFE_INTEGER && source.roundingSource?.allowed === true, 'bounded');
}
exports.explainRoundedDecimal = explainRoundedDecimal;
/** The one carried-number rounding rule, shared by both opt-in dials. */
function roundedFrom(value, basis, sourceAllowed, precision = 'legacy') {
const f = Number(value);
const d = (0, numberPrecision_js_1.decimalsOf)(value);
for (const carried of basis.carried) {
if (sourceAllowed !== undefined && !sourceAllowed(carried))
continue;
const sourceSpelling = carried.roundingSource?.spelling;
const spellings = precision === 'bounded' ? { source: sourceSpelling ?? '', answer: value } : undefined;
if ((0, numberPrecision_js_1.roundsTo)(carried.x, f, d, spellings)) {
const original = precision === 'bounded' ? sourceSpelling : undefined;
return { derivation: 'rounded', from: `${original ?? shown(carried.x)} at ${carried.at}` };
}
}
return undefined;
}
const SCALES = [1, 2, 3, 4, 5].flatMap((k) => [
{ by: 1000 ** k, label: `1000^${k}` },
{ by: 1024 ** k, label: `1024^${k}` },
]);
/** Format a carried number for a `from` string. */
function shown(x) {
return Number.isInteger(x) ? String(x) : String(Number(x.toPrecision(12)));
}
/**
* 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.
*/
function explainFigure(value, basis) {
if (!/^[-+]?\d+(?:\.\d+)?$/.test(value))
return undefined;
const f = Number(value);
if (!Number.isFinite(f))
return undefined;
const d = (0, numberPrecision_js_1.decimalsOf)(value);
const rounded = roundedFrom(value, basis, figureSource);
if (rounded !== undefined)
return rounded;
for (const c of basis.carried) {
if (!figureSource(c) || Math.abs(c.x) < 1000)
continue;
for (const s of SCALES) {
if ((0, numberPrecision_js_1.roundsTo)(c.x / s.by, f, d)) {
return { derivation: 'unit-scale', from: `${shown(c.x)} at ${c.at} ÷ ${s.label}` };
}
}
}
for (const c of basis.carried) {
if (figureSource(c) &&
c.key !== undefined &&
PERCENT_FIELD.test(c.key) &&
(0, numberPrecision_js_1.roundsTo)(100 - c.x, f, d)) {
return { derivation: 'complement', from: `100 − ${shown(c.x)} at ${c.at}` };
}
}
for (const r of basis.derived) {
if ((0, numberPrecision_js_1.roundsTo)(r.x, f, d))
return { derivation: r.derivation, from: r.from };
}
for (const r of basis.derived) {
if (r.derivation !== 'column-sum' || Math.abs(r.x) < 1000)
continue;
for (const s of SCALES) {
if ((0, numberPrecision_js_1.roundsTo)(r.x / s.by, f, d)) {
return { derivation: 'unit-scale', from: `(${r.from}) ÷ ${s.label}` };
}
}
}
return undefined;
}
exports.explainFigure = explainFigure;
/** How many flagged numbers the late line names. */
const MAX_CONCLUSION_VALUES = 8;
// LENS · request-only · this-call
// reads: the flagged values the recheck stage HANDS it, never the corpus
// law: may omit, never deny; it states what the check found, never what the data says.
/**
* 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.
*/
function figuresConclusionLine(values) {
const numbers = values.filter((v) => v.shape === 'figure' || v.shape === 'number');
if (numbers.length === 0)
return undefined;
const shown = numbers.slice(0, MAX_CONCLUSION_VALUES).map((v) => v.value);
const more = numbers.length - shown.length;
return (saidByPerson_js_1.LIBRARY_NOTE_OPENING +
` The evidence check: these numbers in your draft appear in no tool result, and none is a rounding, sum, ` +
`ratio, difference or unit conversion of the numbers the results carry: ` +
`${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}. ` +
'Do not state them. State a figure only as a tool result gives it, or compute it with a ' +
'tool; if no result in front of you shows the figure asked for, say that plainly instead ' +
'of estimating.');
}
exports.figuresConclusionLine = figuresConclusionLine;
//# sourceMappingURL=figures.js.map