agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
204 lines • 9.64 kB
JavaScript
/**
* time/reader — the port a strategy reads a person's words through, and
* the checks every reading passes before anything resolves it.
*
* Pattern: port + adapter (Duckling's value/resolution split). A strategy
* TOKENIZES: it returns the parts it sees and the verbatim quote,
* never an instant, never a date order, never a zone. Everything
* that turns parts into instants is `resolve.ts`'s, the same way for
* every strategy (time design § 5.1).
* Role: L0 leaf (the time layer). Imports nothing. The builder's
* `.time({ reader })` checks a reader through {@link readerIssue};
* seed checks what it returned through {@link checkReading}.
* Emits: N/A.
*
* ## What is checked — shape, never meaning
*
* | Check | A failure |
* |-------|-----------|
* | the reader: `id`, `version`, `locale` non-empty strings, `kind` `'rule'` or `'model'`, `read` a function | refused at build |
* | the reading: `{ mentions: [] }`, at most {@link MAX_MENTIONS} | the reader broke its port — the run fails, naming it |
* | each mention's `quote`: a non-empty VERBATIM substring of the text read | the mention is refused (`quote-not-in-text`) and keeps no text |
* | each mention's parts: the {@link TimeParts} shape, every field in range, no unknown key, at most {@link MAX_PARSES} | the mention is refused (`malformed`) |
*
* None of these checks what a word MEANS: a model that reads "yesterday" as
* the wrong day passes every one of them. That is why NO reading is ever the
* person's words — a `rule` reading as much as a `model` one (the owner's
* decision "Always confirm", time design TQ29): a reading only PROPOSES a
* window, offered through the time ask with its zone, and only what the
* person picks or types in that form is theirs (`rowsBuild.ts` ·
* `timeReadingRows`).
*
* @example
* ```ts
* const fixture: TimeReader = {
* id: 'fixture', version: '1.0.0', locale: 'en-US', kind: 'rule',
* read: () => ({ mentions: [{ quote: '10/09/26', parses: [{ date: { kind: 'numeric', fields: [10, 9, 26] } }] }] }),
* };
* readerIssue(fixture); // undefined — a well-formed reader
* checkReading('errors on 10/09/26', fixture.read('', { locale: 'en-US' }), 'fixture');
* // [{ quote: '10/09/26', parses: [{ date: { kind: 'numeric', fields: [10, 9, 26] } }] }]
* ```
*/
// ─── Limits ──────────────────────────────────────────────────────────────
/** The most mentions one reading may hold — a bound on the record, not a guess at language. */
export const MAX_MENTIONS = 16;
/** The most parses one mention may hold. */
export const MAX_PARSES = 4;
const MAX_TOKEN = 64;
const MAX_OFFSET = 1000;
const MAX_COUNT = 100_000;
// ─── The reader ──────────────────────────────────────────────────────────
const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 128;
/**
* Whether `Intl` reads `value` as a BCP 47 language tag — the time ask renders
* its labels in the reader's locale (`present.ts`), so a tag it cannot read is
* refused at the builder, never met at the first label.
*/
function isLanguageTag(value) {
try {
return Intl.getCanonicalLocales(value).length === 1;
}
catch {
return false;
}
}
/** Why `value` is not a {@link TimeReader}, or `undefined` when it is one. */
export function readerIssue(value) {
if (value === null || typeof value !== 'object') {
return 'reader must be a TimeReader — { id, version, locale, kind, read }';
}
const r = value;
if (!nonEmpty(r.id))
return 'reader.id must be a non-empty string';
if (!nonEmpty(r.version))
return 'reader.version must be a non-empty string';
if (!nonEmpty(r.locale) || !isLanguageTag(r.locale)) {
return "reader.locale must be a language tag such as 'en-US'";
}
if (r.kind !== 'rule' && r.kind !== 'model')
return "reader.kind must be 'rule' or 'model'";
if (typeof r.read !== 'function')
return 'reader.read must be a function';
return undefined;
}
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
const isInt = (value, lo, hi) => typeof value === 'number' && Number.isInteger(value) && value >= lo && value <= hi;
const onlyKeys = (value, allowed) => Object.keys(value).every((k) => allowed.includes(k) && value[k] !== undefined);
function isDate(value) {
if (!isRecord(value))
return false;
if (value.kind === 'numeric') {
const fields = value.fields;
return (onlyKeys(value, ['kind', 'fields', 'yearDigits']) &&
Array.isArray(fields) &&
(fields.length === 2 || fields.length === 3) &&
fields.every((f) => isInt(f, 0, 9999)) &&
(value.yearDigits === undefined || value.yearDigits === 2 || value.yearDigits === 4));
}
if (value.kind === 'fixed') {
return (onlyKeys(value, ['kind', 'year', 'month', 'day']) &&
(value.year === undefined || isInt(value.year, 0, 9999)) &&
isInt(value.month, 1, 12) &&
isInt(value.day, 1, 31));
}
return false;
}
function isWall(value) {
return (isRecord(value) &&
onlyKeys(value, ['h', 'm', 's', 'meridiem', 'clock']) &&
isInt(value.h, 0, 23) &&
(value.m === undefined || isInt(value.m, 0, 59)) &&
(value.s === undefined || (value.m !== undefined && isInt(value.s, 0, 59))) &&
(value.meridiem === undefined || value.meridiem === 'am' || value.meridiem === 'pm') &&
(value.clock === undefined || (value.clock === '24h' && value.meridiem === undefined)));
}
const OFFSET_UNITS = ['minute', 'hour', 'day', 'week', 'month', 'year'];
const COUNT_UNITS = ['second', 'minute', 'hour', 'day', 'week'];
function isRelative(value) {
if (!isRecord(value) || typeof value.unit !== 'string')
return false;
if (value.offset !== undefined) {
return (onlyKeys(value, ['unit', 'offset']) &&
OFFSET_UNITS.includes(value.unit) &&
isInt(value.offset, -MAX_OFFSET, MAX_OFFSET));
}
return (onlyKeys(value, ['unit', 'count']) &&
COUNT_UNITS.includes(value.unit) &&
isInt(value.count, 1, MAX_COUNT));
}
const PART_KEYS = [
'date',
'wall',
'zoneToken',
'relative',
'partOfDay',
'anchor',
'rangeOf',
];
/** Whether `value` is a well-formed {@link TimeParts} — `nested` refuses a range inside a range side. */
export function isTimeParts(value, nested = false) {
if (!isRecord(value) || !onlyKeys(value, PART_KEYS))
return false;
if (Object.keys(value).length === 0)
return false;
if (value.date !== undefined && !isDate(value.date))
return false;
if (value.wall !== undefined && !isWall(value.wall))
return false;
if (value.zoneToken !== undefined && !nonEmptyToken(value.zoneToken))
return false;
if (value.relative !== undefined && !isRelative(value.relative))
return false;
if (value.partOfDay !== undefined && !nonEmptyToken(value.partOfDay))
return false;
if (value.anchor !== undefined && value.anchor !== 'previous')
return false;
if (value.rangeOf !== undefined) {
const sides = value.rangeOf;
if (nested || !Array.isArray(sides) || sides.length !== 2)
return false;
if (!sides.every((side) => isTimeParts(side, true)))
return false;
}
return true;
}
function nonEmptyToken(value) {
return typeof value === 'string' && value.trim().length > 0 && value.length <= MAX_TOKEN;
}
function checkMention(text, value) {
if (!isRecord(value) || !onlyKeys(value, ['quote', 'parses', 'problem'])) {
return { refused: 'malformed' };
}
const { quote, parses, problem } = value;
if (!Array.isArray(parses) || parses.length > MAX_PARSES)
return { refused: 'malformed' };
if (problem !== undefined && problem !== 'unreadable')
return { refused: 'malformed' };
// An unreadable mention carries no parses; a readable one carries at least one.
if ((problem === 'unreadable') !== (parses.length === 0))
return { refused: 'malformed' };
if (!parses.every((p) => isTimeParts(p)))
return { refused: 'malformed' };
if (typeof quote !== 'string' || quote.length === 0 || !text.includes(quote)) {
return { refused: 'quote-not-in-text' };
}
return {
quote,
parses: parses,
...(problem === 'unreadable' && { problem: 'unreadable' }),
};
}
/**
* A reader's answer for `text`, checked: one {@link CheckedMention} per
* mention, in order. Throws a `TypeError` naming the reader when the answer
* is not `{ mentions: [] }` (at most {@link MAX_MENTIONS}) — the reader
* broke its port, and a run is never read by a guess at what it meant.
*/
export function checkReading(text, value, readerId) {
if (!isRecord(value) || !Array.isArray(value.mentions) || value.mentions.length > MAX_MENTIONS) {
throw new TypeError(`TimeReader '${readerId}' returned ${isRecord(value) && Array.isArray(value.mentions) ? 'too many mentions' : 'no reading'} — read() answers { mentions: [] } with at most ${MAX_MENTIONS} mentions.`);
}
return value.mentions.map((m) => checkMention(text, m));
}
//# sourceMappingURL=reader.js.map