UNPKG

agentfootprint

Version:

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

956 lines • 42 kB
"use strict"; /** * time/resolve — parts + clock + policy → every candidate window, and * the one rule for which candidate a reading settles on. * * Pattern: the hard, universal half of reading time, written once (time * design § 5.1): a strategy tokenizes (`reader.ts`); this module * resolves, the same way for every strategy. It is the one place * zone arithmetic meets the person's words. * Role: L0 leaf (the time layer). Imports `instant.ts`, `zone.ts`, * `range.ts`, `present.ts` (the `Grain` word), `reader.ts` (the * parts) and `resolveRecord.ts` (the shapes, the policy and the * record's checks — split out for the synchronous doors, and * re-exported here). `rowsBuild.ts` · `timeReadingRows` calls it at the * seed, which loads it only under `.time()`. * Emits: N/A. * * ## Every candidate, then the policy (§ 5.1, § 11) * * {@link resolveMention} writes EVERY reading of the parts, each tagged with * how it read them ({@link ReadingTags}): * * | Parts | Candidates | * |-------|------------| * | a numeric date `10/09/26` | up to three: `MDY`, `DMY`, `YMD` — each checked as a calendar day | * | a numeric date of two fields `10/09` | `MDY` and `DMY`, each for the clock's year and the one before | * | a date with no year | the clock's year (`current`) and the one before (`previous`) | * | a two-digit year `26` | the clock's century, noted `century-implied` | * | a clock time with no meridiem, hour 1–12 (`8:40`) | am and pm — one when the form is a 24-hour clock (`clock: '24h'`, an ISO instant) | * | a wall time the clocks go back through (`01:30`) | both instants, each noted `dst-overlap` | * | a wall time the clocks skip (`02:30`) | both readings Temporal names, each noted `dst-gap` | * | a day word (`{ day, offset: -1 }`) | the calendar day in the zone, anchored on the clock | * | a span (`{ minute, count: 40 }`) | a look-back until the clock's `now` | * | a range | one per combination of its sides that agree on date order, year and abbreviation reading, `from` before `to`; a first side with no meridiem takes the second's (`8 to 9 PM` → 8 PM) unless that runs backwards (`11 to 1 PM` → 11 AM); two sides with none share one (`8:45 to 8:55` → AM–AM and PM–PM, never 8:45 AM – 8:55 PM) unless neither runs forward (`11 to 1` → 11 AM – 1 PM, and 11 PM – 1 AM the next day); any range that runs forward on no one day ends the next one, within the half-day (`11 PM to 1 AM`, `23:00 to 1:00`) | * | a place named with `time` (`London time`) | the ONE zone the tz database has for it (`zone.ts` · `zoneOfPlace`), noted `zone-read`; none or several → the zone is asked | * | any mention that names no zone, while the clock's zone is unknown (`zoneSource: 'unknown'`, G15) | none — the person's zone is asked first (`needsZone`) | * | an abbreviation in the app's map (`PST`) | its zone's reading AND its literal offset's, when they name different windows — tagged `abbreviation`, noted `zone-read` | * * The fixed laws: a time is read to the END OF ITS GRAIN ("to 8:40" is * `[08:00, 08:41)`, a lone "9 AM" is `[09:00, 10:00)`, a day ends at the next * midnight — each noted `end-of-grain`) EXCEPT a range end said as an * o'clock hour, which is a boundary on the clock face and ends AT that * instant ("8 AM to 9 AM" is `[08:00, 09:00)`, no note — `endOf`); whether * an end was widened is asked of `widenedGrain`, never of `grain`. A day * starts at its first instant (Temporal's `startOfDay`: a midnight the * clocks skip starts the day at the first wall time that exists); a zone the * person did not say is the clock's; a zone they said is an IANA name, a * numeric offset, a place named with `time` that the tz database names * exactly once, or an abbreviation in the APP's map (`policy.abbreviations`) * — any other token (`PST` with no map, `India time`, `Pacific time`) * resolves nothing and is ASKED (`zoneReadsOf`, the one owner). Parts v1 * does not resolve — a part of the day, a calendar span other than a day, a * window anchored on the previous one — are named `unsupported`, never * guessed. * * {@link chooseReading} then applies the app's policy (`dateOrder`, `year`) * and names what is left: one window (`only`, or `policy` when the policy * removed a reading — a choice nobody said, recorded), several (`open`, with * the questions an ask must settle), or none. A `kind: 'model'` reader's * window is never settled by the library: it stays `open` until the person * confirms it (§ 5.5), and its candidates carry `said: []`. Since the * owner's decision "Always confirm" (time design TQ29) neither is ANY * reading the record files: `rowsBuild.ts` · `timeReadingRows` passes * `confirm: true` for every reader's reading, so only the person's answer * in the time ask settles a window. The `confirm` parameter's default * (`kind === 'model'`) is the unit's, for a caller that resolves parts * outside the record. * * @example * ```ts * const clock = { now: '2026-10-09T15:40:00Z', zone: 'America/Los_Angeles' }; * const rule = { id: 'fixture', kind: 'rule' as const }; * const res = resolveMention([{ date: { kind: 'numeric', fields: [10, 9, 26] } }], clock, rule); * res.candidates.map((c) => c.reading.dateOrder); // ['MDY', 'DMY', 'YMD'] * chooseReading(res, DEFAULT_TIME_POLICY, 'rule'); * // { by: 'open', remaining: [0, 1, 2], open: ['date-order'] } * chooseReading(res, { dateOrder: 'DMY', year: 'ask' }, 'rule'); * // { by: 'policy', candidate: 1, policy: { dateOrder: 'DMY' } } * ``` */ Object.defineProperty(exports, "__esModule", { value: true }); exports.chooseReading = exports.resolveMention = exports.withZoneAnswered = exports.placeBeforeTime = exports.widenedGrain = exports.shownGrain = exports.readPolicy = exports.DEFAULT_TIME_POLICY = exports.choiceIsWellFormed = exports.candidateIsWellFormed = void 0; const instant_js_1 = require("./instant.js"); const range_js_1 = require("./range.js"); const zone_js_1 = require("./zone.js"); const resolveRecord_js_1 = require("./resolveRecord.js"); var resolveRecord_js_2 = require("./resolveRecord.js"); Object.defineProperty(exports, "candidateIsWellFormed", { enumerable: true, get: function () { return resolveRecord_js_2.candidateIsWellFormed; } }); Object.defineProperty(exports, "choiceIsWellFormed", { enumerable: true, get: function () { return resolveRecord_js_2.choiceIsWellFormed; } }); Object.defineProperty(exports, "DEFAULT_TIME_POLICY", { enumerable: true, get: function () { return resolveRecord_js_2.DEFAULT_TIME_POLICY; } }); Object.defineProperty(exports, "readPolicy", { enumerable: true, get: function () { return resolveRecord_js_2.readPolicy; } }); Object.defineProperty(exports, "shownGrain", { enumerable: true, get: function () { return resolveRecord_js_2.shownGrain; } }); Object.defineProperty(exports, "widenedGrain", { enumerable: true, get: function () { return resolveRecord_js_2.widenedGrain; } }); /** * `London time`, `New York time` — a place named with the word `time` (the * reader keeps it as written): `/^(.+?)\s+time$/i.exec(token)?.[1]`, in one * pass. That regex grew its lazy place one character at a time and, at each, * re-read the blank run after it looking for `time` — quadratic on a letter, a * long blank run and anything but `time`, and a token can come straight from * the message. The place is everything before the blank run that ends just * before `time` (at least one character), and holds no line break, which `.` * would not match. */ function placeBeforeTime(token) { const timeAt = token.length - 4; if (timeAt < 2 || !spellsTimeAt(token, timeAt)) return undefined; let blankStart = timeAt; while (blankStart > 0 && isRegExpWhitespace(token.charCodeAt(blankStart - 1))) blankStart--; const placeEnd = Math.max(blankStart, 1); if (placeEnd >= timeAt) return undefined; for (let at = 0; at < placeEnd; at++) { if (isLineBreak(token.charCodeAt(at))) return undefined; } return token.slice(0, placeEnd); } exports.placeBeforeTime = placeBeforeTime; /** `time`, in any ASCII case, at `at`. */ function spellsTimeAt(token, at) { const word = 'time'; for (let i = 0; i < word.length; i++) { if ((token.charCodeAt(at + i) | 0x20) !== word.charCodeAt(i)) return false; } return true; } // `lib/linearText.ts` · `isRegExpWhitespace` / `isLineBreak`, restated because // this folder imports nothing outside itself. Both are pinned on every UTF-16 // code unit through `placeBeforeTime` in `test/security/linear-scanners.test.ts`. /** `\s`: WhiteSpace and LineTerminator. */ function isRegExpWhitespace(code) { if (code <= 0x20) return code === 0x20 || (code >= 0x09 && code <= 0x0d); if (code < 0xa0) return false; return (code === 0xa0 || code === 0x1680 || (code >= 0x2000 && code <= 0x200a) || code === 0x2028 || code === 0x2029 || code === 0x202f || code === 0x205f || code === 0x3000 || code === 0xfeff); } /** What `.` does not match: LF, CR, U+2028, U+2029. */ function isLineBreak(code) { return code === 0x0a || code === 0x0d || code === 0x2028 || code === 0x2029; } /** * The app's entry for an abbreviation token — looked up by OWN key only. A * token is whatever a reader returned (a `model` or custom reader may pass * `constructor` or `__proto__` straight from the message), and the map is a * plain object, so an inherited member must read as "not in the map" — the * zone is then asked, never thrown on. */ function abbreviationOf(policy, token) { const map = policy?.abbreviations; return map !== undefined && Object.prototype.hasOwnProperty.call(map, token) ? map[token] : undefined; } /** * Every zone reading of a token — one, or TWO for an abbreviation in the * app's map (its zone, then its literal offset) — or `undefined` when the * token names no zone this layer can read, so the zone is ASKED. The one * owner of which words become a zone (§ 5.3): an IANA name and a numeric * offset as said; a place named with `time` only when the tz database has * exactly one zone for it (`zone.ts` · `zoneOfPlace`); an abbreviation only * through the app's map. Nothing else — never a hand word list. */ function zoneReadsOf(token, clockZone, policy) { if (token === undefined) return [{ kind: 'iana', zone: clockZone, said: false }]; if ((0, zone_js_1.isZoneName)(token)) return [{ kind: 'iana', zone: token, said: true }]; if (token === 'Z') return [{ kind: 'offset', minutes: 0, spelled: 'Z' }]; const offset = (0, resolveRecord_js_1.offsetOfToken)(token); if (offset !== undefined) return [{ kind: 'offset', ...offset }]; const mapped = abbreviationOf(policy, token); if (mapped !== undefined) { const literal = (0, resolveRecord_js_1.offsetOfToken)(mapped.offset); const reads = [ { kind: 'iana', zone: mapped.zone, said: true, via: { token, as: 'abbreviation' } }, ]; if (literal !== undefined) { const via = { token, as: 'abbreviation-literal' }; const fixed = (0, zone_js_1.fixedOffsetZone)(literal.minutes); reads.push(fixed !== undefined ? { kind: 'iana', zone: fixed, said: true, via } : { kind: 'offset', ...literal, via, shownIn: mapped.zone }); } return reads; } const place = placeBeforeTime(token); const zone = place === undefined ? undefined : (0, zone_js_1.zoneOfPlace)(place); if (zone !== undefined) return [{ kind: 'iana', zone, said: true, via: { token, as: 'place' } }]; return undefined; } /** * The parts with every zone token this layer cannot read (an abbreviation * outside the app's map, `India time`) replaced by the zone the PERSON named * when asked (§ 6.3) — the whole mention's and each range side's. A token it * can read is kept. * * @example * ```ts * withZoneAnswered([{ wall: { h: 8, meridiem: 'am' }, zoneToken: 'PST' }], 'America/Los_Angeles'); * // [{ wall: { h: 8, meridiem: 'am' }, zoneToken: 'America/Los_Angeles' }] * ``` */ function withZoneAnswered(parses, zone, policy) { const fix = (parts) => parts.zoneToken !== undefined && zoneReadsOf(parts.zoneToken, zone, policy) === undefined ? { ...parts, zoneToken: zone } : parts; return parses.map((parts) => { const outer = fix(parts); return outer.rangeOf === undefined ? outer : { ...outer, rangeOf: [fix(outer.rangeOf[0]), fix(outer.rangeOf[1])] }; }); } exports.withZoneAnswered = withZoneAnswered; /** The wall time an instant shows under a zone read. */ function wallIn(zone, ms) { if (zone.kind === 'iana') return (0, zone_js_1.wallAt)(zone.zone, ms); const d = new Date(ms + (zone.kind === 'offset' ? zone.minutes : 0) * 60_000); return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate(), hour: d.getUTCHours(), minute: d.getUTCMinutes(), }; } /** The offset to spell an instant in, minutes east of UTC. */ function offsetFor(zone, ms) { return zone.kind === 'offset' ? zone.minutes : (0, zone_js_1.offsetAt)(zone.zone, ms); } /** Every instant a wall time names under a zone read (two in a DST overlap or gap). */ function instantsOf(wall, zone) { if (!(0, zone_js_1.isWallTime)(wall)) return []; const utc = (0, instant_js_1.utcWallMs)(wall.year, wall.month, wall.day, wall.hour, wall.minute, wall.second ?? 0); if (zone.kind === 'offset') return [{ ms: utc - zone.minutes * 60_000 }]; const reading = (0, zone_js_1.readWall)(wall, zone.zone); if (reading.kind === 'unique') return [{ ms: reading.ms }]; const kind = reading.kind === 'overlap' ? 'dst-overlap' : 'dst-gap'; return [ { ms: reading.earlier, dst: { kind, which: 'earlier' } }, { ms: reading.later, dst: { kind, which: 'later' } }, ]; } /** The first instant of a calendar day in a zone (Temporal's `startOfDay`). */ function dayStart(y, m, d, zone) { const wall = { year: y, month: m, day: d, hour: 0, minute: 0 }; if (!(0, zone_js_1.isWallTime)(wall)) return undefined; if (zone.kind === 'offset') return (0, instant_js_1.utcWallMs)(y, m, d, 0, 0, 0) - zone.minutes * 60_000; return (0, zone_js_1.wallToInstant)(wall, zone.zone, 'compatible'); } /** The calendar day `days` after `y-m-d`. */ function addDays(y, m, d, days) { const date = new Date((0, instant_js_1.utcWallMs)(y, m, d + days, 0, 0, 0)); return [date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()]; } const isDay = (y, m, d) => Number.isInteger(y) && y >= 0 && y <= 9999 && m >= 1 && m <= 12 && d >= 1 && d <= (0, instant_js_1.daysInMonth)(y, m); /** A said year value → a full year, or `undefined`; `century` set when the clock's century was used. */ function fullYear(value, digits, clockYear) { const century = Math.floor(clockYear / 100) * 100; if (digits === 2) return value <= 99 ? { year: century + value, century } : undefined; if (digits === 4) return value >= 1000 ? { year: value } : undefined; return value >= 100 ? { year: value } : { year: century + value, century }; } /** The readings of a date said without a year: the clock's year, and the one before. */ function yearless(m, d, clockYear, tags) { const out = []; for (const [year, which] of [ [clockYear, 'current'], [clockYear - 1, 'previous'], ]) { if (!isDay(year, m, d)) continue; out.push({ y: year, m, d, tags: { ...tags, year: which }, said: ['month', 'day'], implied: ['year'], notes: [], anchor: 'none', }); } return out; } const ORDERS3 = [ // [tag, index of month, index of day, index of year] ['MDY', 0, 1, 2], ['DMY', 1, 0, 2], ['YMD', 1, 2, 0], ]; function numericDates(fields, digits, clockYear) { const out = []; if (fields.length === 2) { const [a, b] = fields; out.push(...yearless(a, b, clockYear, { dateOrder: 'MDY' })); out.push(...yearless(b, a, clockYear, { dateOrder: 'DMY' })); return out; } for (const [tag, mi, di, yi] of ORDERS3) { const full = fullYear(fields[yi], digits, clockYear); if (full === undefined) continue; const m = fields[mi]; const d = fields[di]; if (!isDay(full.year, m, d)) continue; out.push({ y: full.year, m, d, tags: { dateOrder: tag, year: 'said' }, said: ['year', 'month', 'day'], implied: [], notes: full.century !== undefined ? [{ kind: 'century-implied', century: full.century }] : [], anchor: 'none', }); } return out; } /** Every date reading of the parts, or a part v1 does not resolve. */ function datesOf(parts, today) { const rel = parts.relative; if (rel !== undefined) { if (parts.date !== undefined) return { unsupported: 'a date and a relative day together' }; if (!('offset' in rel) || rel.unit !== 'day') { return { unsupported: `relative ${rel.unit}` }; } const [y, m, d] = addDays(today.year, today.month, today.day, rel.offset); return [ { y, m, d, tags: {}, said: ['day'], implied: ['year', 'month'], notes: [], anchor: 'message', }, ]; } const date = parts.date; if (date === undefined) { if (parts.wall === undefined) return { unsupported: 'no date or time' }; // A clock time alone is on the clock's day. return [ { y: today.year, m: today.month, d: today.day, tags: {}, said: [], implied: ['year', 'month', 'day'], notes: [], anchor: 'message', }, ]; } if (date.kind === 'numeric') return numericDates(date.fields, date.yearDigits, today.year); if (date.year === undefined) return yearless(date.month, date.day, today.year, {}); if (!isDay(date.year, date.month, date.day)) return []; return [ { y: date.year, m: date.month, d: date.day, tags: { year: 'said' }, said: ['year', 'month', 'day'], implied: [], notes: [], anchor: 'none', }, ]; } /** The same reading of a date, one calendar day later. */ function nextDay(date) { const [y, m, d] = addDays(date.y, date.m, date.d, 1); return { ...date, y, m, d }; } // ─── Clock times ───────────────────────────────────────────────────────── /** * The 24-hour readings of a clock time: one, or am and pm when no meridiem * settles an hour 1–12 — unless the text's form is a 24-hour clock (an ISO * instant, `clock: '24h'`), which says the hour as written. */ function hoursOf(wall) { if (wall.meridiem !== undefined) { if (wall.h < 1 || wall.h > 12) return []; return [{ h: (wall.h % 12) + (wall.meridiem === 'pm' ? 12 : 0) }]; } if (wall.clock !== '24h' && wall.h >= 1 && wall.h <= 12) { return [ { h: wall.h % 12, meridiem: 'am' }, { h: (wall.h % 12) + 12, meridiem: 'pm' }, ]; } return [{ h: wall.h }]; } const GRAIN_MS = { hour: 3_600_000, minute: 60_000, second: 1_000, }; /** * The points of one side. `dayAfter` reads its date one calendar day later — * only for the right side of an overnight range (`overnightPairs`), whose day * is the left side's. */ function pointsOf(parts, clock, nowMs, policy, dayAfter = false) { if (parts.partOfDay !== undefined) return { unsupported: 'a part of the day' }; if (parts.anchor !== undefined) return { unsupported: 'a window anchored on the previous one' }; const zones = zoneReadsOf(parts.zoneToken, clock.zone, policy); if (zones === undefined) return { needsZone: true }; const out = []; for (const zone of zones) { const points = pointsIn(parts, zone, nowMs, dayAfter); if (!Array.isArray(points)) return points; out.push(...points); } return out; } /** What a zone read adds to a point: the note naming the words, and the abbreviation's reading tag. */ function viaOf(zone) { if (zone.kind === 'offset' && zone.via === undefined) { return { notes: [{ kind: 'offset-said', offset: zone.spelled }], tags: {} }; } const via = zone.via; if (via === undefined) return { notes: [], tags: {} }; const note = { kind: 'zone-read', token: via.token, as: via.as }; if (via.as === 'place') return { notes: [note], tags: {} }; return { notes: [note], tags: { abbreviation: via.as === 'abbreviation' ? 'zone' : 'literal' } }; } /** The points of the parts under ONE zone read. */ function pointsIn(parts, zone, nowMs, dayAfter) { const read = datesOf(parts, wallIn(zone, nowMs)); if (!Array.isArray(read)) return read; const dates = dayAfter ? read.map(nextDay) : read; const zoneSaid = parts.zoneToken !== undefined ? ['zone'] : []; const zoneImplied = parts.zoneToken !== undefined ? [] : ['zone']; const via = viaOf(zone); const offsetNote = via.notes; const out = []; for (const date of dates) { const base = { zone, said: [...date.said, ...zoneSaid], implied: [...date.implied, ...zoneImplied], anchor: date.anchor, }; const wall = parts.wall; if (wall === undefined) { const from = dayStart(date.y, date.m, date.d, zone); const [ny, nm, nd] = addDays(date.y, date.m, date.d, 1); const to = dayStart(ny, nm, nd, zone); if (from === undefined || to === undefined) continue; out.push({ ...base, fromMs: from, toMs: to, grain: 'day', tags: { ...date.tags, ...via.tags }, notes: [...date.notes, ...offsetNote], }); continue; } const grain = wall.s !== undefined ? 'second' : wall.m !== undefined ? 'minute' : 'hour'; const wallSaid = [ 'hour', ...(wall.m !== undefined ? ['minute'] : []), ...(wall.s !== undefined ? ['second'] : []), ...(wall.meridiem !== undefined ? ['meridiem'] : []), ]; for (const hour of hoursOf(wall)) { const at = { year: date.y, month: date.m, day: date.d, hour: hour.h, minute: wall.m ?? 0, second: wall.s ?? 0, }; for (const instant of instantsOf(at, zone)) { out.push({ ...base, said: [...base.said, ...wallSaid], fromMs: instant.ms, toMs: instant.ms + GRAIN_MS[grain], grain, tags: { ...date.tags, ...(hour.meridiem !== undefined && { meridiem: hour.meridiem }), ...via.tags, }, notes: [...date.notes, ...offsetNote], ...(instant.dst !== undefined && { dst: instant.dst }), }); } } } return out; } // ─── Candidates ────────────────────────────────────────────────────────── const GRAIN_ORDER = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second']; const finer = (a, b) => GRAIN_ORDER.indexOf(a) >= GRAIN_ORDER.indexOf(b) ? a : b; const partsIn = (parts) => resolveRecord_js_1.PART_ORDER.filter((p) => parts.includes(p)); function noteKey(note) { return JSON.stringify(note); } function uniqueNotes(notes) { const seen = new Set(); return notes.filter((n) => { const key = noteKey(n); if (seen.has(key)) return false; seen.add(key); return true; }); } function dstNote(dst, end) { if (dst === undefined) return []; return [{ kind: dst.kind, which: dst.which, ...(end !== undefined && { end }) }]; } function spell(ms, zone) { return (0, instant_js_1.spellInstant)({ ms, nanos: 0 }, offsetFor(zone, ms)); } function windowZone(zone, clock) { return zone.kind === 'iana' ? zone.zone : zone.shownIn ?? clock.zone; } function fromPoint(point, clock) { const from = spell(point.fromMs, point.zone); const to = spell(point.toMs, point.zone); if (from === undefined || to === undefined) return undefined; const range = { from, to }; return { said: point.said, candidate: { window: { kind: 'range', range }, range, zone: windowZone(point.zone, clock), grain: point.grain, implied: partsIn(point.implied), anchor: point.anchor, notes: uniqueNotes([...point.notes, ...dstNote(point.dst), { kind: 'end-of-grain' }]), reading: point.tags, }, }; } function agrees(a, b) { const same = (x, y) => x === undefined || y === undefined || x === y; return (same(a.dateOrder, b.dateOrder) && same(a.year, b.year) && same(a.abbreviation, b.abbreviation)); } /** * Where a range's END side ends (§ 3.3): an o'clock hour ("to 9 AM") is a * boundary on the clock face and ends AT that instant; any other end — a * minute ("to 8:40" → `08:41`), a second, a whole day — runs to the end of * its grain. `widened` says which, for the `end-of-grain` note. */ function endOf(r) { return r.grain === 'hour' ? { ms: r.fromMs, widened: false } : { ms: r.toMs, widened: true }; } function fromSides(l, r, clock) { const end = endOf(r); if (!agrees(l.tags, r.tags) || !(l.fromMs < end.ms)) return undefined; const from = spell(l.fromMs, l.zone); const to = spell(end.ms, r.zone); if (from === undefined || to === undefined) return undefined; const range = { from, to }; const tags = { ...((l.tags.dateOrder ?? r.tags.dateOrder) !== undefined && { dateOrder: l.tags.dateOrder ?? r.tags.dateOrder, }), ...(l.tags.meridiem !== undefined && { meridiem: l.tags.meridiem }), ...(r.tags.meridiem !== undefined && { endMeridiem: r.tags.meridiem }), ...((l.tags.year ?? r.tags.year) !== undefined && { year: l.tags.year ?? r.tags.year }), ...((l.tags.abbreviation ?? r.tags.abbreviation) !== undefined && { abbreviation: l.tags.abbreviation ?? r.tags.abbreviation, }), }; return { said: [...l.said, ...r.said], candidate: { window: { kind: 'range', range }, range, zone: windowZone(l.zone, clock), grain: finer(l.grain, r.grain), implied: partsIn([...l.implied, ...r.implied]), anchor: l.anchor === 'message' || r.anchor === 'message' ? 'message' : 'none', notes: uniqueNotes([ ...l.notes, ...r.notes, ...dstNote(l.dst, 'from'), ...dstNote(r.dst, 'to'), ...(end.widened ? [{ kind: 'end-of-grain' }] : []), ]), reading: tags, }, }; } const LOOKBACK_LETTER = { second: 's', minute: 'm', hour: 'h', day: 'd', week: 'w', }; function lookbackBuilt(parts, clock) { const rel = parts.relative; if (rel === undefined || !('count' in rel)) return undefined; const others = Object.keys(parts).filter((k) => k !== 'relative'); if (others.length > 0) return { unsupported: 'a look-back with other parts' }; const duration = `${rel.count}${LOOKBACK_LETTER[rel.unit]}`; let range; try { range = (0, range_js_1.lookbackRange)(clock.now, duration, 'smhdw'); } catch { return { unsupported: 'a look-back past the calendar' }; } return { said: ['duration'], candidate: { window: { kind: 'lookback', duration }, range, zone: clock.zone, grain: rel.unit, implied: [], anchor: 'message', notes: [], reading: {}, }, }; } /** A range side with what it takes from the whole mention and the other side: the day, the zone. */ function sideOf(side, outer, other) { const hasDay = (p) => p.date !== undefined || p.relative !== undefined; const day = hasDay(side) ? side : hasDay(outer) ? outer : hasDay(other) ? other : undefined; const zoneToken = side.zoneToken ?? outer.zoneToken ?? other.zoneToken; return { ...(side.wall !== undefined && { wall: side.wall }), ...(side.partOfDay !== undefined && { partOfDay: side.partOfDay }), ...(side.anchor !== undefined && { anchor: side.anchor }), ...(day?.date !== undefined && { date: day.date }), ...(day?.relative !== undefined && { relative: day.relative }), ...(zoneToken !== undefined && { zoneToken }), }; } /** * A literal-offset reading that names the same window as the zone reading is * no second reading: `PST` in January IS America/Los_Angeles's −08:00. */ function withoutAgreeingLiterals(built) { const key = (b) => { const from = (0, instant_js_1.instantOf)(b.candidate.range.from, 'strict')?.ms; const to = (0, instant_js_1.instantOf)(b.candidate.range.to, 'strict')?.ms; const r = b.candidate.reading; return JSON.stringify([from, to, r.dateOrder, r.year, r.meridiem, r.endMeridiem]); }; const zoneWindows = new Set(built.filter((b) => b.candidate.reading.abbreviation === 'zone').map(key)); return built.filter((b) => b.candidate.reading.abbreviation !== 'literal' || !zoneWindows.has(key(b))); } const HALF_DAY_MS = 12 * 3_600_000; /** Every combination of a range's sides that runs forward (`fromSides`). */ function forwardPairs(lp, rp, clock) { const all = []; for (const a of lp) { for (const b of rp) { const built = fromSides(a, b, clock); if (built !== undefined) { all.push({ built, left: a.tags.meridiem, right: b.tags.meridiem, spanMs: endOf(b).ms - a.fromMs, }); } } } return all; } /** * The combinations of a range's sides that run forward. A first side said * with no meridiem takes the second side's said one ("8 to 9 PM" is 8 PM – * 9 PM, English's own rule), falling back to the other only when that does * not run forward ("11 to 1 PM" → 11 AM). */ function inheritingPairs(pairs, inherits) { const same = pairs.filter((x) => x.left === inherits); return (same.length > 0 ? same : pairs).map((x) => x.built); } /** * A range whose two sides say no meridiem SHARES one (G13): "8:45 to 8:55" * is 8:45–8:55 AM or 8:45–8:55 PM, never 8:45 AM – 8:55 PM — the half-day is * crossed only when no shared reading runs forward, because the right side * is earlier on the clock face ("11 to 1", "11:15 to 12:30"): then the range * crosses it either way — AM to PM the same day, and PM to AM overnight, * the right side read on the next day when its day is the left side's. */ function sharedMeridiemPairs(pairs, overnight) { const shared = pairs.filter((x) => x.left === x.right); if (shared.length > 0) return shared.map((x) => x.built); const crossing = [ ...pairs.filter((x) => x.left === 'am' && x.right === 'pm'), ...overnight().filter((x) => x.left === 'pm' && x.right === 'am'), ]; return crossing.map((x) => x.built); } /** A side that says an hour 1–12 with no meridiem and not on a 24-hour clock — read both ways. */ const saysNoMeridiem = (wall) => wall !== undefined && wall.meridiem === undefined && wall.clock !== '24h' && wall.h >= 1 && wall.h <= 12; /** The meridiem a first side said with none takes from the second side, when the second said one. */ function inheritedMeridiem(l, r) { return saysNoMeridiem(l.wall) ? r.wall?.meridiem : undefined; } /** A side with its own day — a date or a day word of its own, not the mention's or the other side's. */ const hasOwnDay = (side) => side.date !== undefined || side.relative !== undefined; /** Whether a mention names a zone anywhere — its own or a range side's. */ const namesZone = (parts) => parts.zoneToken !== undefined || parts.rangeOf?.[0].zoneToken !== undefined || parts.rangeOf?.[1].zoneToken !== undefined; function builtOf(parts, clock, nowMs, policy) { // The person's zone is unknown (G15): a mention that names none waits on it — even a // look-back, whose window would be shown, confirmed and converted in a zone nobody said. if (clock.zoneSource === 'unknown' && !namesZone(parts)) return { needsZone: true }; const built = builtOfAll(parts, clock, nowMs, policy); return Array.isArray(built) ? withoutAgreeingLiterals(built) : built; } function builtOfAll(parts, clock, nowMs, policy) { const lookback = lookbackBuilt(parts, clock); if (lookback !== undefined) return 'candidate' in lookback ? [lookback] : lookback; if (parts.rangeOf === undefined) { const points = pointsOf(parts, clock, nowMs, policy); if (!Array.isArray(points)) return points; return points.map((p) => fromPoint(p, clock)).filter((b) => b !== undefined); } if (parts.wall !== undefined || parts.partOfDay !== undefined || parts.anchor !== undefined) { return { unsupported: 'a range with a time outside its two sides' }; } const [l, r] = parts.rangeOf; const left = sideOf(l, parts, r); const right = sideOf(r, parts, l); if (lookbackBuilt(left, clock) !== undefined || lookbackBuilt(right, clock) !== undefined) { return { unsupported: 'a look-back as a range side' }; } const lp = pointsOf(left, clock, nowMs, policy); if (!Array.isArray(lp)) return lp; const rp = pointsOf(right, clock, nowMs, policy); if (!Array.isArray(rp)) return rp; const sameDay = forwardPairs(lp, rp, clock); // The right side read on the next day — only when its day is the left side's. const overnight = () => { if (hasOwnDay(r)) return []; const next = pointsOf(right, clock, nowMs, policy, true); return Array.isArray(next) ? forwardPairs(lp, next, clock) : []; }; if (saysNoMeridiem(l.wall) && saysNoMeridiem(r.wall)) return sharedMeridiemPairs(sameDay, overnight); // A range that runs forward on no one day ends the next one (G13's overnight law, for every // spelling): "11 PM to 1 AM", "23:00 to 1:00", "11 to 1 AM". const pairs = sameDay.length > 0 ? sameDay : withinHalfDay(overnight()); const inherits = inheritedMeridiem(l, r); if (inherits !== undefined) return inheritingPairs(pairs, inherits); return pairs.map((x) => x.built); } /** * The overnight readings that cross no more than the half-day ("11 PM to 1" → 1 AM, never 1 PM the * next day) — the bound the shared-meridiem night keeps too. A longer night is no reading: "11 AM * to 1 AM" and "8 AM to 8 AM" name no window, as before. */ function withinHalfDay(pairs) { return pairs.filter((x) => x.spanMs <= HALF_DAY_MS); } /** * Every candidate window of one mention's parses, resolved against the * clock. A reading the person must confirm (`confirm: true` — every reading * the record files, TQ29; a `model` reader's by default) carries `said: []`. * `policy` is read for its abbreviation map only (`dateOrder` and `year` * are {@link chooseReading}'s): absent, no abbreviation names a zone. */ function resolveMention(parses, clock, reader, confirm = reader.kind === 'model', policy) { const now = (0, instant_js_1.instantOf)(clock.now, 'strict'); if (now === undefined || !(0, zone_js_1.isZoneName)(clock.zone)) { throw new TypeError('resolveMention: the clock must carry a strict instant and an IANA zone name.'); } const candidates = []; const unsupported = []; let needsZone = false; parses.forEach((parts, parse) => { const built = builtOf(parts, clock, now.ms, policy); if (!Array.isArray(built)) { if ('needsZone' in built) needsZone = true; else unsupported.push(built.unsupported); return; } for (const b of built) { if (!(0, range_js_1.isTimeRange)(b.candidate.range)) continue; candidates.push({ ...b.candidate, said: confirm ? [] : partsIn(b.said), reader: { id: reader.id, kind: reader.kind }, parse, }); } }); return { candidates, needsZone, unsupported }; } exports.resolveMention = resolveMention; // ─── Choosing ──────────────────────────────────────────────────────────── const windowKey = (c) => c.window.kind === 'lookback' ? `lookback:${c.window.duration}` : `${c.range.from}/${c.range.to}`; function openQuestions(candidates, confirm) { const differs = (read) => new Set(candidates.map((c) => JSON.stringify(read(c) ?? null))).size > 1; const open = []; if (differs((c) => c.parse)) open.push('parse'); if (differs((c) => c.reading.dateOrder)) open.push('date-order'); if (differs((c) => c.reading.year)) open.push('year'); if (differs((c) => [c.reading.meridiem, c.reading.endMeridiem])) open.push('meridiem'); if (differs((c) => c.reading.abbreviation)) open.push('abbreviation'); if (differs((c) => c.notes.filter((n) => n.kind === 'dst-overlap' || n.kind === 'dst-gap'))) { open.push('dst'); } if (confirm) open.push('confirm'); return open; } /** * Whether a policy step DECIDED something: it removed a reading whose window * none of the kept readings names. A step that removed only same-window * readings (`12/12/12` under `DMY` — every order names one day) changed * nothing the person would see, so the choice is not recorded as assumed. */ function policyDecided(all, before, kept) { if (kept.length === before.length) return false; const keptWindows = new Set(kept.map((i) => windowKey(all[i]))); return before.some((i) => !kept.includes(i) && !keptWindows.has(windowKey(all[i]))); } /** * How one mention settles under the app's policy (§ 5.1, § 11). The policy * only removes readings; it never adds one. A DST choice, a meridiem, a * zone the person named that is no zone, and a reading to confirm * (`confirm` — every reading the record files, TQ29) are never settled here * — they stay `open` for the person. */ function chooseReading(resolution, policy, kind, problem, confirm = kind === 'model') { if (problem === 'unreadable') return { by: 'none', why: 'unreadable' }; if (resolution.needsZone && resolution.candidates.length === 0) { return { by: 'open', remaining: [], open: ['zone'] }; } const all = resolution.candidates; if (all.length === 0) { return { by: 'none', why: resolution.unsupported.length > 0 ? 'unsupported' : 'no-candidate' }; } const applied = {}; let remaining = all.map((_, i) => i); if (policy.dateOrder !== 'ask') { const kept = remaining.filter((i) => { const order = all[i]?.reading.dateOrder; return order === undefined || order === policy.dateOrder; }); if (policyDecided(all, remaining, kept)) applied.dateOrder = policy.dateOrder; remaining = kept; } if (policy.year !== 'ask') { const kept = remaining.filter((i) => all[i]?.reading.year !== 'previous'); if (policyDecided(all, remaining, kept)) applied.year = policy.year; remaining = kept; } if (remaining.length === 0) return { by: 'none', why: 'excluded-by-policy' }; const usedPolicy = Object.keys(applied).length > 0; const left = remaining.map((i) => all[i]); const windows = new Set(left.map(windowKey)); const zoneOpen = resolution.needsZone ? ['zone'] : []; if (windows.size === 1 && zoneOpen.length === 0) { const candidate = remaining[0]; if (confirm) { return { by: 'open', remaining, open: ['confirm'], ...(usedPolicy && { policy: applied }) }; } return usedPolicy ? { by: 'policy', candidate, policy: applied } : { by: 'only', candidate }; } const open = [...zoneOpen, ...openQuestions(left, confirm)]; return { by: 'open', remaining, open, ...(usedPolicy && { policy: applied }) }; } exports.chooseReading = chooseReading; //# sourceMappingURL=resolve.js.map