agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
462 lines • 20.1 kB
JavaScript
/**
* time/axis — a dataset's TIME AXIS: the declaration, its one judge, and
* the read-side view that turns the declared column into comparable instants.
*
* Pattern: declared, never guessed; judged at mint, never repaired; values
* read through ONE view ({@link normaliseInstants}) that never
* touches the stored rows. Moved here from `artifacts/timeAxis.ts`
* (which `artifacts/index.ts` still re-exports), so the axis speaks
* the time layer's one grammar: `interval` is a `duration.ts`
* duration under {@link AXIS_UNITS}, `zone` is a `zone.ts` zone name.
* Role: L0 leaf (the time layer's one owner). Imports `instant.ts`,
* `duration.ts` and `zone.ts`. Asked by `artifacts/minting.ts` ·
* `prepareArtifact` and `artifacts/datasetResult.ts` ·
* `stageDatasetArtifacts` (the judge), and by any consumer that
* compares a dataset's times (the view).
* Emits: N/A.
*
* ## Why a producer declares it
*
* A table of rows is a time series only when one column IS time, and the
* reader of the rows cannot know that honestly: `ts` holding 1726000000 is
* epoch SECONDS to the tool that wrote it and a large integer to everything
* else; `'2026-09-14T10:00:00'` is UTC or the collector's wall clock. So the
* producer SAYS it, on the ticket (`PutArtifactInput.timeAxis`, carried on
* `ArtifactMeta.timeAxis`). A malformed declaration is REFUSED at mint
* ({@link timeAxisIssues}) — never trimmed or defaulted — and the shape is
* CLOSED: a misspelt `agg` is refused by name, never dropped.
*
* ## The view — values, not the declaration
*
* The judge never reads a value; {@link normaliseInstants} does, at read time.
* It turns the declared column into UTC `Z` instants spelled at ONE precision,
* so a lexicographic compare downstream is chronological, and it is honest
* about every value it could not place:
*
* | Value | Under | Read as |
* |-------|-------|---------|
* | a number | `epoch-s` / `epoch-ms` | that instant, exactly as written (no float rounding) |
* | ISO with `Z` or `±HH:MM` | `iso`, any zone | that instant (the lenient profile — a producer's spelling) |
* | ISO with NO offset | `iso`, no `zone` | **naive** — counted, never read as UTC; `status: 'naive-values'`, or refused under `naive: 'refuse'` |
* | ISO with no offset | `iso` + `zone` | a wall time in that zone; one instant, or — in a fall-back overlap — the row-order rule below; in a spring-forward gap, counted `dstGap` |
* | anything else | — | counted `unreadable` (`missing` when the row lacks the column) |
*
* **The fall-back rule.** In the hour the clocks go back, one wall time names
* two instants. Read in the rows' order, the values before the wall clock
* steps back take the earlier offset and those from the step on the later
* (a note `{ kind: 'dst-overlap', resolvedBy: 'row-order' }` per value). When
* the order cannot tell — a lone `01:30`, no step back, two steps back, or a
* column whose wall times are not in order anywhere — the value is counted
* `dstAmbiguous` and handled like a naive one. Nothing is picked silently.
*
* @example
* ```ts
* timeAxisIssues({ column: 'ts', unit: 'epoch-s', zone: 'UTC' }); // ['timeAxis.zone is for wall-clock ISO values; …']
* normaliseInstants([{ ts: 1726300800 }, { ts: 1726304400.5 }], { column: 'ts', unit: 'epoch-s' });
* // { status: 'instants', precision: 'millisecond',
* // points: [{ row: 0, at: '2024-09-14T08:00:00.000Z' }, { row: 1, at: '2024-09-14T09:00:00.500Z' }], … }
* normaliseInstants([{ t: '2026-09-14T10:00:00' }], { column: 't', unit: 'iso' });
* // { status: 'naive-values', count: 1, points: [], … } — never read as UTC
* ```
*/
import { AXIS_UNITS, durationParts, isDuration } from './duration.js';
import { instantOf, spellInstant } from './instant.js';
import { isZoneName, readWall } from './zone.js';
/** The closed sets, in one place, for the judge and for its own messages. */
export const TIME_AXIS_UNITS = ['iso', 'epoch-s', 'epoch-ms'];
export const TIME_AXIS_AGGREGATES = [
'avg',
'min',
'max',
'sum',
'count',
'last',
];
const KEYS = new Set(['column', 'unit', 'zone', 'interval', 'aggregate']);
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isAggregate(value) {
return TIME_AXIS_AGGREGATES.includes(value);
}
/**
* THE ONE JUDGE of a time-axis declaration. Returns every problem, in words a
* producer can act on; an empty list means the declaration is well-formed.
* Never throws, never repairs, never reads a value.
*/
export function timeAxisIssues(value) {
if (!isRecord(value)) {
return [`timeAxis must be an object like { column, unit }, got ${describeValue(value)}.`];
}
const issues = [];
for (const key of Object.keys(value)) {
if (!KEYS.has(key)) {
issues.push(`timeAxis has an unknown key '${key}'; the keys are column, unit, zone, interval, aggregate.`);
}
}
const { column, unit, zone, interval, aggregate } = value;
if (typeof column !== 'string' || column.trim() === '') {
issues.push(`timeAxis.column must name the time column, got ${describeValue(column)}.`);
}
if (!TIME_AXIS_UNITS.includes(unit)) {
issues.push(`timeAxis.unit must be one of ${TIME_AXIS_UNITS.join(', ')}, got ${describeValue(unit)}.`);
}
if (zone !== undefined) {
if (!isZoneName(zone)) {
issues.push(`timeAxis.zone must be an IANA zone such as 'Europe/London' (an abbreviation such as ` +
`'PST' or a bare offset is not one), got ${describeValue(zone)}.`);
}
else if (unit !== 'iso') {
issues.push(`timeAxis.zone is for wall-clock ISO values; an epoch is an instant and has no zone — ` +
`drop zone or use unit 'iso'.`);
}
}
if (interval !== undefined && !isDuration(interval, AXIS_UNITS)) {
issues.push(`timeAxis.interval must be a positive whole number and one of s m h d w ('5m', '1h', '1d'), ` +
`got ${describeValue(interval)}.`);
}
if (aggregate !== undefined && aggregate !== 'raw' && !isAggregate(aggregate)) {
if (!isRecord(aggregate)) {
issues.push(`timeAxis.aggregate must be 'raw', one of ${TIME_AXIS_AGGREGATES.join(', ')}, or a ` +
`record of measure column to one of them, got ${describeValue(aggregate)}.`);
}
else {
const entries = Object.entries(aggregate);
if (entries.length === 0) {
issues.push('timeAxis.aggregate names no measure; declare at least one, or omit it.');
}
for (const [measure, how] of entries) {
if (measure.trim() === '')
issues.push('timeAxis.aggregate has a blank measure name.');
if (measure === column) {
issues.push(`timeAxis.aggregate names the time column '${measure}' as a measure.`);
}
if (!isAggregate(how)) {
issues.push(`timeAxis.aggregate['${measure}'] must be one of ${TIME_AXIS_AGGREGATES.join(', ')}, ` +
`got ${describeValue(how)}.`);
}
}
}
}
if (aggregate !== undefined && aggregate !== 'raw' && interval === undefined) {
issues.push('timeAxis.aggregate summarises an interval, so timeAxis.interval must say how wide it is.');
}
if (interval !== undefined && aggregate === undefined) {
issues.push(`timeAxis.interval needs timeAxis.aggregate — say how each interval was summarised, or 'raw'.`);
}
return issues;
}
/** Read the declaration off a ticket (`ArtifactMeta`, or anything shaped like
* one). Absent is absent; anything present is judged. */
export function readTimeAxis(meta) {
const value = isRecord(meta) ? meta.timeAxis : undefined;
if (value === undefined)
return { status: 'absent' };
const issues = timeAxisIssues(value);
return issues.length > 0
? { status: 'malformed', issues }
: { status: 'declared', axis: value };
}
// ── The wording ─────────────────────────────────────────────────────────────
const UNIT_WORDS = {
s: 'second',
m: 'minute',
h: 'hour',
d: 'day',
w: 'week',
};
const SINGLE = {
s: 'per-second',
m: 'per-minute',
h: 'hourly',
d: 'daily',
w: 'weekly',
};
/** `'1h'` → `'hourly'`, `'5m'` → `'5-minute'`. */
function intervalAdjective(interval) {
const parts = durationParts(interval, AXIS_UNITS);
if (parts === undefined)
return interval;
return parts.digits === '1'
? SINGLE[parts.unit]
: `${parts.digits}-${UNIT_WORDS[parts.unit]}`;
}
/** `'5m'` → `'5 minutes'`, `'1h'` → `'hour'`. */
function intervalNoun(interval) {
const parts = durationParts(interval, AXIS_UNITS);
if (parts === undefined)
return interval;
return parts.digits === '1'
? UNIT_WORDS[parts.unit]
: `${parts.digits} ${UNIT_WORDS[parts.unit]}s`;
}
function joinWords(words) {
if (words.length <= 1)
return words[0] ?? '';
return `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`;
}
/**
* The declared SUMMARY in words, for a chart title — the one wording every
* consumer shares, so two screens never describe one ticket two ways.
*
* `{ interval: '1h', aggregate: { a: 'avg', b: 'max' } }` → `'hourly avg and max'`
* `{ interval: '5m', aggregate: 'max' }` → `'5-minute max'`
* `{ interval: '5m', aggregate: 'raw' }` → `'raw samples every 5 minutes'`
* `{ aggregate: 'raw' }` → `'raw samples'`
*
* `undefined` when the declaration says nothing about summarising (column and
* unit only) — there is no summary to put in a title, and inventing "raw"
* would claim something the producer did not.
*/
export function describeTimeAxis(axis) {
const { aggregate, interval } = axis;
if (aggregate === undefined)
return undefined;
if (aggregate === 'raw') {
return interval === undefined ? 'raw samples' : `raw samples every ${intervalNoun(interval)}`;
}
const words = typeof aggregate === 'string' ? [aggregate] : [...new Set(Object.values(aggregate))];
const how = joinWords(words);
return interval === undefined ? how : `${intervalAdjective(interval)} ${how}`;
}
function describeValue(value) {
if (value === undefined)
return 'nothing';
try {
const text = JSON.stringify(value);
if (text === undefined)
return typeof value;
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
}
catch {
return typeof value;
}
}
const NS_PER_MS = 1000000n;
/**
* An epoch number read EXACTLY as written — through its decimal spelling, so
* `1726304400.123` is 123 ms and not the double's nearest neighbour. An
* exponent spelling (`1e21`) or a fraction finer than a nanosecond is
* unreadable.
*/
function epochInstant(value, unit) {
if (typeof value !== 'number' || !Number.isFinite(value))
return undefined;
const m = /^(-?)(\d+)(?:\.(\d+))?$/.exec(String(value));
if (m === null)
return undefined;
const fractionDigits = unit === 'epoch-s' ? 9 : 6; // down to one nanosecond
const fraction = m[3] ?? '';
if (fraction.length > fractionDigits)
return undefined;
const magnitude = BigInt(m[2]) * 10n ** BigInt(fractionDigits) +
BigInt(fraction.padEnd(fractionDigits, '0') || '0');
const ns = m[1] === '-' ? -magnitude : magnitude;
let ms = ns / NS_PER_MS;
if (ns < 0n && ms * NS_PER_MS !== ns)
ms -= 1n; // floor, not truncate
const whole = Number(ms);
if (!Number.isSafeInteger(whole))
return undefined;
return { ms: whole, nanos: Number(ns - ms * NS_PER_MS) };
}
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
/**
* An ISO value with no offset, read as a UTC WALL reading only to get its
* fields — never as an instant. `undefined` when it is not a date-time (or a
* date) with no zone.
*/
function naiveFields(value) {
const spelled = DATE_ONLY.test(value) ? `${value}T00:00Z` : `${value}Z`;
return instantOf(spelled, 'lenient');
}
function wallOf(fields) {
const d = new Date(fields.ms);
return {
year: d.getUTCFullYear(),
month: d.getUTCMonth() + 1,
day: d.getUTCDate(),
hour: d.getUTCHours(),
minute: d.getUTCMinutes(),
second: d.getUTCSeconds(),
millisecond: d.getUTCMilliseconds(),
};
}
function spellable(instant) {
return spellInstant(instant, 0) !== undefined;
}
function classifyIso(value, zone, walls) {
if (typeof value !== 'string')
return { kind: 'unreadable' };
const instant = instantOf(value, 'lenient');
if (instant !== undefined)
return { kind: 'instant', instant };
const fields = naiveFields(value);
if (fields === undefined)
return { kind: 'unreadable' };
if (zone === undefined)
return { kind: 'naive' };
const wall = fields.ms;
let reading = walls.get(wall);
if (reading === undefined) {
const w = wallOf(fields);
if (w.year > 9999)
return { kind: 'unreadable' };
reading = readWall(w, zone);
walls.set(wall, reading);
}
const at = (ms) => ({ ms, nanos: fields.nanos });
if (reading.kind === 'gap')
return { kind: 'gap' };
if (reading.kind === 'unique')
return { kind: 'instant', instant: at(reading.ms), wall };
return {
kind: 'overlap',
earlier: at(reading.earlier),
later: at(reading.later),
wall,
day: value.slice(0, 10),
};
}
function classifyEpoch(value, unit) {
const instant = epochInstant(value, unit);
return instant === undefined ? { kind: 'unreadable' } : { kind: 'instant', instant };
}
function classify(row, axis, walls) {
if (!isRecord(row))
return { kind: 'missing' };
const value = row[axis.column];
if (value === undefined || value === null)
return { kind: 'missing' };
const read = axis.unit === 'iso' ? classifyIso(value, axis.zone, walls) : classifyEpoch(value, axis.unit);
if (read.kind === 'instant' && !spellable(read.instant))
return { kind: 'unreadable' };
if (read.kind === 'overlap' && !(spellable(read.earlier) && spellable(read.later))) {
return { kind: 'unreadable' };
}
return read;
}
/**
* The fall-back rule over the rows' order: for each overlap day, the one
* place its wall clock steps back (a value not after the one before it)
* splits it into `earlier` then `later`. Any step back that is NOT inside one
* overlap day means the rows are not in time order, and every overlap value
* is left unplaced; so is a day with no step back or more than one.
*/
function resolveOverlaps(read, nanos) {
const placed = new Map();
const walled = [];
for (let i = 0; i < read.length; i++) {
const r = read[i];
if ((r.kind === 'instant' && r.wall !== undefined) || r.kind === 'overlap')
walled.push(i);
}
const wallAt = (i) => read[i].wall;
const stepsByDay = new Map();
for (let k = 1; k < walled.length; k++) {
const prev = walled[k - 1];
const here = walled[k];
const notAfter = wallAt(here) < wallAt(prev) || (wallAt(here) === wallAt(prev) && nanos(here) <= nanos(prev));
if (!notAfter)
continue;
const a = read[prev];
const b = read[here];
if (a.kind !== 'overlap' || b.kind !== 'overlap' || a.day !== b.day)
return placed;
stepsByDay.set(a.day, [...(stepsByDay.get(a.day) ?? []), here]);
}
for (const [day, steps] of stepsByDay) {
if (steps.length !== 1)
continue;
const step = steps[0];
for (let i = 0; i < read.length; i++) {
const r = read[i];
if (r.kind === 'overlap' && r.day === day)
placed.set(i, i < step ? 'earlier' : 'later');
}
}
return placed;
}
const pad = (n, width) => String(n).padStart(width, '0');
function precisionOf(instants) {
let precision = 'second';
for (const { ms, nanos } of instants) {
if (nanos !== 0)
return 'nanosecond';
if (((ms % 1000) + 1000) % 1000 !== 0)
precision = 'millisecond';
}
return precision;
}
/** `instant` in UTC with exactly the digits `precision` names — fixed width, so text order is time order. */
function spellAt(instant, precision) {
const msPart = ((instant.ms % 1000) + 1000) % 1000;
const whole = spellInstant({ ms: instant.ms - msPart, nanos: 0 }, 0);
if (precision === 'second')
return whole;
const fraction = pad(msPart * 1_000_000 + instant.nanos, 9);
const digits = precision === 'millisecond' ? fraction.slice(0, 3) : fraction;
return `${whole.slice(0, -1)}.${digits}Z`;
}
/**
* THE READ-SIDE VIEW of a declared time column: every value turned into a UTC
* `Z` instant at one precision, sorted in time, with every value it could not
* place counted by reason (the module table). The rows are only read — never
* copied into, reordered or rewritten — so the stored bytes are unchanged.
*
* A malformed `axis` throws a `TypeError` naming its issues (read a ticket
* with `readTimeAxis` first; a declaration is never repaired).
*/
export function normaliseInstants(rows, axis, options = {}) {
const issues = timeAxisIssues(axis);
if (issues.length > 0) {
throw new TypeError(`normaliseInstants: the time axis is malformed — ${issues.join(' ')}`);
}
const naive = options.naive ?? 'flag';
if (naive !== 'flag' && naive !== 'refuse') {
throw new TypeError(`normaliseInstants: naive must be 'flag' or 'refuse', got ${describeValue(naive)}.`);
}
if (!Array.isArray(rows)) {
throw new TypeError(`normaliseInstants: rows must be an array, got ${describeValue(rows)}.`);
}
const walls = new Map();
const read = rows.map((row) => classify(row, axis, walls));
const nanosOf = (i) => {
const r = read[i];
return r.kind === 'instant' ? r.instant.nanos : r.kind === 'overlap' ? r.earlier.nanos : 0;
};
const byOrder = read.some((r) => r.kind === 'overlap')
? resolveOverlaps(read, nanosOf)
: new Map();
const counts = { naive: 0, dstAmbiguous: 0, dstGap: 0, unreadable: 0, missing: 0 };
const placed = [];
const notes = [];
read.forEach((r, row) => {
if (r.kind === 'instant')
placed.push({ row, instant: r.instant });
else if (r.kind === 'overlap') {
const which = byOrder.get(row);
if (which === undefined)
counts.dstAmbiguous++;
else {
placed.push({ row, instant: r[which] });
notes.push({ kind: 'dst-overlap', resolvedBy: 'row-order', row, which });
}
}
else if (r.kind === 'gap')
counts.dstGap++;
else
counts[r.kind]++;
});
const count = counts.naive + counts.dstAmbiguous;
if (count > 0 && naive === 'refuse') {
return { status: 'refused', reason: 'naive-values', count, counts };
}
placed.sort((a, b) => a.instant.ms - b.instant.ms || a.instant.nanos - b.instant.nanos || a.row - b.row);
const precision = precisionOf(placed.map((p) => p.instant));
const points = placed.map((p) => ({ row: p.row, at: spellAt(p.instant, precision) }));
return count > 0
? { status: 'naive-values', count, points, precision, notes, counts }
: { status: 'instants', points, precision, notes, counts };
}
//# sourceMappingURL=axis.js.map