agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
261 lines • 11.8 kB
JavaScript
;
/**
* time/resolveRecord — what the resolver writes down: the candidate and
* choice shapes, the app's policy (read at `.time()`), and the checks the
* checkpoint door runs on a filed reading.
*
* Pattern: the record half of `resolve.ts`, split from the resolver so the
* SYNCHRONOUS doors — `.time({ policy })` (`clock.ts` ·
* `readTimeOptions`) and `validateCheckpoint` (`rows.ts` ·
* `timeRowIsWellFormed`) — reach the shapes and their checks without
* the resolver, which only an armed seed loads (`agent/stages/
* timeLayer.ts`, through `import()`: the optional-family law of
* docs-next's site budget). `resolve.ts` re-exports everything here.
* Role: L0 leaf (the time layer). Imports `range.ts` and `zone.ts`
* (the window check) only.
* Emits: N/A.
*
* @example
* ```ts
* readPolicy({ dateOrder: 'DMY' }); // { dateOrder: 'DMY', year: 'ask' }
* readPolicy({ abbreviations: { PST: { zone: 'America/Los_Angeles', offset: '-08:00' } } });
* // { dateOrder: 'ask', year: 'ask', abbreviations: { PST: { zone: 'America/Los_Angeles', offset: '-08:00' } } }
* candidateIsWellFormed({ kind: 'range', range: { from: 'x', to: 'y' } }); // false
* ```
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.choiceIsWellFormed = exports.candidateIsWellFormed = exports.PART_ORDER = exports.shownGrain = exports.widenedGrain = exports.readPolicy = exports.offsetOfToken = exports.DEFAULT_TIME_POLICY = void 0;
const range_js_1 = require("./range.js");
const zone_js_1 = require("./zone.js");
/** No silent MDY, no guessed year. */
exports.DEFAULT_TIME_POLICY = Object.freeze({ dateOrder: 'ask', year: 'ask' });
// ─── The policy ──────────────────────────────────────────────────────────
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
const DATE_ORDERS = ['ask', 'MDY', 'DMY', 'YMD'];
const YEAR_RULES = ['ask', 'current'];
const POLICY_KEYS = ['dateOrder', 'year', 'abbreviations'];
/** `±HH`, `±HHMM`, `±HH:MM` — a colon only between hours and minutes, never trailing. */
const OFFSET_TOKEN = /^([+-])(\d{2})(?::?(\d{2}))?$/;
/**
* A numeric offset as written (`-07`, `-0700`, `-07:00`) → minutes east of
* UTC and its `±HH:MM` spelling, or `undefined`. The one reader of an offset
* token: `resolve.ts` · `zoneReadsOf` (a said offset) and {@link readPolicy} (an
* abbreviation's literal offset) both ask it.
*/
function offsetOfToken(token) {
const m = OFFSET_TOKEN.exec(token);
if (m === null)
return undefined;
const hours = Number(m[2]);
const minutes = m[3] === undefined ? 0 : Number(m[3]);
if (hours > 23 || minutes > 59)
return undefined;
const total = (m[1] === '-' ? -1 : 1) * (hours * 60 + minutes);
return { minutes: total === 0 ? 0 : total, spelled: `${m[1]}${m[2]}:${m[3] ?? '00'}` };
}
exports.offsetOfToken = offsetOfToken;
/** An abbreviation key: letters only, as the person writes it (`PST`, `CEST`). */
const ABBREVIATION_KEY = /^[A-Za-z]{2,8}$/;
const MAX_ABBREVIATIONS = 256;
function readAbbreviations(value) {
const shape = "policy.abbreviations must map letters to { zone, offset } — { PST: { zone: 'America/Los_Angeles', offset: '-08:00' } }";
if (value === null || typeof value !== 'object' || Array.isArray(value))
return shape;
const entries = Object.entries(value);
if (entries.length === 0 || entries.length > MAX_ABBREVIATIONS)
return shape;
const out = {};
for (const [key, entry] of entries) {
if (!ABBREVIATION_KEY.test(key) || !isRecord(entry))
return shape;
if (Object.keys(entry).some((k) => k !== 'zone' && k !== 'offset'))
return shape;
if (!(0, zone_js_1.isZoneName)(entry.zone)) {
return `policy.abbreviations.${key}.zone must be an IANA zone name such as 'America/Los_Angeles'`;
}
const offset = typeof entry.offset === 'string' ? offsetOfToken(entry.offset) : undefined;
if (offset === undefined) {
return `policy.abbreviations.${key}.offset must be a numeric offset such as '-08:00'`;
}
out[key] = Object.freeze({ zone: entry.zone, offset: offset.spelled });
}
return Object.freeze(out);
}
/**
* The app's `policy`, read with the v1 defaults filled in — or a problem in
* words (an unknown key, a value outside its switch). `abbreviations` is kept
* only when given, so a policy without it reads byte-identical to v1's.
*/
function readPolicy(value) {
if (value === undefined)
return exports.DEFAULT_TIME_POLICY;
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return 'policy must be an object — { dateOrder?, year?, abbreviations? }';
}
const p = value;
const extra = Object.keys(p).filter((k) => !POLICY_KEYS.includes(k));
if (extra.length > 0) {
return `policy takes { dateOrder?, year?, abbreviations? } — unknown key ${extra
.map((k) => `'${k}'`)
.join(', ')}`;
}
if (p.dateOrder !== undefined && !DATE_ORDERS.includes(p.dateOrder)) {
return "policy.dateOrder must be 'ask', 'MDY', 'DMY' or 'YMD'";
}
if (p.year !== undefined && !YEAR_RULES.includes(p.year)) {
return "policy.year must be 'ask' or 'current'";
}
const abbreviations = p.abbreviations === undefined ? undefined : readAbbreviations(p.abbreviations);
if (typeof abbreviations === 'string')
return abbreviations;
return Object.freeze({
dateOrder: p.dateOrder ?? 'ask',
year: p.year ?? 'ask',
...(abbreviations !== undefined && { abbreviations }),
});
}
exports.readPolicy = readPolicy;
/**
* The grain a candidate's END was widened to, or `undefined` when its `to`
* is exact — the one answer to "was the end widened?" (§ 3.3). A label
* renders the end as said only then (`readingAsk.ts` · `timeAskOf`), and
* the said end is `to − 1 grain` only then (`forms.ts` · `saidEndMs`).
* `grain` alone is not the answer: "8 AM to 9 AM" is grain `hour` and ends
* AT `09:00`; a look-back is grain `hour` and ends at the clock's now.
*
* @example
* ```ts
* widenedGrain({ grain: 'minute', notes: [{ kind: 'end-of-grain' }] }); // 'minute' — [08:00, 08:41)
* widenedGrain({ grain: 'hour', notes: [] }); // undefined — [08:00, 09:00)
* ```
*/
function widenedGrain(candidate) {
return candidate.notes.some((n) => n.kind === 'end-of-grain') ? candidate.grain : undefined;
}
exports.widenedGrain = widenedGrain;
/**
* The grain a candidate's LABEL shows its end at (`readingAsk.ts` ·
* `timeAskOf`), or `undefined` to show the range as it is. A widened end is
* shown as said ({@link widenedGrain}). A look-back is the other kept-inside
* end: `[now − L, now + 1 ms)` holds the clock's now, so its label ends AT now,
* at the grain the person counted in — "last 40 minutes" is `8:00 – 8:40 AM`,
* never `8:40:00.001`. Only the label asks this: a look-back's end was not
* widened to a grain, so the said end (`forms.ts` · `saidEndMs`) keeps asking
* {@link widenedGrain}.
*
* @example
* ```ts
* shownGrain({ window: { kind: 'lookback', duration: '40m' }, grain: 'minute', notes: [] }); // 'minute'
* shownGrain({ window: { kind: 'range', range }, grain: 'hour', notes: [] }); // undefined — "8 AM to 9 AM"
* ```
*/
function shownGrain(candidate) {
return candidate.window.kind === 'lookback' ? candidate.grain : widenedGrain(candidate);
}
exports.shownGrain = shownGrain;
// ─── The record's checks ─────────────────────────────────────────────────
/** The parts a candidate can name, in the order a record lists them. */
exports.PART_ORDER = [
'year',
'month',
'day',
'hour',
'minute',
'second',
'meridiem',
'zone',
'duration',
];
const GRAINS = ['second', 'minute', 'hour', 'day', 'week', 'month', 'year'];
const ANCHORS = ['message', 'previous-window', 'none'];
const OPEN = [
'date-order',
'year',
'meridiem',
'dst',
'zone',
'abbreviation',
'parse',
'confirm',
];
const isIndex = (value, below) => typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < below;
const isPartList = (value) => Array.isArray(value) && value.every((p) => exports.PART_ORDER.includes(p));
function isNote(value) {
if (!isRecord(value))
return false;
switch (value.kind) {
case 'end-of-grain':
return true;
case 'dst-overlap':
case 'dst-gap':
return ((value.which === 'earlier' || value.which === 'later') &&
(value.end === undefined || value.end === 'from' || value.end === 'to'));
case 'offset-said':
return typeof value.offset === 'string';
case 'century-implied':
return typeof value.century === 'number';
case 'zone-read':
return (typeof value.token === 'string' &&
(value.as === 'place' || value.as === 'abbreviation' || value.as === 'abbreviation-literal'));
default:
return false;
}
}
function isWindow(value) {
if (!isRecord(value))
return false;
if (value.kind === 'range')
return (0, range_js_1.isTimeRange)(value.range);
return value.kind === 'lookback' && typeof value.duration === 'string';
}
/** Whether `value` is a candidate this module would have written. */
function candidateIsWellFormed(value) {
if (!isRecord(value))
return false;
const reader = value.reader;
return (isWindow(value.window) &&
(0, range_js_1.isTimeRange)(value.range) &&
(0, zone_js_1.isZoneName)(value.zone) &&
GRAINS.includes(value.grain) &&
isPartList(value.said) &&
isPartList(value.implied) &&
ANCHORS.includes(value.anchor) &&
isRecord(reader) &&
typeof reader.id === 'string' &&
(reader.kind === 'rule' || reader.kind === 'model') &&
Array.isArray(value.notes) &&
value.notes.every(isNote) &&
isRecord(value.reading) &&
isIndex(value.parse, 64));
}
exports.candidateIsWellFormed = candidateIsWellFormed;
function isPolicyUsed(value) {
if (!isRecord(value))
return false;
return Object.entries(value).every(([k, v]) => (k === 'dateOrder' && DATE_ORDERS.includes(v)) ||
(k === 'year' && YEAR_RULES.includes(v)));
}
/** Whether `value` is a choice this module would have written over `count` candidates. */
function choiceIsWellFormed(value, count) {
if (!isRecord(value))
return false;
switch (value.by) {
case 'only':
return isIndex(value.candidate, count);
case 'policy':
return isIndex(value.candidate, count) && isPolicyUsed(value.policy);
case 'open':
return (Array.isArray(value.remaining) &&
value.remaining.every((i) => isIndex(i, count)) &&
Array.isArray(value.open) &&
value.open.length > 0 &&
value.open.every((q) => OPEN.includes(q)) &&
(value.policy === undefined || isPolicyUsed(value.policy)));
case 'none':
return ['unreadable', 'unsupported', 'no-candidate', 'excluded-by-policy'].includes(value.why);
default:
return false;
}
}
exports.choiceIsWellFormed = choiceIsWellFormed;
//# sourceMappingURL=resolveRecord.js.map