UNPKG

agentfootprint

Version:

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

253 lines • 14.1 kB
/** Runtime admission and delivery for typed negative citations. The pure checker * compares reads; this boundary additionally binds every citation to this app's * admitted dispatches and trusted caller context. Model text is never evidence. */ import { readResultIdentity } from '../../../integrity/claim-binding/capture.js'; import { MAX_NEGATIVE_CLAIMS } from '../../../integrity/negative-claim/limits.js'; import { judgeTypedNegatives } from '../../../integrity/negative-claim/judge.js'; import { derivedSubjects, populationsMayOverlap, sameRecordedSubject, } from '../../../integrity/negative-claim/derive.js'; import { sameValue, scopeOf, returnedReading } from '../../../integrity/negative-claim/readings.js'; import { composeRecordedStatement, STATEMENT_VERSION, } from '../../../integrity/negative-claim/statement.js'; import { copyJson } from '../../../lib/boundedJson.js'; const HANDLE = /^r[1-9][0-9]{0,3}$/; const own = (v) => typeof v === 'object' && v !== null && !Array.isArray(v); /** Only an explicit field on every observed row proves a peer outside scope. * An unknown row shape, missing field or unscoped reading is conservative. */ function rowsOutside(other, cited) { const rows = other.rowsRead; if (!rows?.length || rows.length < (other.rows ?? rows.length)) return false; const filters = (scopeOf(cited) ?? []).filter((item) => item.facet === 'filter' && item.from !== 'host' && item.any !== true && item.argument !== undefined && item.value !== undefined); return rows.every((row) => filters.some((item) => Object.hasOwn(row, item.argument) && !sameValue(row[item.argument], item.value))); } /** Fixed explanations never borrow scope or instructions from the model's citation. */ const REFUSALS = { 'contradicted-by-rows': "I don't know that this claim holds: recorded rows contradict it.", 'wider-search-has-rows': "I don't know. A wider search returned rows that may be within this claim's scope.", 'other-read-has-rows': "I don't know. Another read for this subject returned rows that have not been shown to lie outside this claim's scope.", 'search-incomplete': "I don't know. The cited result does not cover the complete search required by this claim.", 'superseded-by-write': "I don't know. After or in the same batch as this read, a call that may change the data was dispatched; the earlier result no longer establishes this claim.", 'later-read-failed': "I don't know. A later attempt to repeat this search failed; the earlier result does not settle the current claim.", 'citation-unresolved': "I don't know. The citation does not identify an admitted recorded search.", 'cited-call-undeclared': "I don't know. The cited call is not declared as an eligible source for this claim.", 'coverage-unverified': "I don't know. The cited coverage was not composed by this runtime from a trusted local search declaration.", 'call-not-read': "I don't know. The cited call did not establish a read that can support this claim.", 'undeclared-empty': "I don't know. The recorded result was an empty list without a declaration of absence or coverage.", 'evidence-kind-undetermined': "I don't know. The cited reads do not establish one supported kind of scoped fact.", 'kind-mismatch': "I don't know. This kind of negative claim does not match what the cited search established.", 'about-mismatch': "I don't know. The claim's subject does not include the population the admitted search actually covered.", 'record-incomplete': "I don't know. The available execution record is incomplete, so it cannot establish this claim.", 'stale-turn': "I don't know. The cited result belongs to an earlier turn and was not re-read during this turn.", 'too-many-claims': "I don't know. The claim or reference count exceeds the checking limit; no checked prefix is accepted.", 'outside-trusted-context': "I don't know. The cited search is not bound to the caller's trusted context.", }; function refusalFor(row) { if (row.code === 'established') return ''; // Retained stale identities carry runtime-produced turn/date metadata even // when their scope was scrubbed. This says when the read ran, not what it read. if (row.code !== 'stale-turn' || row.staleSince === undefined) return REFUSALS[row.code]; const readAt = row.staleSince.readAt; const date = readAt === undefined ? NaN : Date.parse(readAt); return (`I don't know. The cited result was read on turn ${row.staleSince.turn}` + (Number.isFinite(date) ? ` at ${new Date(date).toISOString()}` : '') + '; not re-read during the current turn.'); } const positivePeer = (other, cited) => other.resultId !== cited.resultId && returnedReading(other) && other.emptiness === 'non-empty' && sameRecordedSubject(other, cited) && populationsMayOverlap(other, cited) && !rowsOutside(other, cited); export function renderNegativeCandidate(candidate, config, record, trusted, interpretation = 'evidence-owned') { const source = copyJson(candidate, 1024 * 1024, false); if (!own(source)) throw new Error('A negative-claims answer must be a JSON object.'); const entries = source[config.field]; const validList = Array.isArray(entries) && entries.length <= MAX_NEGATIVE_CLAIMS; // Bound before the pure checker maps entries; malformed/missing is a real // unreachable check, whereas an explicit empty array makes no assertion. const rawEntries = validList ? entries : [undefined]; const judgments = judgeTypedNegatives({ field: config.field, negatives: rawEntries, record, citable: config.tools, interpretation, }); const checks = []; const claims = []; let correction = false; for (let i = 0; i < judgments.length; i++) { const raw = rawEntries[i]; const entry = own(raw) ? raw : {}; const refs = Array.isArray(entry.restsOn) ? entry.restsOn : []; const about = entry.about; const validAbout = (typeof about === 'string' && about.trim().length > 0 && about.length <= 256) || (typeof about === 'number' && Number.isFinite(about)); const validShape = validList && own(raw) && (entry.about === undefined || validAbout) && refs.length > 0 && refs.length <= 16 && refs.every((ref) => typeof ref === 'string' && HANDLE.test(ref)) && Object.keys(entry).every((key) => ['kind', 'about', 'restsOn'].includes(key)); const validKind = typeof entry.kind === 'string' && ['none', 'not-found', 'complete'].includes(entry.kind); const reads = refs.flatMap((ref) => record.calls.filter((call) => call.row?.handle === ref)); let row = judgments[i].row; const fail = (code, disposition = 'unreachable') => { row = { ...row, code, disposition }; }; if (!validList) fail(Array.isArray(entries) && entries.length > MAX_NEGATIVE_CLAIMS ? 'too-many-claims' : 'citation-unresolved'); else if (!validShape) fail('citation-unresolved'); else if (!validKind) fail('kind-mismatch'); else if (row.code === 'established') { if (reads.length === 0 || !reads.every(trusted)) fail('outside-trusted-context'); else if (record.calls.some((other) => other.turn === record.turn && reads.some((cited) => positivePeer(other, cited)))) fail('other-read-has-rows', 'checked-fail'); } // Even a failing citation must not use another caller's scope in a fallback. const canDescribe = validShape && reads.length > 0 && reads.every((call) => trusted(call) && call.turn <= record.turn && config.tools.includes(call.toolName) && call.row?.composed === 'local' && call.keptOut !== true); const established = row.code === 'established'; const contrary = !established && canDescribe ? record.calls.filter((other) => other.turn === record.turn && trusted(other) && other.effect === 'read' && scopeOf(other) !== undefined && (other.row === undefined || other.row.composed === 'local') && other.keptOut !== true && config.tools.includes(other.toolName) && !reads.some((read) => read.resultId === other.resultId) && reads.some((cited) => positivePeer(other, cited))) : []; if (contrary.length > 0) row = { ...row, results: [...new Set([...row.results, ...contrary.map((call) => call.resultId)])], }; const refusal = refusalFor(row); let statement = established ? judgments[i].statement?.text : canDescribe ? composeRecordedStatement(reads.flatMap((call) => call.row === undefined ? [] : [{ call, row: call.row, tool: record.tools[call.toolName] }]), record.clock, { refusal, contraryReads: contrary.map((call) => ({ call, row: call.row, tool: record.tools[call.toolName], })), }).text : refusal; // A partial absence cannot satisfy a complete-list request. Only describe // that narrower fact as established if it independently passes every guard. if (entry.kind === 'complete' && row.code === 'search-incomplete' && canDescribe && reads.every((call) => call.emptiness === 'declared-absent')) { const absence = renderNegativeCandidate({ [config.field]: [{ ...entry, kind: 'none' }] }, config, record, trusted, interpretation); if (absence.answer.checks[0]?.code === 'established') { statement = 'The requested complete-list claim is not established; only this absence is. ' + absence.answer.claims[0].statement; } } if (row.kind !== 'unknown' && (entry.kind === 'none' || entry.kind === 'not-found') && (row.kind === 'none' || row.kind === 'not-found') && entry.kind !== row.kind) row = { ...row, notes: ['absence-kind-normalized'] }; checks.push(row); correction ||= row.code === 'citation-unresolved' || row.code === 'stale-turn'; const subjects = canDescribe || established ? derivedSubjects(reads) : []; claims.push({ kind: row.kind, ...(subjects.length === 1 ? { about: subjects[0] } : {}), restsOn: canDescribe || established ? [...reads, ...contrary].map((call) => call.row?.handle ?? `r${readResultIdentity(call.resultId).ordinal}`) : [], statement: statement ?? "I don't know. No admitted search supports this claim.", delivery: 'record-composed', version: STATEMENT_VERSION, }); } const established = checks.every((row) => row.code === 'established'); const allowed = established || config.unestablished === 'state-searched'; const examined = new Set(checks.flatMap((row) => row.results)); const coverage = config.tools.map((tool) => ({ tool, checked: record.calls.filter((call) => examined.has(call.resultId) && call.toolName === tool && call.turn === record.turn && returnedReading(call) && (call.row?.composed === 'local' || (call.row === undefined && call.effect === 'read' && call.scope !== undefined)) && call.keptOut !== true && trusted(call)).length, })); const answer = { field: config.field, version: STATEMENT_VERSION, delivery: allowed ? established && checks.length > 0 && Object.keys(source).every((key) => key === config.field) ? 'as-checked' : 'recorded' : 'fallback', claims, checks, coverage, actions: record.calls .filter((call) => call.turn === record.turn && call.dispatched && call.effect !== 'read') .map((call) => ({ tool: call.toolName, outcome: call.outcome })), }; return { candidate: { ...source, [config.field]: claims }, answer, correction, allowed }; } /** * Merge negative verdicts without manufacturing a successful host comparison. * * THE ONE PROJECTION of a negative row onto the report: its code becomes the * check's `reason`, and the tools it names stay `tools` — the answer's fold * reads both off this row (`assessment/assess.ts` · `readAnswerRows`, * `assessment/sources.ts` · `callsOfCheck`), so a field it reads is never * left behind here. A row that names no tool carries no `tools`. */ export function reportWithNegatives(report, answer, allowed) { const rows = answer.checks.map((row) => ({ id: row.id, path: row.path, disposition: row.disposition, reason: row.code, ...(row.tools.length > 0 && { tools: [...row.tools] }), })); const checks = [...report.checks, ...rows]; const failed = checks.filter((row) => row.disposition === 'checked-fail').length; const unreachable = checks.filter((row) => row.disposition === 'unreachable').length; return { ...report, checks, failed, unreachable, checked: checks.filter((row) => row.disposition === 'checked-pass' || row.disposition === 'checked-fail').length, notApplicable: checks.filter((row) => row.disposition === 'not-applicable').length, status: failed > 0 ? 'failed' : unreachable > 0 ? 'unverified' : report.status, delivery: allowed ? answer.delivery : 'fallback', }; } //# sourceMappingURL=delivery.js.map