agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
222 lines (221 loc) • 13.1 kB
TypeScript
/**
* time/convert — a tool's period FORMS (time design § 7.1) and the EXACT
* conversions of one range into them (§ 7.2), with the inverse a binding reads.
*
* Pattern: one table per direction. A tool declares every SHAPE its period
* takes (`PeriodForm` — two arguments, one joined string, an object,
* a day, a look-back); the library converts a resolved range into the
* first form that holds it EXACTLY ({@link convertExact}) and reads a
* sent value back into a range ({@link readBack}). Both cross the
* argument boundary of § 3.3 through `range.ts` (`boundInto` /
* `boundFrom` for a `to` bound; `lookbackRange` for a look-back) —
* never inline.
* Role: L0 leaf (the time layer). Imports `instant.ts`, `duration.ts`,
* `range.ts`, `zone.ts` and `periodForm.ts` — the declaration half
* (the shapes, the sugar, each form's own rules, a declared value's
* spelling, the tool's facts), split out for the synchronous doors
* (`tools/declare.ts`, `ask.ts`, `rows.ts`) and re-exported
* here. Asked by `bind.ts` (the fill and the binding) and `drift.ts`,
* which only an armed agent loads.
* Emits: N/A.
*
* ## The sugar (§ 7.1)
*
* | Sugar | Means |
* |-------|-------|
* | `{ argument, spelling: 'lookback' }` | `{ kind: 'lookback', argument, signed: false }` |
* | `spelling: 'signed-lookback'` | the same with `signed: true` |
* | `spelling: 'iso-range'` | `{ kind: 'joined', argument, as: 'iso', joiner: '..', edge: 'inclusive' }` |
* | `'wall-range'` + `zoneArgument` | `{ kind: 'joined', argument, as: 'wall', joiner: '..', edge: 'inclusive', zone: { argument } }` |
* | `accepts: [a, b]` | `[sugar(a), sugar(b)]` |
*
* ## The exact rows (§ 7.2) — what {@link convertExact} sends
*
* | Asked | Form | Sent | Note |
* |-------|------|------|------|
* | a look-back | `lookback` | the same length, the sign added or removed, respelled in the form's `units` | none when no unit of `units` spells it |
* | a look-back | `bounds` / `joined` / `object` | `[now − L, now)` in that `as` | |
* | a range | `bounds` / `joined` / `object`, `as` `iso` · `epoch-ms` · `epoch-s` | the range; a `to` bound per its `edge` | `epoch-s` rounds outward to whole seconds: `rounded` |
* | a range | `as: 'wall'` / `'date'` + a zone | the wall times (dates) in the zone | exact only when the tool reads back the same instants (no doubled hour; a date only at midnights) |
* | a whole day | `day` | that day | any other range is not exact — see the inexact rows |
* | a range ending at now (within `granularity`) | `lookback` | the smallest spelling in `units` that covers `from` | `rounded` when it is longer |
*
* Every other row of § 7.2 is not exact, and {@link convertExact} answers
* `undefined` for it.
*
* ## The inexact rows (§ 7.2, step T5b) — what {@link convertWidened} sends
*
* | Asked | Form | Sent | Reads |
* |-------|------|------|-------|
* | a range inside one calendar day (in the form's zone) | `day` | that day | the whole day — the rest of it is `extra` |
* | a range ending before now (past the tool's step) | `lookback` | the covering look-back from now, in the form's units | `[now − L, now]` — the gap after the range (and any rounding before it) is `extra` |
*
* A form whose read would be wider than the tool's `maxRange` is skipped.
* The refusals are tests the binding asks: a range across days when every
* form is a `day` ({@link spansDaysForDayOnly}), a sent wall time the zone
* skips ({@link wallGapArgument}), and the facts ({@link periodFactProblem};
* a range only partly older than `retention` runs —
* {@link partlyBeyondRetention}).
*
* ## Precision and the inclusive edge
*
* An ISO or wall bound is written to the second when the instant is a whole
* second, else to the millisecond; an inclusive `to` is "the last instant
* inside at that precision" (`to − 1 s`, or `to − 1 ms`). Read back, a bound's
* precision is how it was written: minutes (`to + 1 min`), seconds, or a
* fraction (`+ 1 ms`). An epoch bound's precision is its unit; a date's is
* the calendar day in the zone.
*
* @example
* ```ts
* const forms = sugarForms({ argument: 'window', spelling: 'iso-range' });
* convertExact(
* { range: { from: '2026-10-09T08:00:00-07:00', to: '2026-10-09T08:41:00-07:00' } },
* forms,
* { now: '2026-10-09T15:40:00Z', zone: 'America/Los_Angeles', granularityMs: 60_000 },
* );
* // { form: 0, values: { window: '2026-10-09T08:00:00-07:00..2026-10-09T08:40:59-07:00' } }
* ```
*/
import { type DurationText } from './duration.js';
import { type InstantText } from './instant.js';
import { sameRange, type TimeRange } from './range.js';
export { sameRange };
import { type ZoneName } from './zone.js';
import { type PeriodFacts, type PeriodForm, type TimeRefusal } from './periodForm.js';
export { BOUND_AS, DEFAULT_GRANULARITY_MS, FACT_UNITS, FORM_KINDS, formArguments, formFacts, formIssue, formMaxRange, isBoundValue, needsZone, parsesUnderForm, PERIOD_SPELLINGS, periodFactProblem, primaryArgument, sugarForm, sugarForms, TIME_REFUSALS, toolFacts, } from './periodForm.js';
export type { Bound, BoundAs, FormArgument, PeriodDirection, PeriodFactProblem, PeriodFacts, PeriodForm, PeriodShapes, PeriodSpelling, TimeRefusal, ToBound, ZoneArgument, } from './periodForm.js';
/** What a conversion knows beyond the window: the turn's clock, the zones, the tool's step. */
export interface ConvertContext {
/** The turn's clock (§ 4) — a look-back ends here. */
readonly now: InstantText;
/** The zone a wall or date form is written in and its zone argument is sent: the window's. */
readonly zone: ZoneName;
/** The app's `.time({ zone })` — the zone of a form that declares `wallZone: 'app'` and no zone argument. */
readonly appZone?: ZoneName;
/** The tool's `granularity` in milliseconds, else {@link DEFAULT_GRANULARITY_MS}. */
readonly granularityMs: number;
}
/** The window to convert: its half-open range and, when it was said as one, its look-back. */
export interface WindowToConvert {
readonly range: TimeRange;
/** The look-back length the window was said as (`smhdw`), ending at the clock's `now`. */
readonly lookback?: DurationText;
}
/** One exact conversion: the form used (its index), the argument values, and whether it rounded outward. */
export interface Conversion {
readonly form: number;
readonly values: Readonly<Record<string, unknown>>;
readonly rounded?: true;
}
/**
* The first form (in declared order) that holds `window` EXACTLY — the exact
* rows of § 7.2 — as the argument values to send, or `undefined` when no form
* does. A form that would widen, shift or drop part of the window is never
* chosen here.
*/
export declare function convertExact(window: WindowToConvert, forms: readonly PeriodForm[], ctx: ConvertContext): Conversion | undefined;
/**
* One conversion that holds MORE than the window: the form, the values, the
* range the tool reads with them (`sent`, half-open) and the parts of it the
* window did not ask for (`extra` — one or two ranges, in time order).
*/
export interface WidenedConversion extends Conversion {
readonly sent: TimeRange;
readonly extra: readonly TimeRange[];
}
/**
* The first form (in declared order) that holds `window` by READING MORE than
* it — the inexact rows of § 7.2: a range inside one calendar day → that day
* (`day`); a range ending before now → the covering look-back from now
* (`lookback`). `widestMs` (the tool's `maxRange`) skips a form whose read
* would be wider than the tool reads at once. Asked only after
* {@link convertExact} found no exact form.
*/
export declare function convertWidened(window: WindowToConvert, forms: readonly PeriodForm[], ctx: ConvertContext, widestMs?: number): WidenedConversion | undefined;
/**
* Whether every form is a `day` and the window spans more than one calendar
* day in the form's zone — the row § 7.2 refuses before dispatch: one call per
* day is the model's choice, not the library's.
*/
export declare function spansDaysForDayOnly(window: WindowToConvert, forms: readonly PeriodForm[], ctx: ConvertContext): boolean;
/** A window a tool can read — the conversion to send — or the reason it cannot, before dispatch. */
export type ToolConversion = {
/** Exact, or — when no form holds the window exactly — WIDENED (it carries `sent` and `extra`). */
readonly conversion: Conversion | WidenedConversion;
/** The window starts before the source's oldest data and ends after it: it dispatches, marked. */
readonly partlyBeyondRetention?: true;
} | {
readonly refused: TimeRefusal;
};
/**
* Whether one tool can read one window, and how — the ONE answer every door
* that puts a person's window into a tool asks (the fill, the time ask's
* answer and its choices, the served line): the tool's facts first
* ({@link periodFactProblem}), then the first form that holds it exactly
* ({@link convertExact}), then the first that holds it by reading MORE
* ({@link convertWidened} — `maxRange` skips a read too wide), then `multi-day`
* for a `day`-only tool ({@link spansDaysForDayOnly}), and otherwise
* `no-form-holds`: no declared form can read it at all (a look-back ends at
* now, so it cannot reach a window still running; a covering look-back wider
* than `maxRange` is not read). Never the tool's own default in its place.
*
* @example
* ```ts
* const lookbackOnly = [{ kind: 'lookback', argument: 'window', signed: false }] as const;
* const ctx = { now: '2026-10-09T15:40:00Z', zone: 'America/Los_Angeles', granularityMs: 60_000 };
* const yesterday = { from: '2026-10-08T00:00:00-07:00', to: '2026-10-09T00:00:00-07:00' };
* convertForTool({ range: yesterday }, lookbackOnly, {}, ctx);
* // { conversion: { form: 0, values: { window: '1960m' }, sent: …, extra: [ …the gap after it… ] } }
* convertForTool({ range: yesterday }, lookbackOnly, { maxRange: '24h' }, ctx);
* // { refused: 'no-form-holds' } — reaching 8 Oct from now takes a look-back wider than 24h
* ```
*/
export declare function convertForTool(window: WindowToConvert, forms: readonly PeriodForm[], facts: PeriodFacts | undefined, ctx: ConvertContext): ToolConversion;
/**
* The first argument of `form` whose sent value is a wall time the zone
* SKIPS (a spring-forward gap) — the tool would read a time that never
* happened; refused before dispatch (§ 7.2). `undefined` when the form reads
* no wall bound, the zone is unknown, or every wall value exists.
*/
export declare function wallGapArgument(args: Readonly<Record<string, unknown>>, form: PeriodForm, ctx: {
readonly appZone?: ZoneName;
}): string | undefined;
/**
* Whether {@link readBack} cannot read `args` for want of a zone alone — a
* wall or date form whose zone argument is not in the call yet and no app
* zone stands in. A caller that finds every bound present and still gets no
* range tells "the zone is still to come" (this) from "the bounds name no
* window" (misspelled, a wall time the zone skips, `from` not before `to`).
*/
export declare function readBackLacksZone(args: Readonly<Record<string, unknown>>, form: PeriodForm, ctx: {
readonly appZone?: ZoneName;
}): boolean;
/**
* The half-open range the arguments of `form` name — the § 3.3 conversion back
* from each bound — or `undefined` when they do not name one (missing,
* misspelled, a wall time the zone doubles or skips, `from` not before `to`).
* A look-back is read at the turn's clock (`lookbackRange`). The range is
* spelled in UTC: compare it as instants ({@link sameRange}).
*/
export declare function readBack(args: Readonly<Record<string, unknown>>, form: PeriodForm, ctx: {
readonly now: InstantText;
readonly appZone?: ZoneName;
}): TimeRange | undefined;
/**
* The absolute range a look-back window was sent as to a bounds form —
* `[now − L, now)`, which reads back one millisecond shorter than the
* look-back's own `[now − L, now]` — so a binding recognises either.
*/
export declare function lookbackAsBounds(now: InstantText, lookback: DurationText): TimeRange | undefined;
/** The tool's step in milliseconds — its `granularity`, else one minute (§ 7.4). */
export declare function granularityMsOf(facts: PeriodFacts | undefined): number;
/**
* Whether a half-open range is PARTLY older than what the source keeps — it
* starts before `now − retention` and ends after it. It dispatches as asked
* (§ 7.2); the result's `held` decides `partly-held`. `false` when the tool
* declares no `retention`.
*/
export declare function partlyBeyondRetention(range: TimeRange, facts: PeriodFacts, now: InstantText): boolean;
/** The widest window the tool reads at once (`maxRange`) in milliseconds, when it declares one. */
export declare function widestMsOf(facts: PeriodFacts | undefined): number | undefined;