agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
604 lines • 29.4 kB
JavaScript
"use strict";
/**
* evidenceIndex — what the run can PROVE it read, as a lookup set.
*
* Pattern: build once per judgement, ask many times (the same "one pass, then
* query" shape the slice layer uses).
* Role: core/ layer. The other half of `namesAndNumbersFromEvidence`.
* Emits: N/A.
*
* ## Why the walk is STRUCTURAL
*
* The obvious implementation is `allToolResults.includes(value)`. It is wrong
* in the direction that matters: a fabricated FCID `0xef0101` "appears" in a
* blob that contains `0xef01011` or `naa.0xef0101ab`, so the invented value
* reads as grounded and the check quietly passes everything. So a tool result
* that parses as JSON is WALKED — every key, every leaf — and each leaf is
* indexed both whole and tokenized; so is the JSON a result OPENS with when a
* framework note follows it (`readResult`, over `servedJson.ts`). A result
* that is not JSON is tokenized as text. Either way the comparison is
* token-exact, never substring. Under `decimalRounding` a whole token of
* text in scientific notation also files the exact decimal it states
* (`1.12e-02` → `0.0112`, {@link addDecimalOf}); the spelling stays.
*
* ## What counts as evidence, and what deliberately does not
*
* • **Tool results** — the `role: 'tool'` turns in `scope.history` AS IT
* STANDS AT JUDGEMENT. A value the model read from a tool is grounded.
*
* Say that precisely, because "the whole conversation" is only true for an
* agent with no window strategy. `.window()` / `.compaction()` /
* `tokenBudget` REWRITE `scope.history` in place (window.ts, `scope.history
* = result.window`), so on those agents this corpus is the LIVE WINDOW and
* a result the window has dropped is not in it — the same value then reads
* as ungrounded. `keepLastToolResults` (default 2) is what keeps the most
* recent observations pinned; nothing pins the rest. Anything computed from
* this index therefore describes what the run can still SEE, never
* everything it ever read.
* • **Object KEYS count.** A map keyed by WWN puts real identifiers in key
* position, and the model saw them exactly as it saw the values.
* • **Tool call ARGUMENTS do not count.** The model typed those. Grounding a
* value because the model passed it to a tool would let any invention
* launder itself through one failed lookup.
* • **The model's own earlier answers do not count**, for the same reason.
* • **An `absent(…)` result grounds everything EXCEPT `looked_for`.** Its
* coverage lists, its `try_instead`, its note and any extra key the tool
* attached (a `known_shares` list, say) are the tool's own words about the
* world, and an answer that follows the absence's advice must not be
* called ungrounded for doing so. `looked_for` alone quotes the arguments
* the model passed, so indexing it would ground an invented identifier
* through the one operation that proves nothing about it: a lookup that
* found nothing. See `../../../semantics/coverage/evidence.ts`.
*
* The EXEMPT index is built from a different corpus with the same machinery:
* the user's own message, the conversation's user/system turns, the values
* the person typed into a typed ask (`requestInput` — its `input_received`
* result is a `role: 'tool'` message carrying THEIR words), and the
* system-prompt content this turn was built from — each text with the other
* spellings of the dates and clock times in it (`time/forms.ts` ·
* `timeFormsOf`'s `said` list: `8 Am` is also `8:00`, `2026-10-09` also
* `2026`) — and, under `.time()`, the said spellings of the turn's recorded
* windows (`timeSaid`). A compaction summary is a
* user-role turn a MODEL wrote, so its text is not in it — the summary
* carries the forms its folded person/app turns exempted instead
* (`LLMMessage.foldedExempt`, {@link exemptLineageOf}). A value the user supplied is
* not a fabrication — the user gave it — and neither is one the app's own
* prompt or skill body put in front of the model.
*
* ## WHEN a value was read, and why the corpus is still conversation-wide
*
* The corpus above answers "did the run read this?" and says nothing about
* WHEN. That gap has a measured cost: an agent answered a fresh question with
* ZERO tool calls, and the gate approved it because all seven values appeared
* in an inventory result fetched four turns earlier for a different question.
* Grounded, and four turns out of date.
*
* The fix is NOT to narrow the corpus to this turn. "And what about that
* disk?" legitimately leans on the previous turn's rows, and flagging those
* would make the gate cry wolf until somebody switched it off. So the corpus
* stays whole and every indexed form carries the TURN it was last served in
* — one number, stamped during the walk that was happening anyway.
*
* A TURN starts at each `role: 'user'` message the library did NOT author.
* `isLibraryAuthoredTurn` (frames.ts) is a statement about AUTHORSHIP, not
* about time — it exists to keep the exempt corpus from swallowing the gate's
* own corrections — and it is reused here for a different reason that happens
* to need the same predicate: a frame the library wrote is not a person asking
* something new. Excluding them is load-bearing rather than tidy. The evidence
* recheck appends a `role: 'user'` turn MID-RUN, so counting it as a boundary
* would push this turn's own tool results into the "earlier" bucket and file a
* notice against every revised answer.
*
* Turn 1 is the first user message still in the window; anything indexed
* before one (a history with no user turn at all) is turn 0, which is also
* `currentTurn`, so nothing can read as earlier and no notice can be filed
* from a conversation with no boundary in it.
*
* **The ordinals are WINDOW-RELATIVE**, and that is the honest bound: they
* count the user turns the run can still see, so under a window strategy
* "turn 2 of 4" may be the conversation's turn 9 of 13. The BOUNDARY is
* exact regardless — the current request is un-droppable by every window
* strategy (`'current-request'`, window/currentRequest.ts) — so "this turn
* versus earlier" holds; only the DISTANCE is a floor.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.derivedFormsOf = exports.exemptFromRun = exports.exemptLineageOf = exports.evidenceFromHistory = exports.readResult = exports.MAX_CARRIERS = exports.MAX_INDEX_TOKENS = void 0;
const toolBytes_js_1 = require("../../../lib/toolBytes.js");
const index_js_1 = require("../../../semantics/coverage/index.js");
const toolResultCap_js_1 = require("../toolResultCap.js");
const saidByPerson_js_1 = require("../../../lib/saidByPerson.js");
const frames_js_1 = require("./frames.js");
const textForms_js_1 = require("../../../time/textForms.js");
const normalize_js_1 = require("../../../lib/normalize.js");
const numberPrecision_js_1 = require("./numberPrecision.js");
const servedJson_js_1 = require("./servedJson.js");
/**
* Ceiling on indexed tokens. Generous — a 200 000-token corpus is roughly a
* 5 MB tool result — because the cost of hitting it is not "slower", it is
* "the gate stops accusing" (see {@link EvidenceCorpus.truncated}). The
* declared-sources check reads a haystack to the same ceiling and says
* `uncheckable` past it (`evidence/resultCarries.ts` · `resultReader`).
*/
exports.MAX_INDEX_TOKENS = 200_000;
/**
* How many results one indexed form remembers as its CARRIERS (9.110.0)
* before the list is cut and marked. Eight, because the question the list
* answers — "did EVERY result that carried this value get judged non-fact?"
* (`findings/contingent.ts`) — needs the whole list or none of it: a value
* nine results carried is a common value, not a tower, and the honest answer
* for it is "not judged" rather than a verdict read off a prefix.
*/
exports.MAX_CARRIERS = 8;
/**
* File the result being walked as a carrier of `form` — once per result,
* in wire order, cut and marked at `MAX_CARRIERS`. Costs no index entry: a
* carrier is an attribute of an entry the walk was going to write anyway.
*/
function carry(sink, form) {
const id = sink.toolCallId;
if (id === undefined)
return;
const entry = sink.carriers.get(form);
if (entry === undefined) {
sink.carriers.set(form, { toolCallIds: [id] });
return;
}
if (entry.toolCallIds.includes(id))
return;
if (entry.toolCallIds.length >= exports.MAX_CARRIERS) {
entry.truncated = true;
return;
}
entry.toolCallIds.push(id);
}
function add(sink, raw) {
const norm = (0, normalize_js_1.normalizeToken)(raw);
if (norm === '')
return;
for (const form of (0, normalize_js_1.lookupForms)(norm)) {
// Re-stamping a form the index already holds costs no entry, so it is
// done before the budget is consulted: a truncated index should still
// hold the freshest turn for everything it did manage to index.
if (sink.values.has(form)) {
sink.values.set(form, sink.turn);
carry(sink, form);
continue;
}
if (sink.budget <= 0)
return;
sink.values.set(form, sink.turn);
carry(sink, form);
sink.budget -= 1;
}
}
function addText(sink, text) {
for (const token of (0, normalize_js_1.tokenize)(text)) {
if (sink.budget <= 0)
return;
add(sink, token);
if (sink.decimalShapes !== undefined)
addDecimalOf(sink, token, sink.decimalShapes);
}
}
/**
* Under `decimalRounding`: a standalone scientific token (`1.12e-02`) also
* files the exact decimal it states (`0.0112`, `numberPrecision.ts` ·
* `scientificDecimalOf`) — beside its spelling, never instead of it, and
* from the same result, so it carries the same turn and the same carrier.
* A token that matches a declared ID shape is an identifier, not a
* measurement, and files nothing more. The decimal takes one entry of the
* same budget as any form.
*/
function addDecimalOf(sink, token, shapes) {
const decimal = (0, numberPrecision_js_1.scientificDecimalOf)(token);
if (decimal === undefined || shapes.some((shape) => shape.match.test(token)))
return;
add(sink, decimal.exact);
}
/** Walk a parsed JSON value, indexing keys and leaves. */
function walk(node, sink) {
if (sink.budget <= 0 || node === null || node === undefined)
return;
if (typeof node === 'string') {
// Whole value first (a leaf may contain spaces and still be one value),
// then its tokens (`"fc1/3 is down"` carries `fc1/3`).
add(sink, node);
addText(sink, node);
return;
}
if (typeof node === 'number' || typeof node === 'boolean' || typeof node === 'bigint') {
add(sink, String(node));
return;
}
if (Array.isArray(node)) {
for (const el of node)
walk(el, sink);
return;
}
if (typeof node === 'object') {
for (const [key, value] of Object.entries(node)) {
add(sink, key);
walk(value, sink);
}
}
}
/**
* One parsed value as evidence. An `absent(…)` frame grounds everything the
* TOOL authored and drops the one field that quotes the REQUEST
* (`looked_for`) — indexing that would ground every identifier a model
* invented as long as it handed the invention to one tool that found nothing.
* See `../../../semantics/coverage/evidence.ts` — it is the frames.ts argument on the tool
* side of the conversation. A capped result (`../toolResultCap.ts`) is walked
* as served AND its `head` — the tool's first characters, verbatim, usually
* JSON cut mid-value — is read as a result of its own, so a number or a
* boolean in it is found. Every other value keeps the walk it always had.
*/
function asEvidence(value) {
const projection = (0, index_js_1.absenceEvidenceProjection)(value);
if (projection !== undefined)
return projection;
if (!(0, toolResultCap_js_1.isTruncatedToolResult)(value) || typeof value.head !== 'string')
return value;
const head = readResult(value.head);
if (!('parsed' in head))
return value;
return head.tail === undefined ? [value, head.parsed] : [value, head.parsed, head.tail];
}
/**
* How ONE tool result is read — the one reading both the index
* (`indexResult`) and the per-result question (`resultCarries.ts` ·
* `resultReader`) take: the parsed JSON when it is JSON (`asEvidence`: an
* absence's `looked_for` projected away, a capped result's head read too), its
* text otherwise.
*
* A result that is NOT one JSON value but OPENS with one — the tool's JSON
* with a framework note joined after it (a step banner, an effect note, the
* repeated-call note), or several JSON blocks (an MCP text result's content
* blocks) — reads as those leading values, walked, and the text after them as
* `tail`; one cut short before it closes (a tool that truncated its own
* output) reads as the leaves the grammar can read, and — cut inside a
* string — that string's whole words as `tail` (`servedJson.ts` ·
* `jsonPrefixOf`: never the word the cut may have split). Read whole as text,
* `{"id":4417}` tokenises to `:4417`, and every number and boolean the tool
* returned would read as absent. Text that opens with a brace and is not JSON
* (a log line) stays text.
*/
function readResult(content) {
const trimmed = content.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('['))
return { text: content };
try {
return { parsed: asEvidence(JSON.parse(trimmed)) };
}
catch {
// Not ONE JSON value: read what the grammar can, below.
}
const { values, rest } = (0, servedJson_js_1.leadingJsonValues)(trimmed);
if (values.length === 0) {
const cut = (0, servedJson_js_1.jsonPrefixOf)(trimmed);
if (cut === undefined)
return { text: content };
return { parsed: cut.leaves, ...(cut.words !== undefined && { tail: cut.words }) };
}
// After the values: a further block cut short (its leaves, and the words of a cut string), or text.
const cut = rest === '' ? undefined : (0, servedJson_js_1.jsonPrefixOf)(rest);
const walked = values.map(asEvidence);
const all = cut === undefined ? walked : [...walked, cut.leaves];
const tail = cut === undefined ? (rest === '' ? undefined : rest) : cut.words;
return {
parsed: all.length === 1 ? all[0] : all,
...(tail !== undefined && { tail }),
};
}
exports.readResult = readResult;
/** Index one tool result: structurally where it is JSON, as text where it is not. */
function indexResult(content, sink) {
const read = readResult(content);
if ('text' in read) {
addText(sink, read.text);
return;
}
walk(read.parsed, sink);
if (read.tail !== undefined)
addText(sink, read.tail);
}
// FOLD · the one owner of the corpus of values this run can prove it read from a tool result
// consumers read this and never re-derive it: gate.ts · checkAnswer, which is HANDED the corpus rather than building one, and findings/contingent.ts · contingentRowsOf, handed its `carriers`; built at two moments by one fold — stages/route.ts · `judgeEvidence` (the answer) and stages/toolCalls.ts · `towersFor` (dispatch, under `.findings()` beside the gate only)
// detached: yes — never stored; rebuilt per judgement, and the verdict is committed as plain data.
/**
* Build the evidence corpus from the history AS IT STANDS: every `role: 'tool'`
* turn still in it, each indexed form stamped with the turn that served it.
*
* In a single-turn run these are exactly this turn's tool results. In a
* continued conversation the earlier turns' results are in here too, and that
* is deliberate: the model really did read them, and calling a value from turn
* one a fabrication in turn two would be false. Which earlier turns are still
* here is the WINDOW's decision, not this function's — see the header.
*/
function evidenceFromHistory(history,
/**
* The gate this corpus is judged by. Under its `decimalRounding`, a
* standalone scientific token in a result's text also files the exact
* decimal it states, unless it matches one of its declared `shapes`.
* Absent, or the dial off → the corpus it always was.
*/
gate) {
const sink = {
values: new Map(),
carriers: new Map(),
budget: exports.MAX_INDEX_TOKENS,
turn: 0,
toolCallId: undefined,
...(gate?.decimalRounding === true && { decimalShapes: gate.shapes }),
};
let toolResultsThisTurn = 0;
for (const msg of history) {
// A turn boundary — the person asked something new. The library's own
// corrections are `role: 'user'` too and are NOT boundaries; see the
// header for the bug that distinction prevents.
if (msg.role === 'user' && !(0, frames_js_1.isLibraryAuthoredTurn)(msg.content)) {
sink.turn += 1;
toolResultsThisTurn = 0;
// The carriers are THIS turn's: an earlier turn's results keep their
// turn stamp in `values` and lose their place here.
sink.carriers.clear();
continue;
}
if (msg.role !== 'tool')
continue;
toolResultsThisTurn += 1;
sink.toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : undefined;
// The tool's OWN bytes (`lib/toolBytes.ts` · `toolBytesOf`, the one owner of the
// boundary): the inputs layer's note carries a value the call ran with ("2h", the
// value the tool's rule assumes), and indexing it would let an assumption ground an
// answer as if the tool had said it.
indexResult((0, toolBytes_js_1.toolBytesOf)(msg), sink);
sink.toolCallId = undefined;
}
return {
values: sink.values,
carriers: sink.carriers,
truncated: sink.budget <= 0,
currentTurn: sink.turn,
toolResultsThisTurn,
};
}
exports.evidenceFromHistory = evidenceFromHistory;
/**
* Index one piece of EXEMPT text — something the person or the app said —
* with the other spellings of every date and clock time in it — the SAID
* list of `time/textForms.ts` · `timeFormsOfText` (the text rule of
* `time/forms.ts` · `timeFormsOf`), the one owner of which
* spellings of a time are the person's (time design § 9.5). The exempt
* corpus's one text door: the tool-evidence index reads `addText` and never
* gains these forms.
*/
function addExempt(sink, text) {
addText(sink, text);
for (const form of (0, textForms_js_1.timeFormsOfText)(text).said)
add(sink, form);
}
/**
* The values the PERSON typed into a typed ask (`requestInput`), read off the
* `input_received` result their answer landed as — `undefined` for every
* other message. Only a field whose origin is `'response'` is theirs: a
* `'declaration'` value is one the asking tool already knew, and stays the
* tool's words (it is in the evidence corpus, where it always was). Read
* from the message AS IT STANDS — the tool-result rules (redaction first)
* ran on the answer before it landed, so a hidden field reads as its
* placeholder and exempts nothing, exactly as a redacted person turn does.
* A result with no `origins` exempts nothing: no field can be proven theirs.
*/
function typedAskAnswerOf(msg) {
if (msg.role !== 'tool' || typeof msg.content !== 'string')
return undefined;
// Every other tool result is read by the evidence index already; this
// corpus parses only the ones that can be an answer.
if (!msg.content.includes('"input_received"'))
return undefined;
const read = readResult((0, toolBytes_js_1.toolBytesOf)(msg));
if (!('parsed' in read))
return undefined;
const landed = read.parsed;
if (!isRecord(landed) || landed.status !== 'input_received')
return undefined;
const { values, origins } = landed;
if (!isRecord(values) || !isRecord(origins))
return undefined;
const given = [];
for (const [field, value] of Object.entries(values)) {
if (origins[field] !== 'response')
continue;
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
given.push(String(value));
}
return given;
}
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Insert an already-normalized lookup form (a fold's carried lineage). */
function addForm(sink, form) {
if (form === '' || sink.values.has(form))
return;
if (sink.budget <= 0)
return;
sink.values.set(form, sink.turn);
sink.budget -= 1;
}
/**
* The history half of the exempt corpus — ONE rule, asked by the corpus
* ({@link exemptFromRun}) and by the fold that records its lineage
* ({@link exemptLineageOf}), so what a message exempted before a fold and
* what its summary carries after one cannot disagree.
*/
function addHistoryExempt(sink, history) {
for (const msg of history) {
// The person's answer to a typed ask (`requestInput`) lands as a
// `role: 'tool'` message, but it is their words, not an observation:
// exempt like a value in their message — and, being read here, carried
// by a fold's lineage exactly as their message is.
const answered = typedAskAnswerOf(msg);
if (answered !== undefined) {
for (const value of answered) {
add(sink, value);
addExempt(sink, value);
}
continue;
}
if (msg.role !== 'user' && msg.role !== 'system')
continue;
// A compaction summary's TEXT is a model's claim about the conversation,
// not something the person or the app supplied — indexing it exempted
// every value the summarizer wrote, invented ones included, from the
// gate and the contingent check. It contributes the LINEAGE it was folded
// with instead: the forms the folded person/app turns exempted, taken
// from those original messages at fold time. A frame without one exempts
// nothing. See the "Compaction" section of this folder's README.
if ((0, saidByPerson_js_1.isCompactedSummary)(msg)) {
for (const form of msg.foldedExempt ?? [])
addForm(sink, form);
continue;
}
// …except the corrections this library wrote. They are `role: 'user'`
// turns that QUOTE the flagged values back to the model, so indexing one
// would exempt exactly what it challenged — the gate laundering its own
// accusation. See frames.ts.
if ((0, frames_js_1.isLibraryAuthoredTurn)(msg.content))
continue;
addExempt(sink, msg.content);
}
}
// FOLD · the one owner of what a compaction summary carries forward for the exempt corpus
// consumers read this and never re-derive it: window/strategies/summarizeOldest.ts (stamps it on the frame via buildSummaryMessage), read back by exemptFromRun
// detached: yes — a fresh array of strings, computed from the folded ORIGINALS, never from the summary text.
/**
* The exemption lineage of a span about to be folded: every lookup form the
* exempt corpus holds for these messages, in first-seen order — the person's
* and the app's turns by the corpus's own rule, a nested summary by the
* lineage IT carried, and a typed-ask answer by the values the person typed
* into it (its `input_received` result is their words). Every other tool
* result contributes nothing here: a summarized tool value keeps its tool
* source and is judged against the tool results still in the window, exactly
* like a value whose result a drop removed.
*
* @example
* ```ts
* const lineage = exemptLineageOf([{ role: 'user', content: 'Check array ARR-2291.' }]);
* lineage.includes(normalizeToken('ARR-2291')); // true — the person's value
* exemptLineageOf([{ role: 'tool', content: 'ARR-7', toolCallId: 't1' }]); // [] — a tool's
* ```
*/
function exemptLineageOf(span) {
const sink = {
values: new Map(),
carriers: new Map(),
budget: exports.MAX_INDEX_TOKENS,
turn: 0,
toolCallId: undefined,
};
addHistoryExempt(sink, span);
return [...sink.values.keys()];
}
exports.exemptLineageOf = exemptLineageOf;
/**
* Build the exempt corpus: everything the RUN put in front of the model that
* the model did not invent — the user's message, the conversation's user and
* system turns, and the composed system prompt (base prompt, skill bodies,
* facts, retrieved passages).
*
* `rawContent` is optional on an {@link InjectionRecord} — a record that was
* summarised or redacted contributes what it has. That direction is the safe
* one: a missing exemption can only cost a false flag on a value the app
* supplied, and the caller can name it in `exempt`.
*
* STATED REACH, because `systemPromptInjections` is broader than "the app's
* own prompt": it also carries `.memory()` recall and RAG passages. A value
* that reached the model through memory or retrieval is therefore EXEMPT from
* grounding — never flagged, and never counted as tool evidence of any turn,
* so it is invisible to the recency read too. That is the correct call for the
* fabrication question (the run really did put those words in front of the
* model) and a real blind spot for the recency one, and it is deliberately NOT
* fixed here: recall is its own seam, with its own record
* (`retrievalEvidence_<id>`, `agentfootprint.memory.attached`), and giving a
* recalled passage a turn stamp means deciding whose turn it belongs to — the
* one it was written in or the one it was recalled in — which is a question
* this file has no standing to answer.
*/
function exemptFromRun(args) {
// The same accumulator, walked with no turn boundaries: an exemption is a
// fact about WHO supplied a value, and the turn it arrived in changes
// nothing about that. The keys are lifted into a Set at the end so the
// grounding decision keeps the exact membership type it always took.
// No `toolCallId` is ever set, so nothing here is filed as a carrier: an
// exemption names no result.
const sink = {
values: new Map(),
carriers: new Map(),
budget: exports.MAX_INDEX_TOKENS,
turn: 0,
toolCallId: undefined,
};
if (args.userMessage)
addExempt(sink, args.userMessage);
addHistoryExempt(sink, args.history);
for (const rec of args.systemPromptInjections ?? []) {
if (rec.rawContent)
addExempt(sink, rec.rawContent);
// The summary is what a redacted record has instead. Indexing it cannot
// create a false exemption for a value nobody supplied — the summary is
// built from the content itself.
else if (rec.contentSummary)
addExempt(sink, rec.contentSummary);
}
// A tool's declared default is the app's declaration (honesty layer 2): the
// value itself and its tokens, exactly as a prompt's text is indexed.
for (const value of args.declaredDefaults ?? []) {
add(sink, value);
addExempt(sink, value);
}
// The person's answer to the library's ask: their own words, exempt like a
// value in their message (honesty layer 2, step 4).
for (const value of args.answeredValues ?? []) {
add(sink, value);
addExempt(sink, value);
}
// The person's time parts at grain (time design § 9.5): spellings, each one
// value — indexed whole, never tokenized further.
for (const form of args.timeSaid ?? [])
add(sink, form);
return new Set(sink.values.keys());
}
exports.exemptFromRun = exemptFromRun;
// FOLD · the one owner of the lookup set of the library's own time spellings (the `derived` lineage)
// consumers read this and never re-derive it: stages/route.ts · judgeEvidence (checkAnswer's `derived`)
// detached: yes — a fresh Set per call.
/**
* The lookup forms of the spellings the library DERIVED from this turn's
* time readings (`time/forms.ts` · `timeFormsOf`'s `derived` list, the
* values the library filled from a window, the served time line) — each
* value indexed whole and by its tokens, exactly as the exempt corpus
* indexes a text. A value the answer took only from here is filed
* `derived-from-reading` (time design § 9.5): never invented, never known.
*
* @example
* ```ts
* derivedFormsOf(['2026-10-09T15:00:00Z', 'PDT']).has('pdt'); // true
* ```
*/
function derivedFormsOf(spellings) {
const sink = {
values: new Map(),
carriers: new Map(),
budget: exports.MAX_INDEX_TOKENS,
turn: 0,
toolCallId: undefined,
};
for (const spelling of spellings) {
add(sink, spelling);
addText(sink, spelling);
}
return new Set(sink.values.keys());
}
exports.derivedFormsOf = derivedFormsOf;
//# sourceMappingURL=evidenceIndex.js.map