UNPKG

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
/** * 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