agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
414 lines • 20.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.claimValueKey = exports.compareClaimValues = exports.claimShapeOf = exports.evaluateClaimPair = void 0;
const types_js_1 = require("../assertion/types.js");
const conflicts_js_1 = require("../assertion/conflicts.js");
// The case rule lives in the one spelling leaf, shared with every name binding.
const normalize_js_1 = require("../../lib/normalize.js");
const isDoubt = (value) => value === null || value === 'unknown';
/** The existing one-direction leniency: a raw numeric string claims a number. */
function numericallyEqual(claimed, settled) {
return (typeof settled === 'number' &&
typeof claimed === 'string' &&
claimed.trim() !== '' &&
Number(claimed) === settled);
}
/**
* Evaluate an explicitly selected evidence/claim pair, without selecting or
* rewriting either assertion. Adapters must establish that the asserted pair
* concerns the same subject, predicate and epoch before interpreting a result.
* No rule here establishes scope, completeness, authorisation or truth.
*/
function evaluateClaimPair(evidence, claim) {
if (!(0, types_js_1.participates)(evidence.value))
return 'evidence-unavailable';
const settled = (0, types_js_1.comparableValueOf)(evidence.value);
// RAW claimed null/'unknown' is doubt; a known(...) wrapper follows the
// algebra below. Unwrapping both sides here would change the old contract.
if (isDoubt(claim.value))
return isDoubt(settled) ? 'both-doubt' : 'doubt-against-value';
if (numericallyEqual(claim.value, settled))
return 'numeric-leniency';
const conflict = (0, conflicts_js_1.conflictsOf)([evidence, claim]).length !== 0;
// Classify the absence without converting it into positive agreement. Do
// this AFTER the algebra so its existing serialization failures still occur.
if (!(0, types_js_1.participates)(claim.value))
return 'claim-nonparticipating';
return conflict ? 'conflict' : 'no-conflict';
}
exports.evaluateClaimPair = evaluateClaimPair;
/** A string that is a plain decimal: a number as JSON writes one, with no exponent. */
const PLAIN_DECIMAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
/** A plain decimal and the unit or word after it: `1250 ms`, `42.50 USD`, `50%`. */
const WITH_UNIT = /^(-?(?:0|[1-9]\d*)(?:\.\d+)?)\s*([^\d\s][^\d]*)$/;
/** Lists and objects nest; past this depth (or around a cycle) values are not compared. */
const MAX_DEPTH = 64;
/**
* A value written in a FORMAT — a currency symbol, digit separators, a date
* or a time — has more than one spelling of one value (`$1,250.00` and
* `$1,250`, `2026-01-01` and `2026-01-01T00:00:00Z`), or one spelling of two
* (`1.250` is 1.25 or 1250, by locale), so two of them agree only as written
* and are otherwise never compared.
*/
const FORMATTED = [
/\p{Sc}/u, // a currency symbol: $ € £ ¥ ₹ …
/\d[,'\u00a0\u202f]\d/u, // a separator between digits: 1,250 · 1,5 · 1'250
/\d \d{3}(?!\d)/, // a space between thousands: 1 250
/(?:^|[^\d.])[1-9]\d{0,2}(?:\.\d{3})+(?![\d.])/, // dot-grouped: 1.250 · 12.345.678
/\d{4}-\d{2}-\d{2}/, // an ISO date, with or without its time
/\b\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b/, // 01/02/2026 · 1-2-26
/\b\d{1,2}\.\d{1,2}\.\d{4}\b/, // 01.02.2026
/(?:^|[\sT])\d{1,2}:\d{2}(?::\d{2})?(?![:\w])/, // a clock time: 09:14 · 9:14:05
/\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2}\b/i, // Jan 5
/\b\d{1,2}(?:st|nd|rd|th)?\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i, // 5 Jan
];
const isFormatted = (text) => FORMATTED.some((format) => format.test(text));
/** Every number inside a text by its value: `USD 42.50` and `USD 42.5` mask alike. */
const masked = (text) => text.replace(/\d+(?:\.\d+)?/g, (number) => `\u0000${decimalKey(number)}\u0000`);
/**
* Two texts (each `textOf`). Equal: they agree. Otherwise a formatted one is
* never compared. Texts with no digit agree when they differ in case alone
* (`caseOnly`: foldable words, the same letters in another case). Texts that
* differ in no letter are not compared, never a contradiction: any other case
* difference (a short word, a mixed-case one, a digit, ß against ss in any
* casing, a long s or a ligature against its letters), a mark (an accent:
* Greek capitals drop theirs, `Αθήνα` and `ΑΘΗΝΑ`), a compatibility form (a
* full-width letter) or the dot of an i (`ılık`, `ilik`) — and neither are
* texts that differ only in how a number inside them is spelled (`USD 42.50`,
* `v1.20`). Anything else contradicts.
*/
function compareText(a, b) {
if (a === b)
return 'agrees';
if (isFormatted(a) || isFormatted(b))
return 'incomparable';
if (!(0, normalize_js_1.hasDigit)(a) && !(0, normalize_js_1.hasDigit)(b) && (0, normalize_js_1.caseOnly)(a, b))
return 'agrees';
// The same letters but for case, marks, compatibility forms or the dot of
// an i: a difference the rule above does not accept — never a
// contradiction, never agreement.
const bareA = (0, normalize_js_1.bareLetters)(a);
const bareB = (0, normalize_js_1.bareLetters)(b);
if ((0, normalize_js_1.foldCase)(a) === (0, normalize_js_1.foldCase)(b) || bareA === bareB)
return 'incomparable';
return masked(bareA) === masked(bareB) ? 'incomparable' : 'contradicts';
}
/**
* The exact key of a text: equal keys only where `compareText` agrees. A
* foldable word in lower case or with a capital first letter keys by its
* lower case (`Settled` as `settled`), tagged so it never meets a word kept
* as written (`İstanbul` lowers to an `i` and a combining dot, which as
* written is not a word of letters); every other word, and any text with a
* digit or a format, keys exactly as written.
*/
function textKey(text) {
if (isFormatted(text))
return `f:${JSON.stringify(text)}`;
if ((0, normalize_js_1.hasDigit)(text))
return `t:${JSON.stringify(text)}`;
const words = text
.split(' ')
.map((word) => (0, normalize_js_1.foldableWord)(word) && (0, normalize_js_1.casing)(word) !== 'upper' ? `l:${word.toLowerCase()}` : `w:${word}`);
return `w:${JSON.stringify(words)}`;
}
/**
* The own keys a well-formed Claim of each kind may have, and the ones it must
* (`lib/claim/claim.ts`): `known` its `value` and text `evidence`, `unknown`
* its text `reason` (and text `evidence`, optionally), `not-applicable` its
* text `evidence`. Anything else carrying one of these `kind`s is malformed.
*/
const CLAIM_KEYS = {
known: { may: ['kind', 'value', 'evidence'], must: ['value', 'evidence'] },
unknown: { may: ['kind', 'reason', 'evidence'], must: ['reason'] },
'not-applicable': { may: ['kind', 'evidence'], must: ['evidence'] },
};
/** The doubt word, after the comparison's own normalization (`' Unknown '` is one). */
const DOUBT_WORD = 'unknown';
/**
* One spelling per decimal value: `42.50` and `42.5` share `425e2`. Takes a
* plain decimal, or a JS number's own spelling with its exponent (`1e+21`).
*/
function decimalKey(spelling) {
const [mantissa = '', exponent = '0'] = spelling.split(/e/i);
const negative = mantissa.startsWith('-');
const [whole = '', fraction = ''] = (negative ? mantissa.slice(1) : mantissa).split('.');
const digits = whole + fraction;
const first = digits.search(/[1-9]/);
if (first === -1)
return '0'; // every zero, -0 included
const significant = digits.slice(first).replace(/0+$/, '');
const point = whole.length - first + Number(exponent);
return `${negative ? '-' : ''}${significant}e${point}`;
}
/**
* The Claim a value at the TOP of a claim is, by its shape: `undefined` for
* plain data (no Claim `kind`), else its kind and whether it is well formed.
*/
function claimOf(value) {
if (typeof value !== 'object' || value === null || Array.isArray(value))
return undefined;
const record = value;
const kind = record.kind;
if (kind !== 'known' && kind !== 'unknown' && kind !== 'not-applicable')
return undefined;
const keys = CLAIM_KEYS[kind];
// Every own key is one the kind may have, every key it must have is there,
// and every key but `kind` and `value` holds text — an own `undefined`
// included is not a well-formed Claim (no constructor writes one).
const wellFormed = Object.keys(record).every((key) => keys.may.includes(key) &&
(key === 'kind' || key === 'value' || typeof record[key] === 'string')) && keys.must.every((key) => Object.hasOwn(record, key));
return { kind, wellFormed };
}
/**
* Read a value at the TOP of a claim — the one place the contract puts a
* Claim (a tool's `known(…)` fact, a claimed field) — by its shape. Doubt
* words are recognized after the same normalization the comparison uses, so
* `'Unknown'` and `' UNKNOWN '` are doubt like `'unknown'`. Inside a value
* nothing is read this way: a wrapper there is the value's data.
*/
function claimShapeOf(value) {
const claim = claimOf(value);
if (claim !== undefined) {
if (!claim.wellFormed)
return { kind: 'malformed' };
return claim.kind === 'known'
? { kind: 'value', face: value.value, declared: true }
: { kind: 'doubt', declared: true };
}
if (value === null || value === undefined)
return { kind: 'doubt', declared: false };
if (typeof value === 'string' && (0, normalize_js_1.foldCase)((0, normalize_js_1.textOf)(value)) === DOUBT_WORD) {
return { kind: 'doubt', declared: false };
}
return { kind: 'value', face: value, declared: false };
}
exports.claimShapeOf = claimShapeOf;
/**
* The comparable face of a value at the TOP of a claim: a well-formed
* `known(…)` by its value, plain data as itself. `undefined` — never
* compared — for an `unknown` / `notApplicable` Claim and for anything
* Claim-shaped that is not well formed. A raw doubt (`null`, `'unknown'`) is
* the caller's to classify first (`claimShapeOf`); here it is plain data.
*/
function topFace(value) {
const claim = claimOf(value);
if (claim === undefined)
return { face: value };
return claim.wellFormed && claim.kind === 'known'
? { face: value.value }
: undefined;
}
/** What a value IS, as plain data — no wrapper is read here (see `topFace`). */
function readingOf(value) {
if (value === null)
return { kind: 'null' };
if (typeof value === 'boolean')
return { kind: 'boolean', value };
if (typeof value === 'number') {
return Number.isFinite(value) ? { kind: 'number', value } : { kind: 'other' };
}
if (typeof value === 'string') {
const text = (0, normalize_js_1.textOf)(value);
// A currency, a separator, a date, a time or a dot-grouped number is
// compared as written — before it could read as a plain decimal.
if (isFormatted(text))
return { kind: 'text', text };
if (PLAIN_DECIMAL.test(text)) {
return { kind: 'number', value: Number(text), written: decimalKey(text) };
}
const unit = WITH_UNIT.exec(text);
if (unit?.[1] !== undefined && unit[2] !== undefined) {
return { kind: 'with-unit', written: decimalKey(unit[1]), unit: unit[2].trim() };
}
return { kind: 'text', text };
}
if (Array.isArray(value))
return { kind: 'list', items: value };
if (typeof value === 'object') {
const proto = Object.getPrototypeOf(value);
if (proto === Object.prototype || proto === null) {
return { kind: 'object', value: value };
}
}
return { kind: 'other' };
}
/** A definite difference anywhere wins; else anything not compared; else agreement. */
function combine(results) {
if (results.includes('contradicts'))
return 'contradicts';
return results.includes('incomparable') ? 'incomparable' : 'agrees';
}
/** An object's keys JSON would write (a key holding `undefined` is not one), sorted. */
const keysOf = (value) => Object.keys(value)
.filter((key) => value[key] !== undefined)
.sort();
function compareAt(settled, claimed, depth) {
if (depth > MAX_DEPTH)
return 'incomparable';
const a = readingOf(settled);
const b = readingOf(claimed);
if (a.kind === 'other' || b.kind === 'other')
return 'incomparable';
// An absence against a value is a difference between them, not two kinds.
if (a.kind === 'null' || b.kind === 'null')
return a.kind === b.kind ? 'agrees' : 'contradicts';
if (a.kind === 'number' && b.kind === 'number') {
// Digit for digit when both were written as text; a double is compared
// as the double it is (it cannot say which of two spellings it was).
const same = a.written !== undefined && b.written !== undefined
? a.written === b.written
: a.value === b.value;
return same ? 'agrees' : 'contradicts';
}
if (a.kind === 'with-unit' && b.kind === 'with-unit') {
if (a.unit !== b.unit)
return 'incomparable';
return a.written === b.written ? 'agrees' : 'contradicts';
}
if (a.kind === 'text' && b.kind === 'text')
return compareText(a.text, b.text);
if (a.kind === 'boolean' && b.kind === 'boolean') {
return a.value === b.value ? 'agrees' : 'contradicts';
}
if (a.kind === 'list' && b.kind === 'list') {
if (a.items.length !== b.items.length)
return 'contradicts';
// Every slot, by index: `map` would skip a hole (`[, 1250]`, which
// `structuredClone` keeps), and a skipped slot would count as agreement.
// A hole is no value, so it is never compared.
const results = [];
for (let index = 0; index < a.items.length; index++) {
results.push(Object.hasOwn(a.items, index) && Object.hasOwn(b.items, index)
? compareAt(a.items[index], b.items[index], depth + 1)
: 'incomparable');
}
return combine(results);
}
if (a.kind === 'object' && b.kind === 'object') {
const keys = keysOf(a.value);
const other = keysOf(b.value);
if (keys.length !== other.length || keys.some((key, index) => key !== other[index])) {
return 'contradicts';
}
return combine(keys.map((key) => compareAt(a.value[key], b.value[key], depth + 1)));
}
// A list or an object against a scalar, a unit against a bare number or
// text, text against a number or `true`/`false`: kinds that cannot be
// compared safely.
return 'incomparable';
}
/** The exact key of a plain-data value; `undefined` when it cannot be compared. */
function keyAt(value, depth) {
if (depth > MAX_DEPTH)
return undefined;
const reading = readingOf(value);
switch (reading.kind) {
case 'null':
return 'null';
case 'boolean':
return String(reading.value);
case 'number':
return `n:${reading.written ?? decimalKey(String(reading.value))}`;
case 'with-unit':
return `u:${JSON.stringify([reading.unit, reading.written])}`;
case 'text':
return textKey(reading.text);
case 'list': {
// Every slot, by index: a hole has no key, so neither has its list.
const items = [];
for (let index = 0; index < reading.items.length; index++) {
const inner = Object.hasOwn(reading.items, index)
? keyAt(reading.items[index], depth + 1)
: undefined;
if (inner === undefined)
return undefined;
items.push(inner);
}
return `[${items.join(',')}]`;
}
case 'object': {
const entries = keysOf(reading.value).map((key) => {
const inner = keyAt(reading.value[key], depth + 1);
return inner === undefined ? undefined : `${JSON.stringify(key)}:${inner}`;
});
return entries.includes(undefined) ? undefined : `{${entries.join(',')}}`;
}
case 'other':
return undefined;
}
}
/**
* Compare the value a tool settled with the value an answer states, after
* the declared normalization — the comparison a claim that GOVERNS DELIVERY
* is judged by (`delivered.ts`). The detector above keeps exact equality (its
* findings are pinned); this rule exists so a representational difference
* never withholds a correct answer as a contradiction:
*
* - text is Unicode NFC, trimmed and its white space collapsed. Case agrees
* only where two texts differ in case alone: every word that differs is a
* word of letters, more than three of them, cased all lower, all upper or
* with a capital first letter, with the same letters, letter for letter, in
* another case (`'Settled'` and `'settled '`, `'STRAẞE'` and `'Straße'` —
* `ẞ` is the capital of `ß`). The rule cannot know where case carries
* meaning, so it merges such words too (`'Polish'` and `'polish'`,
* `'AIDS'` and `'aids'`). Any other case difference is not compared — a
* short word or a unit (`'MW'`, `'Co'`), a mixed-case word, anything with a
* digit, `ß` against `ss` in any casing (`'Masse'`, `'maße'`, `'MASSE'`
* against `'Maße'`), a dotted against a dotless i (`'ılık'`). Texts that
* differ in no letter — in such a case difference, in marks (Greek
* capitals drop the accent: `'Αθήνα'`, `'ΑΘΗΝΑ'`), in compatibility forms
* (a full-width letter) — are never agreement, and never a contradiction;
* - a value written in a format — a currency symbol, digit separators, a
* dot-grouped number (`'1.250'`: 1.25 or 1250), a date or a time — agrees
* only as written and is otherwise not compared, and so is text that
* differs only in how a number inside it is spelled (`'USD 42.50'`);
* - a number and a string written as a plain decimal (`-?digits[.digits]`, no
* leading zero, sign or exponent) compare by value, both ways: two decimal
* strings digit for digit (`'42.50'` = `'42.5'`), a JS number at the double
* precision it has (`'42.50'` = `42.5`);
* - a plain decimal with a unit (`'1250 ms'`) compares only with the same
* unit, written exactly — case included (`MW` is not `mW`);
* - lists compare item by item in order, objects key by key, and a definite
* difference anywhere wins over a part that could not be compared; a hole
* in a list (`[, 1250]`, which `structuredClone` keeps) is no value and is
* never compared;
* - `null` against a value is a difference (a raw doubt is the caller's to
* classify first; only a DECLARED `known(null)` should reach here);
* - a Claim wrapper is read only at the TOP of either value, and only when it
* is a well-formed `known(…)` (`kind`, `value`, a text `evidence`). Inside a
* value a wrapper-shaped object is that value's data, compared as an
* object: an answer cannot have a nested object read as the value it wraps.
*
* Anything else — a list or an object against a scalar, a unit against a bare
* number or another unit, text against a number or `true`/`false`, a value
* that is not JSON, an unknown `Claim` or a malformed wrapper at the top,
* nesting past 64 levels — is `incomparable`: never "contradicts", never
* "agrees". Pure and total.
*/
function compareClaimValues(settled, claimed) {
const a = topFace(settled);
const b = topFace(claimed);
if (a === undefined || b === undefined)
return 'incomparable';
return compareAt(a.face, b.face, 0);
}
exports.compareClaimValues = compareClaimValues;
/**
* One EXACT key per value under the same normalization, for a reader of
* several values at once (`delivered.ts` asks whether a run read one fact two
* ways). Equal keys always compare `agrees`: two values share a key only when
* they are the same value under the comparison's exact parts — text up to
* the case rule (a word keyed by its lower case never meets a word kept as
* written), a number by its digits — so `'9007199254740993'` and the double
* `9007199254740992`, which `compareClaimValues` cannot tell apart, have
* different keys. The converse does not hold: a key is stricter (`'SETTLED'`
* and `'settled'` agree under two keys), and key equality is transitive where
* agreement at double precision is not. `undefined` for a value that cannot
* be compared at all, a list with a hole included.
*/
function claimValueKey(value) {
const top = topFace(value);
return top === undefined ? undefined : keyAt(top.face, 0);
}
exports.claimValueKey = claimValueKey;
//# sourceMappingURL=evaluate.js.map