agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
436 lines • 21.3 kB
JavaScript
;
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.turnFormsWindowsOf = exports.timeFormsOf = void 0;
const textForms_js_1 = require("./textForms.js");
const windows_js_1 = require("./windows.js");
const instant_js_1 = require("./instant.js");
const resolve_js_1 = require("./resolve.js");
const rows_js_1 = require("./rows.js");
const zone_js_1 = require("./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: [] }
* ```
*/
function timeFormsOf(source) {
if ('text' in source)
return (0, textForms_js_1.timeFormsOfText)(source.text);
if ('clock' in source)
return clockForms(source.clock);
if ('held' in source)
return heldForms(source.held);
return windowForms(source.window);
}
exports.timeFormsOf = timeFormsOf;
/**
* 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 textForms_js_1.Spellings();
for (const value of [held.from, held.to]) {
const ms = instantMs(value);
if (ms === undefined)
continue;
derived.push(...allWallForms((0, zone_js_1.wallAt)(held.zone, ms), true));
derived.push(...allWallForms((0, zone_js_1.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 textForms_js_1.Spellings();
derived.push(...allWallForms((0, zone_js_1.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()`).
*/
function turnFormsWindowsOf(ledger) {
const clock = (0, rows_js_1.clockOf)(ledger);
if (clock === undefined)
return [];
const readings = (0, rows_js_1.readingsOf)(ledger, clock.turn);
const answers = (0, rows_js_1.answersOf)(ledger, clock.turn);
const { windows } = (0, windows_js_1.turnWindowsOf)(readings, clock, answers);
return [
...windows.map((w) => withReading(w, readings, clock)),
...proposalsOf(readings, answers),
];
}
exports.turnFormsWindowsOf = turnFormsWindowsOf;
/**
* 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 = (0, resolve_js_1.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 } = (0, resolve_js_1.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 : (0, resolve_js_1.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 textForms_js_1.Spellings();
const derived = new textForms_js_1.Spellings();
const ends = [fromMs, saidEndMs(window, toMs)];
for (const ms of ends) {
const wall = (0, zone_js_1.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((0, zone_js_1.wallAt)(window.zone, ms), true));
derived.push(...allWallForms((0, zone_js_1.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 textForms_js_1.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 = (0, textForms_js_1.pad2)(wall.minute);
const forms = (0, textForms_js_1.clockSpellings)(wall.hour, minutes);
if (meridiemSaid || wall.hour < 12)
return forms;
const h12 = wall.hour % 12 === 0 ? 12 : wall.hour % 12;
return [`${h12}:${minutes}`, `${(0, textForms_js_1.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 = (0, textForms_js_1.pad2)(wall.minute);
const clock = twelveHour
? (0, textForms_js_1.clockSpellings)(wall.hour, minutes)
: [`${(0, textForms_js_1.pad2)(wall.hour)}:${minutes}`, `${(0, textForms_js_1.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,
`${(0, textForms_js_1.pad2)(wall.hour)}:${minutes}:${(0, textForms_js_1.pad2)(wall.second ?? 0)}`,
`${isoDate(wall)}T${(0, textForms_js_1.pad2)(wall.hour)}:${minutes}`,
`${isoDate(wall)}T${(0, textForms_js_1.pad2)(wall.hour)}:${minutes}:${(0, textForms_js_1.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((0, zone_js_1.offsetAt)(zone, ms));
const out = [String(ms), String(Math.floor(ms / 1000))];
for (const minutes of [0, offset]) {
const full = (0, instant_js_1.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((0, zone_js_1.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}:${(0, textForms_js_1.pad2)(m)}`;
return [
zone,
`${sign}${(0, textForms_js_1.pad2)(h)}:${(0, textForms_js_1.pad2)(m)}`,
`${sign}${(0, textForms_js_1.pad2)(h)}${(0, textForms_js_1.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')}-${(0, textForms_js_1.pad2)(wall.month)}-${(0, textForms_js_1.pad2)(wall.day)}`;
/** An instant's milliseconds, or `undefined` when the text is not one. */
function instantMs(value) {
return (0, instant_js_1.instantOf)(value, 'lenient')?.ms;
}
//# sourceMappingURL=forms.js.map