agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
427 lines • 18.6 kB
JavaScript
/**
* time/periodForm — a tool's period FORMS as DECLARED (time design § 7.1):
* the shapes, the sugar, each form's own rules, a declared value's spelling,
* and the tool's facts against a range.
*
* Pattern: the declaration half of `convert.ts`, split from the conversions so
* the SYNCHRONOUS doors — `defineTool({ period })`
* (`tools/declare.ts`), a time answer's check at the resume
* door (`ask.ts` · `checkTimeAnswer`) and the checkpoint door's
* refusal codes (`rows.ts`) — reach it without the conversion
* engine, which only an armed agent loads (`bind.ts`, `drift.ts`,
* reached through `import()`: the optional-family law of docs-next's
* site budget). `convert.ts` re-exports everything public here.
* Role: L0 leaf (the time layer). Imports `instant.ts`, `duration.ts`
* and `zone.ts` only.
* Emits: N/A.
*
* @example
* ```ts
* const [form] = sugarForms({ argument: 'window', spelling: 'iso-range' });
* formIssue(form); // undefined — a well-formed form
* parsesUnderForm('2026-10-09T08:00:00-07:00..2026-10-09T08:40:59-07:00', form, 'window'); // true
* ```
*/
import { AXIS_UNITS, durationMs, isDuration, isUnitSet, LOOKBACK_UNITS, } from './duration.js';
import { compareInstants, daysInMonth, instantOf } from './instant.js';
import { reachMs } from './range.js';
import { isZoneName } from './zone.js';
/** The spellings, in the order the docs list them. */
export const PERIOD_SPELLINGS = Object.freeze([
'lookback',
'signed-lookback',
'iso-range',
'wall-range',
]);
export const BOUND_AS = Object.freeze([
'iso',
'epoch-ms',
'epoch-s',
'date',
'wall',
]);
export const FORM_KINDS = Object.freeze([
'bounds',
'joined',
'object',
'day',
'lookback',
]);
/** The unit set of a fact's duration (`retention`, `maxRange`, `granularity`). */
export const FACT_UNITS = AXIS_UNITS;
/** The step a look-back ending "at now" may miss by when no `granularity` is declared (§ 7.4). */
export const DEFAULT_GRANULARITY_MS = 60_000;
// ─── The sugar ───────────────────────────────────────────────────────────
/** The form one single-argument spelling means (the module table). */
export function sugarForm(argument, spelling, zoneArgument) {
switch (spelling) {
case 'lookback':
return { kind: 'lookback', argument, signed: false };
case 'signed-lookback':
return { kind: 'lookback', argument, signed: true };
case 'iso-range':
return { kind: 'joined', argument, as: 'iso', joiner: '..', edge: 'inclusive' };
case 'wall-range':
return {
kind: 'joined',
argument,
as: 'wall',
joiner: '..',
edge: 'inclusive',
...(zoneArgument !== undefined && { zone: { argument: zoneArgument } }),
};
}
}
/**
* Every form a declaration names, in preference order: `forms` as written,
* else the sugar (`accepts`, else `spelling`). A declaration that names an
* argument and no spelling has no form — today's "which argument", nothing
* the library can convert into.
*/
export function sugarForms(period) {
if (period.forms !== undefined)
return period.forms;
if (period.argument === undefined)
return [];
const spellings = period.accepts ?? (period.spelling !== undefined ? [period.spelling] : []);
return spellings.map((s) => sugarForm(period.argument, s, period.zoneArgument));
}
export const isPlain = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
const isName = (value) => typeof value === 'string' && value.trim() !== '';
const FORM_KEYS = Object.freeze({
bounds: ['kind', 'from', 'to', 'zone', 'maxRange'],
joined: ['kind', 'argument', 'as', 'joiner', 'edge', 'zone', 'maxRange'],
object: ['kind', 'argument', 'keys', 'as', 'edge', 'zone', 'maxRange'],
day: ['kind', 'argument', 'zone', 'maxRange'],
lookback: ['kind', 'argument', 'signed', 'units', 'maxRange'],
});
function boundIssue(value, which) {
if (!isPlain(value))
return which === 'to' ? 'to must be { argument, as, edge }.' : 'from must be { argument, as }.';
for (const key of Object.keys(value)) {
if (key !== 'argument' && key !== 'as' && key !== 'edge') {
return `${which} has an unknown key '${key}' — a bound reads \`argument\`, \`as\` and \`edge\`.`;
}
}
if (!isName(value.argument))
return `${which}.argument must name an argument.`;
if (!BOUND_AS.includes(value.as)) {
return `${which}.as must be one of ${BOUND_AS.join(', ')}.`;
}
if (value.edge !== undefined && value.edge !== 'inclusive' && value.edge !== 'exclusive') {
return `${which}.edge must be 'inclusive' or 'exclusive'.`;
}
if (which === 'from' && value.edge === 'exclusive') {
return "from.edge is 'exclusive' — a start bound holds the instant it names; only a `to` bound says its edge.";
}
if (which === 'to' && value.edge === undefined)
return EDGE_UNDECLARED('to.edge');
return undefined;
}
/** TQ18: a `bounds` / `object` form declares its end edge — the library never guesses it. */
const EDGE_UNDECLARED = (where) => `${where} is missing — say whether the end instant is inside ('inclusive') or just past ('exclusive'); the library never guesses a tool's edge.`;
function zoneIssue(value) {
if (value === undefined)
return undefined;
if (!isPlain(value) ||
Object.keys(value).some((k) => k !== 'argument') ||
!isName(value.argument)) {
return 'zone must be { argument } naming the argument that carries the zone.';
}
return undefined;
}
const edgeIssue = (value) => value === undefined || value === 'inclusive' || value === 'exclusive'
? undefined
: "edge must be 'inclusive' or 'exclusive'.";
/**
* Why one form's own shape is not a form, or `undefined` when it is one. The
* schema half (does the argument exist, is it the right type, does it carry a
* rule) is `tools/declare.ts`'s — this is the shape alone.
*/
export function formIssue(form) {
if (!isPlain(form))
return 'a form must be an object with a `kind`.';
const kind = form.kind;
if (!FORM_KINDS.includes(kind))
return `kind must be one of ${FORM_KINDS.join(', ')}.`;
for (const key of Object.keys(form)) {
if (!FORM_KEYS[kind].includes(key)) {
return `unknown key '${key}' on a '${kind}' form — it reads ${FORM_KEYS[kind]
.map((k) => `\`${k}\``)
.join(', ')}.`;
}
}
if (form.maxRange !== undefined && !isDuration(form.maxRange, FACT_UNITS)) {
return `maxRange ${JSON.stringify(form.maxRange)} is not a duration — a positive whole number and one of s, m, h, d, w (\`24h\`).`;
}
switch (kind) {
case 'bounds':
return (boundIssue(form.from, 'from') ??
boundIssue(form.to, 'to') ??
(form.from.argument === form.to.argument
? 'from and to name the same argument — a `joined` form is one argument.'
: undefined) ??
zoneIssue(form.zone));
case 'joined':
if (!isName(form.argument))
return 'argument must name an argument.';
if (!BOUND_AS.includes(form.as))
return `as must be one of ${BOUND_AS.join(', ')}.`;
if (form.joiner !== '..' && form.joiner !== '/')
return "joiner must be '..' or '/'.";
return edgeIssue(form.edge) ?? zoneIssue(form.zone);
case 'object': {
if (!isName(form.argument))
return 'argument must name an argument.';
const keys = form.keys;
if (!isPlain(keys) ||
Object.keys(keys).length !== 2 ||
!isName(keys.from) ||
!isName(keys.to) ||
keys.from === keys.to) {
return 'keys must be { from, to }, two different property names.';
}
if (!BOUND_AS.includes(form.as))
return `as must be one of ${BOUND_AS.join(', ')}.`;
if (form.edge === undefined)
return EDGE_UNDECLARED('edge');
return edgeIssue(form.edge) ?? zoneIssue(form.zone);
}
case 'day':
if (!isName(form.argument))
return 'argument must name an argument.';
return zoneIssue(form.zone);
case 'lookback':
if (!isName(form.argument))
return 'argument must name an argument.';
if (typeof form.signed !== 'boolean')
return 'signed must be true or false.';
if (form.units !== undefined && !isUnitSet(form.units)) {
return `units '${String(form.units)}' is not a unit set — distinct letters from 'smhdw'.`;
}
return undefined;
}
}
/** Whether a form reads wall-clock values and so needs a zone (`wall`, `date`, a `day`). */
export function needsZone(form) {
switch (form.kind) {
case 'bounds':
return wallish(form.from.as) || wallish(form.to.as);
case 'joined':
case 'object':
return wallish(form.as);
case 'day':
return true;
case 'lookback':
return false;
}
}
const wallish = (as) => as === 'wall' || as === 'date';
const typeOfAs = (as) => as === 'epoch-ms' || as === 'epoch-s' ? 'number' : 'string';
/** Every argument a form names, the zone argument last. */
export function formArguments(form) {
const zone = 'zone' in form && form.zone !== undefined ? form.zone : undefined;
const zoneArg = zone !== undefined ? [{ argument: zone.argument, role: 'zone', type: 'string' }] : [];
switch (form.kind) {
case 'bounds':
return [
{
argument: form.from.argument,
role: 'bound',
type: typeOfAs(form.from.as),
bound: form.from,
},
{ argument: form.to.argument, role: 'bound', type: typeOfAs(form.to.as), bound: form.to },
...zoneArg,
];
case 'object':
return [{ argument: form.argument, role: 'object', type: 'object' }, ...zoneArg];
case 'joined':
case 'day':
case 'lookback':
return [{ argument: form.argument, role: 'bound', type: 'string' }, ...zoneArg];
}
}
/** The argument a form is known by — the results layer's join key (its first bound). */
export function primaryArgument(form) {
return form.kind === 'bounds' ? form.from.argument : form.argument;
}
// ─── Wall times and dates as text ────────────────────────────────────────
const WALL_TEXT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/;
const DATE_TEXT = /^(\d{4})-(\d{2})-(\d{2})$/;
/** A wall time written with no offset, and the step its spelling is precise to. */
export function wallOf(text) {
if (typeof text !== 'string')
return undefined;
const m = WALL_TEXT.exec(text);
if (m === null)
return undefined;
const wall = {
year: Number(m[1]),
month: Number(m[2]),
day: Number(m[3]),
hour: Number(m[4]),
minute: Number(m[5]),
second: m[6] === undefined ? 0 : Number(m[6]),
millisecond: m[7] === undefined ? 0 : Number(m[7].padEnd(3, '0')),
};
if (wall.year < 0 || wall.month < 1 || wall.month > 12)
return undefined;
if (wall.day < 1 || wall.day > daysInMonth(wall.year, wall.month))
return undefined;
if (wall.hour > 23 || wall.minute > 59 || (wall.second ?? 0) > 59)
return undefined;
return { wall, stepMs: m[7] !== undefined ? 1 : m[6] !== undefined ? 1_000 : 60_000 };
}
/** A calendar date `YYYY-MM-DD`, day-checked. */
export function dateOf(text) {
if (typeof text !== 'string')
return undefined;
const m = DATE_TEXT.exec(text);
if (m === null)
return undefined;
const year = Number(m[1]);
const month = Number(m[2]);
const day = Number(m[3]);
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month))
return undefined;
return { year, month, day };
}
// ─── A declared value's spelling ─────────────────────────────────────────
/** Whether a single bound value is written the way `as` declares. */
export function isBoundValue(value, as) {
switch (as) {
case 'iso':
return instantOf(value, 'strict') !== undefined;
case 'epoch-ms':
case 'epoch-s':
return typeof value === 'number' && Number.isSafeInteger(value);
case 'date':
return dateOf(value) !== undefined;
case 'wall':
return wallOf(value) !== undefined;
}
}
/** An epoch bound inside a joined string: its digits, read as a number. */
const EPOCH_TEXT = /^-?(0|[1-9][0-9]*)$/;
export const epochFromText = (text) => EPOCH_TEXT.test(text) && Number.isSafeInteger(Number(text)) ? Number(text) : undefined;
/**
* Whether a declared value (an `assume` default, an `ask` choice) of
* `argument` is written the way `form` spells that argument. A format check:
* no clock, no conversion. `true` for an argument the form does not name.
*/
export function parsesUnderForm(value, form, argument) {
const named = formArguments(form).find((a) => a.argument === argument);
if (named === undefined)
return true;
if (named.role === 'zone')
return isZoneName(value);
switch (form.kind) {
case 'lookback': {
if (typeof value !== 'string')
return false;
const units = form.units ?? LOOKBACK_UNITS;
return form.signed
? value.startsWith('-') && isDuration(value.slice(1), units)
: isDuration(value, units);
}
case 'joined': {
if (typeof value !== 'string')
return false;
const halves = value.split(form.joiner);
if (halves.length !== 2)
return false;
return halves.every((h) => form.as === 'epoch-ms' || form.as === 'epoch-s'
? epochFromText(h) !== undefined
: isBoundValue(h, form.as));
}
case 'day':
return dateOf(value) !== undefined;
case 'bounds':
return isBoundValue(value, named.bound.as);
case 'object':
return true;
}
}
/**
* Which of the tool's declared facts a half-open range breaks, against the
* turn's clock — or `undefined`. `past`: a range that starts after now;
* `future`: one that ends before now; `retention`: only a range WHOLLY older
* than `now − retention` (a partial overlap is the result's to judge);
* `maxRange`: a range that REACHES further than it (`range.ts` · `reachMs`:
* first instant to last, so a look-back exactly `maxRange` long fits — an
* inclusive ceiling). A fact the tool does not declare is not checked.
*/
export function periodFactProblem(range, facts, now) {
const from = instantOf(range.from, 'strict');
const to = instantOf(range.to, 'strict');
const at = instantOf(now, 'strict');
if (from === undefined || to === undefined || at === undefined)
return undefined;
if (facts.direction === 'past' && compareInstants(from, at) > 0)
return 'time-future';
if (facts.direction === 'future' && compareInstants(to, at) <= 0)
return 'time-past';
const retention = facts.retention === undefined ? undefined : durationMs(facts.retention, FACT_UNITS);
if (retention !== undefined && to.ms <= at.ms - retention)
return 'beyond-retention';
const widest = facts.maxRange === undefined ? undefined : durationMs(facts.maxRange, FACT_UNITS);
if (widest !== undefined && reachMs(from.ms, to.ms) > widest)
return 'over-max-range';
return undefined;
}
// ─── A form's own `maxRange` (the one owner) ─────────────────────────────
/**
* The widest window ONE form reads at once: its own `maxRange`, else the
* period's — the one owner every door asks (the conversion, a sent window's
* check, the answer to a period argument). `undefined`: no cap for that form.
*
* @example
* ```ts
* formMaxRange({ kind: 'bounds', from, to, maxRange: '24h' }, { maxRange: '7d' }); // '24h'
* formMaxRange({ kind: 'lookback', argument: 'w', signed: false }, { maxRange: '7d' }); // '7d'
* ```
*/
export function formMaxRange(form, facts) {
return form.maxRange ?? facts?.maxRange;
}
/** The facts ONE form is judged against: the period's, with the form's own `maxRange` in place. */
export function formFacts(form, facts) {
if (form.maxRange === undefined)
return facts;
return { ...facts, maxRange: form.maxRange };
}
/**
* The facts the TOOL is judged against before any form is chosen (a window
* of the person's to fill, a time ask's choices): `maxRange` is the widest
* any form reads — none when one form reads with no cap — so a window is
* refused `over-max-range` only when NO form can read it. The period's facts
* as declared when no form declares its own `maxRange`.
*/
export function toolFacts(forms, facts) {
if (!forms.some((f) => f.maxRange !== undefined))
return facts;
let widest;
for (const form of forms) {
const text = formMaxRange(form, facts);
const ms = text === undefined ? undefined : durationMs(text, FACT_UNITS);
if (text === undefined || ms === undefined) {
const { maxRange: _uncapped, ...rest } = facts;
void _uncapped;
return rest;
}
if (widest === undefined || ms > widest.ms)
widest = { text, ms };
}
return widest === undefined ? facts : { ...facts, maxRange: widest.text };
}
export const TIME_REFUSALS = Object.freeze([
'time-future',
'time-past',
'beyond-retention',
'over-max-range',
'multi-day',
'dst-gap',
'no-form-holds',
]);
//# sourceMappingURL=periodForm.js.map