agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
382 lines • 19.9 kB
JavaScript
;
/**
* arguments/checks — the declared-sources checks: where the model SAID a value
* came from, checked against the one place that source lives.
*
* Pattern: Walker, pure. `checkSource` is a function of one argument's value,
* its rule, the model's claim about it (`_findings.from`, read by
* `sources.ts` · `readSources`) and the corpora the layer is handed
* (`SourceCorpus`). The same inputs always give the same verdict; no
* clock, no model, no scope.
* Role: core/ layer, the inputs layer (honesty layer 2), step 5. Loaded with
* the layer's stage bodies (`resolve.ts` imports it; both through
* `import()`), never on a plain agent's graph. Imports nothing from
* `findings/` (the one-way law): the mount builds the corpus and hands
* it in.
* Emits: N/A.
*
* ## One claim, one corpus — and a pass is never support
*
* Every check is whole-token membership (`lib/normalize.ts` · `tokenize`
* and `canonicalForm`, the evidence gate's own "same value", through
* `evidence/resultCarries.ts` · `canonicalTokens`) in the ONE place the
* claimed source lives:
*
* | Claim | Checked against | Verified as |
* |-----------|-----------------------------------------------------------------------|------------------------------------|
* | `user` | the quote in the person's own messages; the value in the quote, or a phrase the author declared for it | `said` (`matched: 'quote' \| 'phrase'`), or a READING |
* | `result` | the named result's own bytes (`evidence/resultCarries.ts` · `resultReader`, read once per batch) | `result` (`setAside` when the model set it aside) |
* | `turn` | the person's earlier answers to the library's ask (the ledger's `answered` rows) | `answered` (`matched: 'spelling'` across `24h` ↔ `-24h`) |
* | `app` | the app's own text (system messages, the composed system prompt, `externalGrounds`) | `app` (`appSource` = the ground's label) |
* | `assumed`, nothing | nothing — the model's own | `model` |
*
* A failed check is FILED as written (`failed`), never repaired and never
* retried against another corpus: a false claim is itself a fact. For a failed
* claim and for the model's own value, the library still looks the value up
* (`coincides`) — a HINT for the lens and the bench, never a source, never
* support, never enough to skip an ask.
*
* Membership can REFUTE a claim ("those words are not in any message you
* sent"); a pass proves nothing — the words can be there by coincidence, in a
* negation, in another sentence. So no verdict here ever supports "known": a
* pass only keeps a reason from firing (`assessment/assess.ts` ·
* `readArgumentVerdicts`).
*
* ## The declared default beats the app and a result (V1)
*
* A value equal to the argument's own `assume` value that the checks did not
* verify as the PERSON's (their quoted words, or their answer) is filed
* `default`, the claim kept as written — a model that copies a default from a
* description chose nothing, and a default must never earn more standing
* through `app` or `result` than the same default declared as `assume`.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.checkSource = exports.isTraced = void 0;
const placement_js_1 = require("../../../artifacts/placement.js");
const evidenceIndex_js_1 = require("../evidence/evidenceIndex.js");
const resultCarries_js_1 = require("../evidence/resultCarries.js");
const servedJson_js_1 = require("../evidence/servedJson.js");
const declare_js_1 = require("../../../tools/declare.js");
const rows_js_1 = require("./rows.js");
/**
* Whether a verdict traces the value to a source the record holds — the
* person's words (`said` via the quote or a declared phrase), the person's
* answer, a result, the app. A READING (`said` + `reading`), the model's own
* value, a hint and a failed claim do not. Under an `ask` rule with declared
* sources armed, a value this says no to is asked about (`resolve.ts`).
*/
function isTraced(check) {
if (check.source === 'said')
return check.reading !== true;
return check.source === 'answered' || check.source === 'result' || check.source === 'app';
}
exports.isTraced = isTraced;
/** Whether a verdict makes the value the PERSON's — their quoted words or their answer. */
function isPersons(check) {
return check.source === 'answered' || (check.source === 'said' && check.reading !== true);
}
// ─── Membership ─────────────────────────────────────────────────────────
const isPrimitive = (value) => typeof value === 'string' ||
typeof value === 'boolean' ||
(typeof value === 'number' && Number.isFinite(value));
/** Per corpus, each haystack's canonical tokens — computed once per text. */
const tokenCache = new WeakMap();
/** Per corpus, each served result read ONCE (`evidence/resultCarries.ts` · `resultReader`). */
const readerCache = new WeakMap();
/** Whether one served result carries a value — its bytes read once per corpus, however many values ask. */
function carries(corpus, result, value) {
let cache = readerCache.get(corpus);
if (cache === undefined) {
cache = new Map();
readerCache.set(corpus, cache);
}
let reader = cache.get(result);
if (reader === undefined) {
reader = (0, resultCarries_js_1.resultReader)(result.text);
cache.set(result, reader);
}
return reader(value);
}
const hasText = (r) => r.text !== undefined;
function haystackOf(corpus, text) {
let cache = tokenCache.get(corpus);
if (cache === undefined) {
cache = new Map();
tokenCache.set(corpus, cache);
}
const hit = cache.get(text);
if (hit !== undefined)
return hit;
const tokens = (0, resultCarries_js_1.canonicalTokens)(text);
cache.set(text, tokens);
return tokens;
}
/** `needle` in one text, read to the evidence index's ceiling: past it, never "not found". */
function contains(corpus, text, needle) {
const hay = haystackOf(corpus, text);
if (hay.length <= evidenceIndex_js_1.MAX_INDEX_TOKENS)
return (0, resultCarries_js_1.occursIn)(needle, hay) ? 'found' : 'not-found';
return (0, resultCarries_js_1.occursIn)(needle, hay.slice(0, evidenceIndex_js_1.MAX_INDEX_TOKENS)) ? 'found' : 'uncheckable';
}
/** The same value, by the evidence module's rule: equal canonical token sequences. */
function sameTokens(a, b) {
return a.length > 0 && a.length === b.length && a.every((t, i) => t === b[i]);
}
/** A row value the clip cut (`integrity/argumentLeaves.ts` · `clipValue`) — nothing to compare. */
const isClipped = (value) => value.length >= 80 && value.endsWith('…');
/**
* A placement ticket — the model was served the ticket, not the value. Read
* as the result's LEADING JSON value (`evidence/servedJson.ts`), so a ticket a
* framework note follows is still a ticket.
*/
function isPlaced(text) {
const trimmed = text.trimStart();
if (!trimmed.startsWith('{'))
return false;
const [first] = (0, servedJson_js_1.leadingJsonValues)(trimmed).values;
return first !== undefined && (0, placement_js_1.isPlacedToolResult)(first);
}
// ─── The hint ───────────────────────────────────────────────────────────
/**
* The library's OWN lookup of a value — the person's words (never a composed
* run's own message), then the results, then the app's text. A hit is a hint
* (`coincides`): never a source, never support, never enough to skip an ask.
*/
function hintOf(corpus, value) {
const needle = (0, resultCarries_js_1.canonicalTokens)(String(value));
if (needle.length === 0)
return undefined;
if (corpus.person.some((p) => p.composed !== true && contains(corpus, p.text, needle) === 'found')) {
return 'person';
}
if (corpus.results.some((r) => hasText(r) && carries(corpus, r, value) === 'found')) {
return 'result';
}
if (corpus.app.some((a) => contains(corpus, a.text, needle) === 'found'))
return 'app';
return undefined;
}
/** A failed claim: filed as written, the hint still computed. */
function failedWith(corpus, value, failed) {
const coincides = isPrimitive(value) ? hintOf(corpus, value) : undefined;
return { source: 'model', failed, ...(coincides !== undefined && { coincides }) };
}
// ─── V2 · `user` + quote ────────────────────────────────────────────────
/** A phrase the author declared for the value's OWN choice, inside the quote. */
function phraseInQuote(subject, quote) {
const own = subject.rule?.ask?.phrases?.find((p) => (0, declare_js_1.sameArgumentValue)(p.value, subject.value));
if (own === undefined)
return false;
return own.said.some((phrase) => (0, resultCarries_js_1.occursIn)((0, resultCarries_js_1.canonicalTokens)(phrase), quote));
}
function checkQuote(subject, quoteText, value, corpus) {
const quote = (0, resultCarries_js_1.canonicalTokens)(quoteText);
if (quote.length === 0)
return failedWith(corpus, value, 'uncheckable');
const matches = [];
let unreadable = false;
for (const p of corpus.person) {
const found = contains(corpus, p.text, quote);
if (found === 'found')
matches.push(p);
else if (found === 'uncheckable')
unreadable = true;
}
if (matches.length === 0) {
return failedWith(corpus, value, unreadable ? 'uncheckable' : 'quote-not-found');
}
// In a composed run, the run's own message is another runner's words.
const persons = matches.filter((p) => p.composed !== true);
if (persons.length === 0)
return failedWith(corpus, value, 'composed-message');
const earlier = persons.every((p) => p.earlier === true);
const valueTokens = (0, resultCarries_js_1.canonicalTokens)(String(value));
if (valueTokens.length === 0)
return failedWith(corpus, value, 'uncheckable');
const at = earlier ? { earlier: true } : {};
if ((0, resultCarries_js_1.occursIn)(valueTokens, quote))
return { source: 'said', matched: 'quote', ...at };
if (phraseInQuote(subject, quote))
return { source: 'said', matched: 'phrase', ...at };
// The words are the person's; the value is the model's READING of them.
return { source: 'said', reading: true, ...at };
}
// ─── V3 · `result` + id ─────────────────────────────────────────────────
function checkResult(subject, id, value, corpus) {
const served = corpus.results.find((r) => r.toolCallId === id);
if (served === undefined)
return failedWith(corpus, value, 'unknown-result');
if (!hasText(served))
return failedWith(corpus, value, 'uncheckable');
if (isPlaced(served.text))
return failedWith(corpus, value, 'placed-result');
const carried = carries(corpus, served, value);
if (carried === 'uncheckable')
return failedWith(corpus, value, 'uncheckable');
if (carried === 'not-found')
return failedWith(corpus, value, 'not-in-result');
const setAside = served.standing === 'open' || served.standing === 'noise' || served.standing === 'ruled-out'
? served.standing
: undefined;
const grounds = subject.argumentsFrom;
return {
source: 'result',
...(served.earlier === true && { earlier: true }),
result: served.toolCallId,
...(setAside !== undefined && { setAside }),
...(grounds !== undefined && {
argumentsFrom: served.toolName !== undefined && grounds.includes(served.toolName)
? 'listed'
: 'unlisted',
}),
};
}
// ─── V4 · `turn` ────────────────────────────────────────────────────────
/**
* The earlier answers a `turn` claim may resolve to: the same argument OF THE
* SAME TOOL (another tool's `limit` is not this one's), or — both period
* arguments — a period answered for any tool (a period is a period, in the
* spelling each tool declares).
*/
function answersFor(subject, corpus) {
const period = subject.rule?.period === true;
return corpus.answers.filter((a) => (a.toolName === subject.toolName && a.argument === subject.argument) ||
(period && a.period === true));
}
function checkTurn(subject, value, corpus) {
const valueTokens = (0, resultCarries_js_1.canonicalTokens)(String(value));
if (valueTokens.length === 0)
return failedWith(corpus, value, 'uncheckable');
const candidates = answersFor(subject, corpus);
const readable = candidates.filter((a) => a.value !== rows_js_1.HIDDEN_VALUE && !isClipped(a.value));
const answered = (a, matched) => ({
source: 'answered',
...(matched !== undefined && { matched }),
...(a.turn < corpus.turn && { earlier: true }),
});
const same = readable.find((a) => sameTokens((0, resultCarries_js_1.canonicalTokens)(a.value), valueTokens));
if (same !== undefined)
return answered(same);
// The one conversion the library makes between declared spellings (`24h` ↔ `-24h`).
if (subject.rule?.period === true && subject.spelling !== undefined) {
const converted = readable.find((a) => {
if (a.period !== true || a.spelling === undefined)
return false;
const as = (0, declare_js_1.convertSpelling)(a.value, a.spelling, subject.spelling);
return as !== undefined && sameTokens((0, resultCarries_js_1.canonicalTokens)(String(as)), valueTokens);
});
if (converted !== undefined)
return answered(converted, 'spelling');
}
// An answer the record cannot read back (the tool's view hides it, or the clip cut it).
if (readable.length < candidates.length)
return failedWith(corpus, value, 'uncheckable');
if (corpus.answers.length === 0 && corpus.turn <= 1) {
return failedWith(corpus, value, 'no-earlier-turn');
}
// Found in earlier words, results or the app's text: a HINT — to be verified, the model
// quotes the words (`user`) or names the result (`result`).
const hint = earlierHint(corpus, value, valueTokens);
if (hint !== undefined)
return hint;
if (corpus.assistant.some((text) => contains(corpus, text, valueTokens) === 'found')) {
return failedWith(corpus, value, 'only-in-model-answer');
}
return failedWith(corpus, value, 'not-in-earlier-turns');
}
/** The value in an earlier turn's person words or results, or the app's text — a hint. */
function earlierHint(corpus, value, needle) {
const person = corpus.person.some((p) => p.earlier === true && p.composed !== true && contains(corpus, p.text, needle) === 'found');
if (person)
return { source: 'model', earlier: true, coincides: 'person' };
const result = corpus.results.some((r) => r.earlier === true && hasText(r) && carries(corpus, r, value) === 'found');
if (result)
return { source: 'model', earlier: true, coincides: 'result' };
if (corpus.app.some((a) => contains(corpus, a.text, needle) === 'found')) {
return { source: 'model', coincides: 'app' };
}
return undefined;
}
// ─── V5 · `app` ─────────────────────────────────────────────────────────
function checkApp(value, corpus) {
const needle = (0, resultCarries_js_1.canonicalTokens)(String(value));
if (needle.length === 0)
return failedWith(corpus, value, 'uncheckable');
let unreadable = false;
// A labelled ground first, so the row can name the source the app vouched for.
const ordered = [
...corpus.app.filter((a) => a.label !== undefined),
...corpus.app.filter((a) => a.label === undefined),
];
for (const piece of ordered) {
const found = contains(corpus, piece.text, needle);
if (found === 'found') {
return { source: 'app', ...(piece.label !== undefined && { appSource: piece.label }) };
}
if (found === 'uncheckable')
unreadable = true;
}
return failedWith(corpus, value, unreadable ? 'uncheckable' : 'not-in-app-text');
}
// ─── The check ──────────────────────────────────────────────────────────
/** V2–V6: the claim, checked against its one corpus. */
function judgeClaim(subject, corpus) {
const { value, claim } = subject;
if (!isPrimitive(value)) {
// Nested and non-primitive values are not ruled in this version.
return claim === undefined ? { source: 'model' } : { source: 'model', failed: 'uncheckable' };
}
switch (claim?.source) {
case 'user':
return checkQuote(subject, claim.quote ?? '', value, corpus);
case 'result':
return checkResult(subject, claim.id ?? '', value, corpus);
case 'turn':
return checkTurn(subject, value, corpus);
case 'app':
return checkApp(value, corpus);
default: {
// V6 — `assumed`, or nothing declared: the model's own value. The lookup is a hint.
const coincides = hintOf(corpus, value);
return { source: 'model', ...(coincides !== undefined && { coincides }) };
}
}
}
// FOLD · the one owner of the declared-sources verdict on one argument value
// consumers read this and never re-derive it: arguments/resolve.ts · verifyPlan (the layer's VERIFY
// stage), whose rows and resolutions carry it; the standing fold reads the rows, never re-checks.
// detached: yes — a verdict of enums, flags, one id and one label; the value is never kept.
/**
* Where one argument value came from, as the record can check it — the
* model's claim (`subject.claim`) checked against the one corpus that source
* lives in, then the declared default applied last (V1). The value is read in
* memory and never kept: the verdict holds enums, flags, a result id and an
* app label only.
*
* @example
* ```ts
* checkSource(
* { toolName: 'search_logs', argument: 'window', value: '7d',
* claim: { argument: 'window', source: 'user', quote: 'over the last week' },
* rule: windowRule }, // choices: [{ value: '7d', said: ['last week'] }, …]
* corpus, // person: [{ text: 'errors on checkout over the last week?' }]
* );
* // { source: 'said', claimed: 'user', matched: 'phrase' }
* ```
*/
function checkSource(subject, corpus) {
const claimed = subject.claim?.source ?? 'none';
const judged = judgeClaim(subject, corpus);
const assume = subject.rule?.assume;
if (assume !== undefined && !isPersons(judged) && (0, declare_js_1.sameArgumentValue)(subject.value, assume)) {
// V1: a default nobody chose stays a default, whatever the model claimed for it.
return {
source: 'default',
claimed,
...(judged.failed !== undefined && { failed: judged.failed }),
};
}
return { ...judged, claimed };
}
exports.checkSource = checkSource;
//# sourceMappingURL=checks.js.map