agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
288 lines • 14.2 kB
JavaScript
;
/**
* time/range — the library's one range, its two spellings, and one
* conversion per boundary a range crosses.
*
* Pattern: one internal shape, converted at every edge by a named function —
* never inline. Inside the library a range is HALF-OPEN `[from, to)`;
* the places a range crosses do not share that edge (the time design,
* `docs/design/time/README.md` § 3.3), so each crossing is a function
* here and its inverse.
* Role: L0 leaf (the time layer's one owner). Imports `instant.ts` and
* `duration.ts`. Asked by `tools/declare.ts` ·
* `parsesUnderSpelling` (an `iso-range` argument is
* {@link splitRange} under `'..'` and the strict profile). No other
* file splits or joins a range.
* Emits: N/A.
*
* ## The boundaries
*
* | Boundary | Its edge | Into it from `[from, to)` | Back |
* |----------|----------|---------------------------|------|
* | a result's `DeclaredPeriod.queried` / `held` | inclusive at both ends | not converted — the tool declares it | {@link fromInclusive} with the tool's step (`granularity`, else 1 ms) |
* | a tool argument bound | declared on every `bounds`/`object` end (TQ18); only the sugar defaults inclusive | {@link boundInto} | {@link boundFrom} |
* | a look-back argument | `[until − L, until]` | {@link lookbackRange} | {@link lookbackOf} |
* | an ask answer / an ISO 8601 interval | `from/to`, half-open | identity: {@link spellRange} | identity: {@link parseRange} |
* | an inclusive interval clause (SQL `BETWEEN`, a chart brush) | inclusive at both ends | {@link toInclusive} | {@link fromInclusive} |
*
* `coverage/period.ts` · `periodVerdict` stays inclusive and is NOT routed
* through here: it compares the two inclusive spans a result declared.
*
* A derived bound keeps the offset its source was written in, spelled in the
* strict profile; a bound that is not derived is kept as written.
*
* @example
* ```ts
* parseRange('2026-10-09T08:00:00-07:00/2026-10-09T08:41:00-07:00');
* // { from: '2026-10-09T08:00:00-07:00', to: '2026-10-09T08:41:00-07:00' }
* toInclusive({ from: '2026-10-09T08:00:00-07:00', to: '2026-10-09T08:41:00-07:00' }, 60_000);
* // { from: '2026-10-09T08:00:00-07:00', to: '2026-10-09T08:40:00-07:00' } — "through 08:40"
* ```
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.roundOutward = exports.overlaps = exports.sameRange = exports.covers = exports.reachMs = exports.lookbackOf = exports.lookbackRange = exports.boundFrom = exports.boundInto = exports.toInclusive = exports.fromInclusive = exports.stepMsOf = exports.spellRange = exports.isTimeRange = exports.parseRange = exports.splitRange = void 0;
const duration_js_1 = require("./duration.js");
const instant_js_1 = require("./instant.js");
/**
* The two bounds of a joined range string — exactly one `joiner`, each half an
* instant under `profile` — or `undefined`. The ORDER is not checked: a format
* check, for a declaration that only promises a spelling.
*/
function splitRange(value, joiner, profile) {
if (typeof value !== 'string')
return undefined;
const halves = value.split(joiner);
if (halves.length !== 2)
return undefined;
const from = (0, instant_js_1.instantOf)(halves[0], profile);
const to = (0, instant_js_1.instantOf)(halves[1], profile);
return from === undefined || to === undefined ? undefined : [from, to];
}
exports.splitRange = splitRange;
/**
* A joined string read as a {@link TimeRange} — strict instants, `from`
* before `to` — or `undefined`. The bounds are kept as written.
*/
function parseRange(value, joiner = '/') {
const bounds = splitRange(value, joiner, 'strict');
if (bounds === undefined || (0, instant_js_1.compareInstants)(bounds[0], bounds[1]) >= 0)
return undefined;
const [from, to] = value.split(joiner);
return { from, to };
}
exports.parseRange = parseRange;
/** Whether `value` is a well-formed {@link TimeRange}: strict instants, `from` before `to`, no other key. */
function isTimeRange(value) {
if (typeof value !== 'object' || value === null || Array.isArray(value))
return false;
const keys = Object.keys(value);
if (keys.length !== 2 || !keys.includes('from') || !keys.includes('to'))
return false;
const { from, to } = value;
const a = (0, instant_js_1.instantOf)(from, 'strict');
const b = (0, instant_js_1.instantOf)(to, 'strict');
return a !== undefined && b !== undefined && (0, instant_js_1.compareInstants)(a, b) < 0;
}
exports.isTimeRange = isTimeRange;
function assertRange(range, caller) {
if (!isTimeRange(range)) {
throw new TypeError(`${caller}: ${JSON.stringify(range)} is not a TimeRange — two strict ISO 8601 instants with a zone, from before to.`);
}
return [(0, instant_js_1.instantOf)(range.from, 'strict'), (0, instant_js_1.instantOf)(range.to, 'strict')];
}
function assertStep(stepMs, caller) {
if (!Number.isSafeInteger(stepMs) || stepMs <= 0) {
throw new TypeError(`${caller}: the step ${String(stepMs)} is not a positive whole number of milliseconds.`);
}
}
function respell(instant, offsetMinutes, caller) {
const text = (0, instant_js_1.spellInstant)(instant, offsetMinutes);
if (text === undefined)
throw new RangeError(`${caller}: the bound falls outside years 0000–9999.`);
return text;
}
/** The range spelled as one string: `from<joiner>to`, bounds as written. Throws on a range that is not well formed. */
function spellRange(range, joiner = '/') {
assertRange(range, 'spellRange');
return `${range.from}${joiner}${range.to}`;
}
exports.spellRange = spellRange;
/** The step a tool's `granularity` names (under `units`), else 1 ms — the § 3.3 rule. */
function stepMsOf(granularity, units) {
if (granularity === undefined)
return 1;
const ms = (0, duration_js_1.durationMs)(granularity, units);
if (ms === undefined)
throw new TypeError(`stepMsOf: '${String(granularity)}' is not a duration in '${units}'.`);
return ms;
}
exports.stepMsOf = stepMsOf;
/**
* An inclusive span read back as half-open: `[from, to + step)`. The step is
* the source's smallest step (a tool's `granularity`, else 1 ms). The span is
* read in the LENIENT profile — a result declares it.
*/
function fromInclusive(span, stepMs = 1) {
assertStep(stepMs, 'fromInclusive');
const from = (0, instant_js_1.instantOf)(span.from, 'lenient');
const to = (0, instant_js_1.instantOf)(span.to, 'lenient');
if (from === undefined || to === undefined || (0, instant_js_1.compareInstants)(from, to) > 0) {
throw new TypeError(`fromInclusive: ${JSON.stringify(span)} is not an inclusive span of instants.`);
}
return {
// Kept as written when it is already strict; a lenient spelling (`t`, `z`, `:60`) is respelled.
from: (0, instant_js_1.instantOf)(span.from, 'strict') !== undefined
? span.from
: respell(from, from.offsetMinutes, 'fromInclusive'),
to: respell((0, instant_js_1.shiftInstant)(to, stepMs), to.offsetMinutes, 'fromInclusive'),
};
}
exports.fromInclusive = fromInclusive;
/**
* A half-open range written with inclusive ends: `[from, to − step]` — the
* last instant inside the range at the reader's precision. Refused when the
* range is narrower than one step (it holds no instant at that precision).
*/
function toInclusive(range, stepMs = 1) {
assertStep(stepMs, 'toInclusive');
const [from, to] = assertRange(range, 'toInclusive');
const last = (0, instant_js_1.shiftInstant)(to, -stepMs);
if ((0, instant_js_1.compareInstants)(from, last) > 0) {
throw new RangeError(`toInclusive: ${spellRange(range)} is narrower than one step of ${stepMs} ms.`);
}
return { from: range.from, to: respell(last, to.offsetMinutes, 'toInclusive') };
}
exports.toInclusive = toInclusive;
/**
* A half-open range's end as a tool's `to` bound: `exclusive` → as is;
* `inclusive` → the last instant inside at the argument's precision
* (`to − step`).
*/
function boundInto(to, edge, stepMs) {
assertStep(stepMs, 'boundInto');
const at = (0, instant_js_1.instantOf)(to, 'strict');
if (at === undefined)
throw new TypeError(`boundInto: ${JSON.stringify(to)} is not a strict instant.`);
return edge === 'exclusive'
? to
: respell((0, instant_js_1.shiftInstant)(at, -stepMs), at.offsetMinutes, 'boundInto');
}
exports.boundInto = boundInto;
/** The inverse of {@link boundInto}: a tool's `to` bound as a half-open range's end. */
function boundFrom(to, edge, stepMs) {
assertStep(stepMs, 'boundFrom');
const at = (0, instant_js_1.instantOf)(to, 'strict');
if (at === undefined)
throw new TypeError(`boundFrom: ${JSON.stringify(to)} is not a strict instant.`);
return edge === 'exclusive'
? to
: respell((0, instant_js_1.shiftInstant)(at, stepMs), at.offsetMinutes, 'boundFrom');
}
exports.boundFrom = boundFrom;
/**
* A look-back `duration` evaluated at `until` — `[until − L, until]`, both
* ends inside — as a half-open range `[until − L, until + 1 ms)`.
*/
function lookbackRange(until, duration, units) {
const at = (0, instant_js_1.instantOf)(until, 'strict');
if (at === undefined)
throw new TypeError(`lookbackRange: ${JSON.stringify(until)} is not a strict instant.`);
const ms = (0, duration_js_1.durationMs)(duration, units);
if (ms === undefined)
throw new TypeError(`lookbackRange: '${String(duration)}' is not a duration in '${units}'.`);
return fromInclusive({
from: respell((0, instant_js_1.shiftInstant)(at, -ms), at.offsetMinutes, 'lookbackRange'),
to: until,
});
}
exports.lookbackRange = lookbackRange;
/**
* The inverse of {@link lookbackRange}: the `until` and the smallest exact
* look-back spelling a half-open range is, or `undefined` when its length is
* no whole duration in `units`.
*/
function lookbackOf(range, units) {
const [from, to] = assertRange(range, 'lookbackOf');
const until = (0, instant_js_1.shiftInstant)(to, -1);
if (from.nanos !== until.nanos)
return undefined;
const duration = (0, duration_js_1.spellDuration)(until.ms - from.ms, units);
return duration === undefined
? undefined
: { until: respell(until, to.offsetMinutes, 'lookbackOf'), duration };
}
exports.lookbackOf = lookbackOf;
/**
* How far a half-open range `[from, to)` REACHES, in milliseconds: from its
* first instant to its last (`to − 1 ms − from`) — the length a tool's
* `maxRange` is held against (an inclusive ceiling: a range is over it only
* when its last instant lies further than `maxRange` after its first). The one
* owner of that length, so a range is judged by the instants it holds, never by
* how it was spelled: the look-back row `[until − L, until]` ({@link lookbackRange},
* half-open `[until − L, until + 1 ms)`) reaches `L`, exactly as the same instants
* written as bounds do — a look-back exactly `maxRange` long is one the tool reads
* at once, and one more step (`L + 1 min`) is not.
*
* @example
* ```ts
* reachMs(Date.parse('2026-10-08T15:40:00Z'), Date.parse('2026-10-09T15:40:00.001Z')); // 86_400_000 — 'last 24h'
* reachMs(Date.parse('2026-10-08T00:00:00Z'), Date.parse('2026-10-09T00:00:00Z')); // 86_399_999 — one day
* ```
*/
function reachMs(fromMs, toMs) {
return toMs - 1 - fromMs;
}
exports.reachMs = reachMs;
/** Whether `outer` holds every instant of `inner` (half-open). */
function covers(outer, inner) {
const [of, ot] = assertRange(outer, 'covers');
const [inf, int] = assertRange(inner, 'covers');
return (0, instant_js_1.compareInstants)(of, inf) <= 0 && (0, instant_js_1.compareInstants)(int, ot) <= 0;
}
exports.covers = covers;
/** Whether two ranges hold the same instants (their spellings and offsets aside). */
function sameRange(a, b) {
const af = (0, instant_js_1.instantOf)(a.from, 'strict');
const at = (0, instant_js_1.instantOf)(a.to, 'strict');
const bf = (0, instant_js_1.instantOf)(b.from, 'strict');
const bt = (0, instant_js_1.instantOf)(b.to, 'strict');
if (af === undefined || at === undefined || bf === undefined || bt === undefined)
return false;
return (0, instant_js_1.compareInstants)(af, bf) === 0 && (0, instant_js_1.compareInstants)(at, bt) === 0;
}
exports.sameRange = sameRange;
/** Whether `a` and `b` share at least one instant (half-open: touching ends do not overlap). */
function overlaps(a, b) {
const [af, at] = assertRange(a, 'overlaps');
const [bf, bt] = assertRange(b, 'overlaps');
return (0, instant_js_1.compareInstants)(af, bt) < 0 && (0, instant_js_1.compareInstants)(bf, at) < 0;
}
exports.overlaps = overlaps;
const floorTo = (instant, stepMs) => ({
ms: instant.ms - (((instant.ms % stepMs) + stepMs) % stepMs),
nanos: 0,
});
/**
* The range widened to whole steps — `from` down, `to` up, never inward —
* with `rounded` saying whether anything moved. Steps are counted from the
* epoch in UTC: right for seconds, minutes and hours; a calendar day or week
* in a zone is `zone.ts`'s question, not this one.
*/
function roundOutward(range, stepMs) {
assertStep(stepMs, 'roundOutward');
const [from, to] = assertRange(range, 'roundOutward');
const down = floorTo(from, stepMs);
const toFloor = floorTo(to, stepMs);
const up = (0, instant_js_1.compareInstants)(toFloor, to) === 0 ? toFloor : (0, instant_js_1.shiftInstant)(toFloor, stepMs);
const fromMoved = (0, instant_js_1.compareInstants)(down, from) !== 0;
const toMoved = (0, instant_js_1.compareInstants)(up, to) !== 0;
return {
range: {
from: fromMoved ? respell(down, from.offsetMinutes, 'roundOutward') : range.from,
to: toMoved ? respell(up, to.offsetMinutes, 'roundOutward') : range.to,
},
rounded: fromMoved || toMoved,
};
}
exports.roundOutward = roundOutward;
//# sourceMappingURL=range.js.map