UNPKG

agentfootprint

Version:

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

431 lines • 20.4 kB
/** * time/forms — every spelling of a time the answer may write, split by * WHO produced it (time design § 9.5, step T7). * * Pattern: a pure function over recorded values — the person's text, or one * window the record holds — never over the answer. The evidence gate * asks it (`evidence/evidenceIndex.ts` · `exemptFromRun`, * `stages/timeLineage.ts` · `timeLineageOf`) and never keeps a time table of * its own. * Role: L0 leaf (the time layer). Imports `windows.ts`, `rows.ts`, * `resolve.ts`, `textForms.ts` (the text rule, split out so the * evidence gate's synchronous corpus never loads the resolver), * `instant.ts` and `zone.ts` only. Loaded under `.time()` only, * through `import()` (`stages/timeLineage.ts`). * Emits: N/A. * * ## The two lists * * - **`said`** — spellings of the parts the PERSON said, at the grain they * said them: "8 AM" said at grain hour is also `8:00` and `08:00` — the * same instant at that grain, equality and not a new fact. They count as * the person's words (the gate exempts them). * - **`derived`** — everything the LIBRARY produced from a reading: an * implied year, the zone's abbreviation in effect (`PDT` for a said `PST`), * offsets, UTC and epoch spellings, the end-of-grain `08:41`, a look-back's * duration, the window's length ("6:25 to 6:45" is `20-minute` as said and * `21-minute` as read to the end of its grain — arithmetic over the ends, * never a part anyone wrote), every part of a `model` reading, every part * of a PROPOSAL — a `rule` reading the time ask offers that the person has * not answered yet (`06:25:00` for "6:25"; its implied year) — and the * turn's CLOCK, at the grain it is served (G16: "This turn's time: …" on * every request under `.time()`): the minute of `now` in the person's zone * (UTC under an unknown zone) — `7:08`, `07:08`, `7:08am`, the date, the * year, the zone and its offset. Never a time the clock does not produce * (`7:09`, or the clock's seconds). And a source's HELD span the served * time line names in the person's zone (take 3): each end's wall spellings * there and in UTC. The gate files an answer * value found only here with the lineage `derived-from-reading`: it can * support "not sure", never "known", and it is never called invented. * * A time value no recorded reading produced is in neither list and still * fails the gate. Library text is never evidence: without `derived`, a model * echoing the served time line back would read as the person's words. */ import { clockSpellings, pad2, Spellings, timeFormsOfText } from './textForms.js'; import { turnWindowsOf } from './windows.js'; import { instantOf, spellInstant } from './instant.js'; import { resolveMention, widenedGrain, withZoneAnswered, } from './resolve.js'; import { answersOf, clockOf, readingsOf, } from './rows.js'; import { offsetAt, wallAt } from './zone.js'; // FOLD · the one owner of which spellings of a time count as the person's and which the library derived // consumers read this and never re-derive it: evidence/evidenceIndex.ts · addExempt (the text rule), stages/timeLineage.ts · timeLineageOf (the turn's windows) // detached: yes — fresh arrays of strings per call. /** * The spellings of one source's times, split by lineage (§ 9.5). * * A `text` — the person's message, their typed answer, the app's prompt — * has only `said` spellings, and each is a SPELLING, never an * interpretation: an ISO date → its year, month and day; a 12-hour reading * (`8 Am`) → its 24-hour, padded and glued forms; a 24-hour reading * (`20:00`) → the same set. Slash dates are never read (their order is a * locale) and durations never (a quantity is not a time of day). * * A `window` — the turn's recorded window — splits by who produced each * part: the person's said parts at grain, or everything of a window they * typed or set in a UI, are `said`; the rest is `derived`. A `model` * reading's window (`derived-from-reading`) has no `said` spelling at all. * * @example * ```ts * timeFormsOf({ text: 'what connected 8 Am to 8:40 AM PST' }).said; * // ['8:00', '08:00', '8:00am', '8:40', '08:40', '8:40am'] * timeFormsOf({ text: '2026-10-09' }).said; // ['2026', '10', '9'] * timeFormsOf({ text: 'took 2h' }); // { said: [], derived: [] } * ``` */ export function timeFormsOf(source) { if ('text' in source) return timeFormsOfText(source.text); if ('clock' in source) return clockForms(source.clock); if ('held' in source) return heldForms(source.held); return windowForms(source.window); } /** * A source's held span, spelled as the library SERVES it (`coverage/timeLimits.ts` * · `heldLine`, take 3): each end's wall time in the person's zone — `9:22`, * `09:22`, `9:22am`, `09:22:44`, the date, the year — its UTC wall time and * machine spellings, and the zone with its abbreviation and offset. All * `derived`: the library converted the tool's instants, the person never * wrote them. Only the two declared ends — never another minute. */ function heldForms(held) { const derived = new Spellings(); for (const value of [held.from, held.to]) { const ms = instantMs(value); if (ms === undefined) continue; derived.push(...allWallForms(wallAt(held.zone, ms), true)); derived.push(...allWallForms(wallAt('UTC', ms), false)); derived.push(...instantForms(ms, held.zone)); derived.push(...zoneForms(held.zone, ms)); } return { said: [], derived: derived.list() }; } /** * The turn's clock, spelled at the grain the library SERVES it (G16, * `arguments/serve.ts` · `clockSentence`: the minute of `now`, its date, in * the clock's zone — `'UTC'` under an unknown zone, G15) — all `derived`: the * library produced it, the person never wrote it. The minute is FLOORED, as * served; the clock's seconds, and any other minute, are not its spellings. */ function clockForms(clock) { const ms = instantMs(clock.now); if (ms === undefined) return { said: [], derived: [] }; const minute = Math.floor(ms / 60_000) * 60_000; const derived = new Spellings(); derived.push(...allWallForms(wallAt(clock.zone, minute), true)); derived.push(...zoneForms(clock.zone, minute)); return { said: [], derived: derived.list() }; } /** * Every recorded window of the LATEST turn on the ledger (its `clock` row's * turn), with the reading each one is — what the gate spells. Empty when no * clock was filed (an agent without `.time()`). */ export function turnFormsWindowsOf(ledger) { const clock = clockOf(ledger); if (clock === undefined) return []; const readings = readingsOf(ledger, clock.turn); const answers = answersOf(ledger, clock.turn); const { windows } = turnWindowsOf(readings, clock, answers); return [ ...windows.map((w) => withReading(w, readings, clock)), ...proposalsOf(readings, answers), ]; } /** * The PROPOSALS of the turn: each window a `rule` reading offers the person in * the time ask (its choice `open`, the offered candidates `remaining`) while * they have not answered it. A proposal settles no call (`windows.ts` · * `turnWindowsOf` leaves it open), but its spellings are the library's own * reading of the person's words — `06:25:00` for "6:25", the implied year — * so every part is `derived` (`source: 'derived-from-reading'`, no said part): * never invented, never the person's, "not sure" at most. Once the person * answers, the answered window replaces it. */ function proposalsOf(readings, answers) { const answered = new Set(answers.map((a) => a.mention)); const out = []; for (const row of readings) { if (row.reader.kind !== 'rule' || row.refused !== undefined || row.quote === undefined) continue; if (row.mention !== undefined && answered.has(row.mention)) continue; const choice = row.choice; if (choice?.by !== 'open') continue; for (const i of choice.remaining) { const c = row.candidates?.[i]; if (c === undefined) continue; out.push({ source: 'derived-from-reading', range: c.range, zone: c.zone, ...(c.window.kind === 'lookback' && { lookback: c.window.duration }), }); } } return out; } /** A turn window with the reading it is, when the person confirmed a `rule` reading of their words. */ function withReading(window, readings, clock) { const base = { source: window.source, range: window.range, zone: window.zone, ...(window.lookback !== undefined && { lookback: window.lookback }), ...(window.answer !== undefined && { answer: window.answer }), }; if (window.mention === undefined || window.answer === 'edited') return base; const row = readings.find((r) => r.mention === window.mention); const reading = row === undefined ? undefined : writtenReadingOf(row, window, clock); return reading === undefined ? base : { ...base, reading }; } /** * Which parts the person WROTE for the reading a window is. Every reading is * filed as a proposal (`said: []`, TQ29), so the recorded parses are resolved * again — the zone the person answered in place of a token the layer cannot * read, the turn's recorded clock, never the reader — and the candidate whose * range IS the window names its parts. Only a `rule` reader's parts are what * was written; a `model` reading has none (§ 5.5). */ function writtenReadingOf(row, window, clock) { if (row.reader.kind !== 'rule' || row.parses === undefined || row.parses.length === 0) { return undefined; } const from = instantMs(window.range.from); const to = instantMs(window.range.to); try { const parses = withZoneAnswered(row.parses, window.zone); // Under an unknown clock zone (G15) the words were read in the zone the person answered — // the window's — exactly as the time ask re-read them. const read = clock.zoneSource === 'unknown' ? { now: clock.now, zone: window.zone } : clock; const { candidates } = resolveMention(parses, read, row.reader, false); return candidates.find((c) => instantMs(c.range.from) === from && instantMs(c.range.to) === to); } catch { // A record the resolver cannot read names no parts: nothing is the person's. return undefined; } } // ─── One recorded window ─────────────────────────────────────────────────── /** The date and clock parts a window of the person's own entry says — all of them. */ const ALL_PARTS = ['year', 'month', 'day', 'hour', 'minute', 'meridiem']; /** * Which parts of `window` the person said: every part of a window they typed * in the ask or set in a UI; of a reading they confirmed, only the parts they * WROTE (an implied year stays the library's); none of a `model` reading, and * none when the reading cannot be named. */ function saidPartsOf(window) { if (window.source === 'derived-from-reading') return []; if (window.source === 'control' || window.answer === 'edited') return ALL_PARTS; return window.reading?.said ?? []; } /** * The last instant of the range AS SAID — a reading whose end was widened * runs to the end of its grain, so `[08:00, 08:41)` at minute grain is said * as `08:40`, and a whole day `[26 Sep, 27 Sep)` as 26 Sep. An end that was * not widened ("8 AM to 9 AM" → `[08:00, 09:00)`) is said as it is, `09:00` * (`resolveRecord.ts` · `widenedGrain`, the one answer). A window with no * reading (typed in the ask, set in a UI) ends at its own `to`, as entered. */ function saidEndMs(window, toMs) { const grain = window.reading === undefined ? undefined : widenedGrain(window.reading); if (grain === undefined) return toMs; // A day or wider: the last millisecond is inside the last said day. return toMs - (GRAIN_MS[grain] ?? 1); } const GRAIN_MS = { second: 1000, minute: 60_000, hour: 3_600_000 }; /** The spellings of one recorded window, split by lineage. */ function windowForms(window) { const fromMs = instantMs(window.range.from); const toMs = instantMs(window.range.to); if (fromMs === undefined || toMs === undefined) return { said: [], derived: [] }; const parts = new Set(saidPartsOf(window)); const said = new Spellings(); const derived = new Spellings(); const ends = [fromMs, saidEndMs(window, toMs)]; for (const ms of ends) { const wall = wallAt(window.zone, ms); said.push(...wallDateForms(wall, parts)); if (parts.has('hour')) said.push(...wallClockForms(wall, parts.has('meridiem'))); } for (const ms of [...ends, toMs, toMs - 60_000]) { derived.push(...allWallForms(wallAt(window.zone, ms), true)); derived.push(...allWallForms(wallAt('UTC', ms), false)); derived.push(...instantForms(ms, window.zone)); } derived.push(window.range.from, window.range.to, ...zoneForms(window.zone, fromMs)); if (window.lookback !== undefined) derived.push(window.lookback); derived.push(...windowLengthForms(fromMs, saidEndMs(window, toMs), toMs)); const saidList = said.list(); return { said: saidList, derived: derived.list().filter((f) => !saidList.includes(f)) }; } // ─── The window's length ──────────────────────────────────────────────────── const SECOND_MS = 1000; const MINUTE_MS = 60_000; const HOUR_MS = 3_600_000; const DAY_MS = 86_400_000; /** * How long the window is — DERIVED, never said: arithmetic over the ends of a * window the record holds, not a part anyone wrote. Two lengths, because a * range read to the end of its grain has two honest ones: the range AS SAID * (its said end minus its start — "6:25 to 6:45 AM" is 20 minutes) and the * half-open range read (`[06:25, 06:46)` is 21 minutes). An end that was not * widened gives one length. * * Only whole-token spellings: the gate compares tokens (`lib/normalize.ts` * · `tokenize`), so `20 minutes` is two tokens and neither is a candidate, * while `20-minute` and `1200s` are one and are asked about. A length that is * not a whole number of seconds (a whole day's said end, one millisecond short) * is not a duration anyone writes, and spells nothing. * * @example * ```ts * windowLengthForms(Date.parse('2026-10-01T13:25:00Z'), Date.parse('2026-10-01T13:45:00Z'), Date.parse('2026-10-01T13:46:00Z')); * // ['1200s', '20m', '20-minute', …, '0.33h', …, '1260s', '21m', '21-minute', …] * ``` */ function windowLengthForms(fromMs, saidEndMs, toMs) { const out = new Spellings(); for (const ms of [saidEndMs - fromMs, toMs - fromMs]) { if (ms <= 0 || ms % SECOND_MS !== 0) continue; out.push(...lengthSpellings(ms)); } return out.list(); } /** The common whole-token spellings of one length: seconds, and minutes, hours, days when whole. */ function lengthSpellings(ms) { const out = [...unitSpellings(ms / SECOND_MS, ['s', 'sec', 'secs'], 'second')]; if (ms % MINUTE_MS === 0) { const m = ms / MINUTE_MS; out.push(...unitSpellings(m, ['m', 'min', 'mins'], 'minute'), `PT${m}M`); } const hours = ms / HOUR_MS; // A fraction of an hour is written to two places at most ("0.33 h"), trailing zeros dropped. const h = Number.isInteger(hours) ? String(hours) : String(Number(hours.toFixed(2))); if (h !== '0') out.push(...unitSpellings(h, ['h', 'hr', 'hrs'], 'hour')); if (Number.isInteger(hours)) out.push(`PT${h}H`); if (ms % DAY_MS === 0) { const d = ms / DAY_MS; out.push(...unitSpellings(d, ['d'], 'day'), `P${d}D`); } return out; } /** `20` with `m` / `min` and `minute` → `20m`, `20min`, `20-minute`, `20-minutes`, `20minute`, `20minutes`. */ function unitSpellings(value, glued, word) { const n = String(value); return [ ...glued.map((unit) => `${n}${unit}`), `${n}-${word}`, `${n}-${word}s`, `${n}${word}`, `${n}${word}s`, ]; } /** The date spellings of the parts said: each said part, and the ISO date when all three were. */ function wallDateForms(wall, parts) { const out = []; if (parts.has('year')) out.push(String(wall.year)); if (parts.has('month')) out.push(String(wall.month)); if (parts.has('day')) out.push(String(wall.day)); if (parts.has('year') && parts.has('month') && parts.has('day')) out.push(isoDate(wall)); return out; } /** * The clock spellings of a said time. Said without a meridiem, a time past * noon keeps only its 12-hour spellings — `20:40` is the library's reading of * a said `8:40`. */ function wallClockForms(wall, meridiemSaid) { const minutes = pad2(wall.minute); const forms = clockSpellings(wall.hour, minutes); if (meridiemSaid || wall.hour < 12) return forms; const h12 = wall.hour % 12 === 0 ? 12 : wall.hour % 12; return [`${h12}:${minutes}`, `${pad2(h12)}:${minutes}`]; } /** * Every date and clock spelling of one wall reading — the derived family. The * 12-hour spellings only in the window's own zone: nobody reads a UTC clock on * a 12-hour dial, and `3:00` is too common to be a lineage. */ function allWallForms(wall, twelveHour) { const minutes = pad2(wall.minute); const clock = twelveHour ? clockSpellings(wall.hour, minutes) : [`${pad2(wall.hour)}:${minutes}`, `${pad2(wall.hour)}:${minutes}Z`]; // A bare month or day number is too common to be a lineage — the year and // the ISO date are spellings no other value shares by chance. return [ String(wall.year), isoDate(wall), ...clock, `${pad2(wall.hour)}:${minutes}:${pad2(wall.second ?? 0)}`, `${isoDate(wall)}T${pad2(wall.hour)}:${minutes}`, `${isoDate(wall)}T${pad2(wall.hour)}:${minutes}:${pad2(wall.second ?? 0)}`, ]; } /** An instant's machine spellings: UTC and the zone's offset, to the minute and second, and epoch. */ function instantForms(ms, zone) { const offset = Math.round(offsetAt(zone, ms)); const out = [String(ms), String(Math.floor(ms / 1000))]; for (const minutes of [0, offset]) { const full = spellInstant({ ms, nanos: 0 }, minutes); if (full === undefined) continue; out.push(full, full.replace(/:\d{2}(?=(Z|[+-]\d{2}:\d{2})$)/, '')); } return out; } /** The zone as the library spells it: its IANA name, the abbreviation in effect, its offset. */ function zoneForms(zone, ms) { const offset = Math.round(offsetAt(zone, ms)); const sign = offset < 0 ? '-' : '+'; const h = Math.floor(Math.abs(offset) / 60); const m = Math.abs(offset) % 60; const hm = m === 0 ? String(h) : `${h}:${pad2(m)}`; return [ zone, `${sign}${pad2(h)}:${pad2(m)}`, `${sign}${pad2(h)}${pad2(m)}`, `utc${sign}${hm}`, `gmt${sign}${hm}`, ...abbreviationOf(zone, ms), ]; } /** The zone's short name at `ms` (`PDT`), when `Intl` has one that is not an offset. */ function abbreviationOf(zone, ms) { const name = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'short' }) .formatToParts(new Date(ms)) .find((p) => p.type === 'timeZoneName')?.value; return name === undefined || /\d/.test(name) ? [] : [name]; } // ─── Small parts ─────────────────────────────────────────────────────────── const isoDate = (wall) => `${String(wall.year).padStart(4, '0')}-${pad2(wall.month)}-${pad2(wall.day)}`; /** An instant's milliseconds, or `undefined` when the text is not one. */ function instantMs(value) { return instantOf(value, 'lenient')?.ms; } //# sourceMappingURL=forms.js.map