UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

469 lines • 20.7 kB
"use strict"; /** * 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 * ``` */ Object.defineProperty(exports, "__esModule", { value: true }); exports.normaliseInstants = exports.describeTimeAxis = exports.readTimeAxis = exports.timeAxisIssues = exports.TIME_AXIS_AGGREGATES = exports.TIME_AXIS_UNITS = void 0; const duration_js_1 = require("./duration.js"); const instant_js_1 = require("./instant.js"); const zone_js_1 = require("./zone.js"); /** The closed sets, in one place, for the judge and for its own messages. */ exports.TIME_AXIS_UNITS = ['iso', 'epoch-s', 'epoch-ms']; exports.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 exports.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. */ 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 (!exports.TIME_AXIS_UNITS.includes(unit)) { issues.push(`timeAxis.unit must be one of ${exports.TIME_AXIS_UNITS.join(', ')}, got ${describeValue(unit)}.`); } if (zone !== undefined) { if (!(0, zone_js_1.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 && !(0, duration_js_1.isDuration)(interval, duration_js_1.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 ${exports.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 ${exports.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; } exports.timeAxisIssues = timeAxisIssues; /** Read the declaration off a ticket (`ArtifactMeta`, or anything shaped like * one). Absent is absent; anything present is judged. */ 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 }; } exports.readTimeAxis = readTimeAxis; // ── 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 = (0, duration_js_1.durationParts)(interval, duration_js_1.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 = (0, duration_js_1.durationParts)(interval, duration_js_1.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. */ 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}`; } exports.describeTimeAxis = describeTimeAxis; 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 (0, instant_js_1.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 (0, instant_js_1.spellInstant)(instant, 0) !== undefined; } function classifyIso(value, zone, walls) { if (typeof value !== 'string') return { kind: 'unreadable' }; const instant = (0, instant_js_1.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 = (0, zone_js_1.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 = (0, instant_js_1.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). */ 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 }; } exports.normaliseInstants = normaliseInstants; //# sourceMappingURL=axis.js.map