nl2time 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,7 +45,7 @@ nl2time puts a small symbolic IR in the middle of both directions:
45
45
  - **`TimeExpr`** — a JSON operator tree (`now`, `literal`, `offset`, `snap`, `span`, `seek`, `between`, `intersect`, `recur`) over timeline intervals. [Spec](docs/ir-spec.md), [JSON Schema](schema/timeexpr.schema.json).
46
46
  - **`resolve(expr, ctx)`** — deterministic evaluation against a `TimeContext` (reference instant, IANA timezone, locale, week start, ambiguity policies). Ambiguity is data: candidates come back ordered, never silently guessed.
47
47
  - **`describe(value, ctx)`** — chooses framing (calendar / elapsed / absolute), builds the IR expression that describes the value, renders it via `Intl`. Round-trip invariant: `resolve(describe(v).expr, ctx)` contains `v`.
48
- - **Everything language refers to is an interval with a grain.** "Last week" is seven days; "9pm" is an hour; "March" is a month.
48
+ - **Everything language refers to is an interval with a grain.** "Last week" is seven days; "9pm" is an hour; "March" is a month. Intervals are half-open `[start, end)`; a range with no lower bound ("orders placed before March 2022") has `start: null`.
49
49
 
50
50
  ## The LLM boundary (optional)
51
51
 
@@ -120,7 +120,7 @@ Every imported conformance case carries machine-readable provenance (upstream pr
120
120
  pip install nl2time
121
121
  ```
122
122
 
123
- The Python engine port lives in [`python/`](python/) (`whenever`-based time model): IR validation, `TimeContext`, and the full deterministic resolver at **100% bit-exact parity** with the JS reference across all 2,790 engine-parity fixtures (`corpus/ir/`), enforced in CI on every push. Parsers and `describe()` are JS-only so far — [docs/porting.md](docs/porting.md) has the strategy and the divergence gates.
123
+ The Python engine port lives in [`python/`](python/) (`whenever`-based time model): IR validation, `TimeContext`, and the full deterministic resolver at **100% bit-exact parity** with the JS reference across all 2,827 engine-parity fixtures (`corpus/ir/`), enforced in CI on every push. Parsers and `describe()` are JS-only so far — [docs/porting.md](docs/porting.md) has the strategy and the divergence gates.
124
124
 
125
125
  ## Release history
126
126
 
@@ -17,6 +17,8 @@ export interface IntervalExpect {
17
17
  /** Civil expectations, compared in the case's timeZone ("YYYY-MM-DDTHH:MM:SS"). */
18
18
  startLocal?: string;
19
19
  endLocal?: string;
20
+ /** Unbounded start ("before March 2022"): the candidate's start must be null (#32). */
21
+ openStart?: boolean;
20
22
  /** Civil point: candidate interval start must equal it. */
21
23
  pointLocal?: string;
22
24
  /** Omit or null to skip the grain check (imported cases). */
@@ -80,6 +80,15 @@ function matchesInterval(v, spec, timeZone) {
80
80
  return false;
81
81
  if (spec.grain !== undefined && spec.grain !== null && v.grain !== spec.grain)
82
82
  return false;
83
+ if ((spec.openStart === true) !== (v.start === null))
84
+ return false;
85
+ if (v.start === null) {
86
+ if (spec.end !== undefined && v.end.toString() !== spec.end)
87
+ return false;
88
+ if (spec.endLocal !== undefined && !localEq(localString(v.end, timeZone), normLocal(spec.endLocal)))
89
+ return false;
90
+ return spec.start === undefined && spec.startLocal === undefined && spec.pointLocal === undefined;
91
+ }
83
92
  if (spec.start !== undefined && v.start.toString() !== spec.start)
84
93
  return false;
85
94
  if (spec.end !== undefined && v.end.toString() !== spec.end)
@@ -177,7 +186,7 @@ function describeMismatch(candidates, tz) {
177
186
  const shown = candidates
178
187
  .slice(0, 3)
179
188
  .map((v) => v.kind === 'interval'
180
- ? `[${localString(v.start, tz)}, ${localString(v.end, tz)}) ${v.grain}`
189
+ ? `[${v.start === null ? '-inf' : localString(v.start, tz)}, ${localString(v.end, tz)}) ${v.grain}`
181
190
  : JSON.stringify(v))
182
191
  .join(' | ');
183
192
  return `got: ${shown || '(none)'}`;
@@ -17,6 +17,11 @@ export function describe(value, ctx, opts = {}) {
17
17
  if (value.kind === 'duration' || value.kind === 'amount') {
18
18
  return describeAmount(value, ctx);
19
19
  }
20
+ if (value.start === null) {
21
+ // Open start ("before March 2022", #32): describe the boundary.
22
+ const bound = describeInterval(toZoned(value.end, ctx.timeZone), value.grain, ctx, 'absolute', style);
23
+ return { ...bound, text: `before ${bound.text}`, expr: { op: 'until', end: bound.expr, exclusive: true } };
24
+ }
20
25
  const start = toZoned(value.start, ctx.timeZone);
21
26
  return describeInterval(start, value.grain, ctx, framing, style);
22
27
  }
@@ -8,9 +8,11 @@ export declare class AmbiguityError extends Error {
8
8
  constructor(message: string);
9
9
  }
10
10
  /** A resolved value. Intervals are half-open [start, end). */
11
- export type TimeValue = {
11
+ export type TimeValue =
12
+ /** `start` is null for an unbounded start ("before March 2022", #32). */
13
+ {
12
14
  kind: 'interval';
13
- start: Instant;
15
+ start: Instant | null;
14
16
  end: Instant;
15
17
  grain: Grain;
16
18
  } | {
@@ -1,4 +1,4 @@
1
- import { addUnits, calendarDaysBetween, containingUnit, floorTo, pointInterval, weekdayNumber, } from '../clock/index.js';
1
+ import { Temporal, addUnits, calendarDaysBetween, containingUnit, floorTo, pointInterval, weekdayNumber, } from '../clock/index.js';
2
2
  import { dayPeriodRules } from '../data/dayPeriods.js';
3
3
  export class NotResolvableError extends Error {
4
4
  constructor(message) {
@@ -27,6 +27,106 @@ function finerGrain(a, b) {
27
27
  return GRAIN_ORDER.indexOf(a) <= GRAIN_ORDER.indexOf(b) ? a : b;
28
28
  }
29
29
  const MAX_CANDIDATES = 8;
30
+ const isPointZi = (zi) => Temporal_compare(zi.start, zi.end) === 0;
31
+ /**
32
+ * Grain of a `between` result (#37). A range that starts on a calendar period
33
+ * and ends on a point ("since 2020" = 2020 → now) keeps the start's grain;
34
+ * a range between two points takes its grain from how the edges align ("the
35
+ * second half of 2024" = Jul 1 → Jan 1 is month-grained, while a deadline
36
+ * "by end of month" = now → Sep 1 stays 'instant'). Otherwise the finer
37
+ * operand grain, as before.
38
+ */
39
+ function rangeGrain(s, e, start, end, touchesNow) {
40
+ const sp = isPointZi(s);
41
+ const ep = isPointZi(e);
42
+ // `now` is an arbitrary instant even when a fixture pins it to midnight.
43
+ if (sp && ep && !touchesNow)
44
+ return alignedGrain(start, end);
45
+ if (!sp && ep)
46
+ return s.grain;
47
+ return finerGrain(s.grain, e.grain);
48
+ }
49
+ /**
50
+ * Clock-time semantics need a fine grain AND a sub-day extent: a multi-day
51
+ * interval whose edges are snap points ("end of year" = Jul 1 → Jan 1) is a
52
+ * calendar period, not a time of day.
53
+ */
54
+ function isTimeGrained(e) {
55
+ return (GRAIN_ORDER.indexOf(e.grain) < GRAIN_ORDER.indexOf('day') &&
56
+ (isPointZi(e) || Temporal_compare(e.start.add({ days: 1 }), e.end) > 0));
57
+ }
58
+ /** Candidates for one (start, end) operand pair of `between`. */
59
+ function betweenPair(s, e, touchesNow) {
60
+ const out = [];
61
+ const isPoint = isPointZi(e);
62
+ const timeGrained = isTimeGrained(e);
63
+ // Day-grain-or-coarser ends are conversationally inclusive: "between July
64
+ // 4th and July 10th" covers the 10th. Emit the inclusive reading (end
65
+ // operand contributes its END) first, with the strict exclusive-at-start
66
+ // reading as an alternative — the Recognizers-derived corpus pins the
67
+ // exclusive value, so both must remain candidates (#17).
68
+ if (!isPoint && !timeGrained) {
69
+ if (Temporal_compare(s.start, e.end) < 0) {
70
+ const grain = rangeGrain(s, e, s.start, e.end, touchesNow);
71
+ out.push({ type: 'interval', zi: { start: s.start, end: e.end, grain } });
72
+ }
73
+ if (Temporal_compare(s.start, e.start) < 0) {
74
+ const grain = rangeGrain(s, e, s.start, e.start, touchesNow);
75
+ out.push({ type: 'interval', zi: { start: s.start, end: e.start, grain } });
76
+ }
77
+ return out;
78
+ }
79
+ // Non-point time ends are exclusive at their *start* ("9 to 5pm" ends at
80
+ // 17:00); point ends are used directly.
81
+ let endPoint = isPoint ? e.end : e.start;
82
+ if (Temporal_compare(s.start, endPoint) >= 0) {
83
+ // A clock-time range wrapping midnight ("9:30pm to 7:30am") rolls the
84
+ // end into the next day; date ranges just drop.
85
+ if (!timeGrained)
86
+ return out;
87
+ endPoint = endPoint.add({ days: 1 });
88
+ if (Temporal_compare(s.start, endPoint) >= 0)
89
+ return out;
90
+ }
91
+ out.push({
92
+ type: 'interval',
93
+ zi: { start: s.start, end: endPoint, grain: rangeGrain(s, e, s.start, endPoint, touchesNow) },
94
+ });
95
+ return out;
96
+ }
97
+ /**
98
+ * Internal stand-in for an unbounded start; `toValue` turns it into
99
+ * `start: null`. Far enough back that no calendar arithmetic reaches it.
100
+ */
101
+ const OPEN_START = Temporal.Instant.from('0100-01-01T00:00:00Z');
102
+ /** (−∞, end) readings for an `until` whose end is not after now (#32). */
103
+ function openStartPair(e, ctx) {
104
+ const open = OPEN_START.toZonedDateTimeISO(ctx.timeZone);
105
+ if (isPointZi(e))
106
+ return [{ type: 'interval', zi: { start: open, end: e.end, grain: edgeGrain(e.end) } }];
107
+ return [
108
+ { type: 'interval', zi: { start: open, end: e.end, grain: e.grain } },
109
+ { type: 'interval', zi: { start: open, end: e.start, grain: e.grain } },
110
+ ];
111
+ }
112
+ /** Coarsest calendar boundary a single point sits on ("end of 2024" → year). */
113
+ function edgeGrain(z) {
114
+ if (Temporal_compare(z, z.startOfDay()) !== 0)
115
+ return 'instant';
116
+ if (z.day !== 1)
117
+ return 'day';
118
+ return z.month === 1 ? 'year' : 'month';
119
+ }
120
+ function alignedGrain(start, end) {
121
+ if (Temporal_compare(start, end) === 0)
122
+ return 'instant';
123
+ const atDayStart = (z) => Temporal_compare(z, z.startOfDay()) === 0;
124
+ if (!atDayStart(start) || !atDayStart(end))
125
+ return 'instant';
126
+ if (start.day !== 1 || end.day !== 1)
127
+ return 'day';
128
+ return start.month === 1 && end.month === 1 ? 'year' : 'month';
129
+ }
30
130
  export function resolve(expr, ctx) {
31
131
  const cands = evalExpr(expr, ctx, undefined).slice(0, MAX_CANDIDATES);
32
132
  return { candidates: cands.map((c) => toValue(c)) };
@@ -44,7 +144,7 @@ function toValue(c) {
44
144
  case 'interval':
45
145
  return {
46
146
  kind: 'interval',
47
- start: c.zi.start.toInstant(),
147
+ start: c.zi.start.epochMilliseconds === OPEN_START.epochMilliseconds ? null : c.zi.start.toInstant(),
48
148
  end: c.zi.end.toInstant(),
49
149
  grain: c.zi.grain,
50
150
  };
@@ -168,63 +268,38 @@ function evalExprInner(expr, ctx, anchor) {
168
268
  case 'span':
169
269
  return intervalMap(evalExpr(expr.anchor, ctx, anchor), (zi) => expr.business ? evalBusinessSpan(zi, expr.amount, ctx) : evalSpan(zi, expr.amount, ctx));
170
270
  case 'between': {
171
- const starts = evalExpr(expr.start, ctx, anchor);
271
+ const touchesNow = expr.start.op === 'now' || expr.end.op === 'now';
172
272
  const out = [];
173
- for (const s of starts) {
174
- if (s.type !== 'interval')
273
+ for (const st of evalExpr(expr.start, ctx, anchor)) {
274
+ if (st.type !== 'interval')
175
275
  continue;
176
276
  // The end operand resolves anchored to the start (shared context:
177
277
  // "between 3 and 12 of Sept" — the 3 anchors to September).
178
- for (const e of evalExpr(expr.end, ctx, s.zi)) {
278
+ for (const e of evalExpr(expr.end, ctx, st.zi)) {
179
279
  if (e.type !== 'interval')
180
280
  continue;
181
- const isPoint = Temporal_compare(e.zi.start, e.zi.end) === 0;
182
- // Clock-time semantics need a fine grain AND a sub-day extent: a
183
- // multi-day interval whose edges are snap points ("end of year" =
184
- // Jul 1 → Jan 1) carries grain 'instant' but is a calendar period,
185
- // not a time of day.
186
- const timeGrained = GRAIN_ORDER.indexOf(e.zi.grain) < GRAIN_ORDER.indexOf('day') &&
187
- (isPoint || Temporal_compare(e.zi.start.add({ days: 1 }), e.zi.end) > 0);
188
- // Day-grain-or-coarser ends are conversationally inclusive:
189
- // "between July 4th and July 10th" covers the 10th. Emit the
190
- // inclusive reading (end operand contributes its END) first, with
191
- // the strict exclusive-at-start reading as an alternative — the
192
- // Recognizers-derived corpus pins the exclusive value, so both
193
- // must remain candidates (#17).
194
- if (!isPoint && !timeGrained) {
195
- const grain = finerGrain(s.zi.grain, e.zi.grain);
196
- if (Temporal_compare(s.zi.start, e.zi.end) < 0) {
197
- out.push({ type: 'interval', zi: { start: s.zi.start, end: e.zi.end, grain } });
198
- }
199
- if (Temporal_compare(s.zi.start, e.zi.start) < 0) {
200
- out.push({ type: 'interval', zi: { start: s.zi.start, end: e.zi.start, grain } });
201
- }
202
- continue;
203
- }
204
- // Non-point time ends are exclusive at their *start* ("9 to 5pm"
205
- // ends at 17:00); point ends are used directly.
206
- let endPoint = isPoint ? e.zi.end : e.zi.start;
207
- if (Temporal_compare(s.zi.start, endPoint) >= 0) {
208
- // A clock-time range wrapping midnight ("9:30pm to 7:30am") rolls
209
- // the end into the next day; date ranges just drop.
210
- if (!timeGrained)
211
- continue;
212
- endPoint = endPoint.add({ days: 1 });
213
- if (Temporal_compare(s.zi.start, endPoint) >= 0)
214
- continue;
215
- }
216
- out.push({
217
- type: 'interval',
218
- zi: {
219
- start: s.zi.start,
220
- end: endPoint,
221
- grain: finerGrain(s.zi.grain, e.zi.grain),
222
- },
223
- });
281
+ out.push(...betweenPair(st.zi, e.zi, touchesNow));
224
282
  }
225
283
  }
226
284
  return out;
227
285
  }
286
+ case 'until': {
287
+ // "by Friday", "until 2025-06-30", "before March 2022" (#32): a
288
+ // deadline [now, end) for the end candidates still ahead of now. Only
289
+ // when none is ahead does it read as the stretch of history up to the
290
+ // end, with an unbounded start. Clock-time ends always keep the
291
+ // deadline reading (they roll to the next day like any time range).
292
+ const now = pointInterval(ctx.zonedNow);
293
+ const ends = evalExpr(expr.end, ctx, anchor).flatMap((e) => (e.type === 'interval' ? [e.zi] : []));
294
+ const ahead = ends.filter((e) => Temporal_compare(e.end, ctx.zonedNow) > 0 || (!isPointZi(e) && isTimeGrained(e)));
295
+ const out = [];
296
+ for (const e of ahead.length > 0 ? ahead : ends) {
297
+ const pair = ahead.length > 0 ? betweenPair(now, e, true) : openStartPair(e, ctx);
298
+ // "before X" excludes X: its exclusive reading leads.
299
+ out.push(...(expr.exclusive && pair.length === 2 ? [pair[1], pair[0]] : pair));
300
+ }
301
+ return out;
302
+ }
228
303
  case 'seek':
229
304
  return evalSeek(expr, ctx, anchor);
230
305
  case 'intersect': {
@@ -398,7 +473,25 @@ function evalSeek(expr, ctx, anchor) {
398
473
  out.push({ type: 'interval', zi });
399
474
  continue;
400
475
  }
401
- const zi = seekFrom(b.zi, expr, ctx);
476
+ // Deictic "this Friday" / "this weekend" (#23): the occurrence on or
477
+ // after the base day leads, whatever the bias; the previous one follows.
478
+ // It leads instead when it is the weekend still in progress (Sunday)
479
+ // or, under bias 'past', when it was at most 3 days ago ("this weekend"
480
+ // said on Monday or Tuesday).
481
+ if (expr.dir === 'this' && expr.target.kind === 'weekday' && (b.zi.grain === 'instant' || b.zi.grain === 'day')) {
482
+ const today = floorTo(b.zi.start, 'day', ctx.weekStart);
483
+ const target = weekdayNumber(expr.target.weekday);
484
+ const upDelta = (target - today.dayOfWeek + 7) % 7;
485
+ const backDelta = ((today.dayOfWeek - target + 6) % 7) + 1;
486
+ const up = today.add({ days: upDelta });
487
+ const prev = today.subtract({ days: backDelta });
488
+ const prevLeads = (target === 6 && backDelta === 1) || (ctx.bias === 'past' && backDelta <= 3);
489
+ for (const d of prevLeads ? [prev, up] : [up, prev]) {
490
+ out.push({ type: 'interval', zi: { start: d, end: d.add({ days: 1 }), grain: 'day' } });
491
+ }
492
+ continue;
493
+ }
494
+ const zi = seekFrom(b.zi, expr.dir === 'this' ? { ...expr, dir: 'nearest' } : expr, ctx);
402
495
  if (zi)
403
496
  out.push({ type: 'interval', zi });
404
497
  }
@@ -623,9 +716,12 @@ function resolveDate(date, ctx, anchor) {
623
716
  if (date.month !== undefined && date.day !== undefined) {
624
717
  // "May 29" with no year → the most recent occurrence starting on-or-
625
718
  // before today, then the following one ("past + future" pairing).
626
- const mk = (yearDelta) => {
719
+ // Occurrence k walks over years where the date exists, so "Feb 29"
720
+ // lands on leap years instead of clamping to Feb 28 (#14).
721
+ const valid = (d) => ref.add({ years: d }).with({ month: date.month, day: 1 }).daysInMonth >= date.day;
722
+ const mk = (k) => {
627
723
  const day = ref
628
- .add({ years: yearDelta })
724
+ .add({ years: nthValidDelta(k, valid) })
629
725
  .with({ month: date.month, day: date.day })
630
726
  .startOfDay();
631
727
  return { start: day, end: day.add({ days: 1 }), grain: 'day' };
@@ -646,8 +742,13 @@ function resolveDate(date, ctx, anchor) {
646
742
  }
647
743
  if (date.day !== undefined) {
648
744
  // "the 3rd" → most recent occurrence on-or-before today, then the next.
649
- const mk = (monthDelta) => {
650
- const day = ref.add({ months: monthDelta }).with({ day: date.day }).startOfDay();
745
+ // Skip months too short for the day ("the 31st" never means the 30th).
746
+ const valid = (d) => ref.add({ months: d }).with({ day: 1 }).daysInMonth >= date.day;
747
+ const mk = (k) => {
748
+ const day = ref
749
+ .add({ months: nthValidDelta(k, valid) })
750
+ .with({ day: date.day })
751
+ .startOfDay();
651
752
  return { start: day, end: day.add({ days: 1 }), grain: 'day' };
652
753
  };
653
754
  if (ctx.bias === 'none') {
@@ -660,6 +761,24 @@ function resolveDate(date, ctx, anchor) {
660
761
  const day = floorTo(ref, 'day', ctx.weekStart);
661
762
  return [{ start: day, end: day.add({ days: 1 }), grain: 'day' }];
662
763
  }
764
+ /**
765
+ * Map occurrence index k to a calendar delta, counting only deltas where
766
+ * `valid` holds: index 0 is the first valid delta >= 0, k > 0 steps forward
767
+ * and k < 0 steps back from it. With every delta valid this is the identity.
768
+ */
769
+ function nthValidDelta(k, valid) {
770
+ const LIMIT = 16; // leap years skip at most 8 years; month lengths at most 2
771
+ let d = 0;
772
+ for (let g = 0; !valid(d) && g < LIMIT; g++)
773
+ d++;
774
+ const step = k > 0 ? 1 : -1;
775
+ for (let i = 0; i < Math.abs(k); i++) {
776
+ d += step;
777
+ for (let g = 0; !valid(d) && g < LIMIT; g++)
778
+ d += step;
779
+ }
780
+ return d;
781
+ }
663
782
  /**
664
783
  * Order the this-period occurrence and its neighbor under the context bias:
665
784
  * 'future' → soonest occurrence not in the past first; 'past' → most recent
@@ -108,6 +108,17 @@ export type TimeExprBody =
108
108
  start: TimeExpr;
109
109
  end: TimeExpr;
110
110
  }
111
+ /**
112
+ * The stretch up to `end` ("by Friday", "until 2025-06-30", "before March
113
+ * 2022"): [now, end) while `end` is ahead of now, else (−∞, end) with an
114
+ * unbounded start. `exclusive` ("before") ranks the reading that stops at
115
+ * the start of `end` first.
116
+ */
117
+ | {
118
+ op: 'until';
119
+ end: TimeExpr;
120
+ exclusive?: boolean;
121
+ }
111
122
  /**
112
123
  * Directed calendar navigation: seek(now, 'next', weekday tue) is "next
113
124
  * Tuesday" (semantics of 'next' controlled by context nextWeekday policy);
@@ -116,7 +127,7 @@ export type TimeExprBody =
116
127
  | {
117
128
  op: 'seek';
118
129
  base: TimeExpr;
119
- dir: 'next' | 'prev' | 'nearest';
130
+ dir: 'next' | 'prev' | 'nearest' | 'this';
120
131
  target: Target;
121
132
  n?: number;
122
133
  }
@@ -8,7 +8,7 @@ export class IRValidationError extends Error {
8
8
  }
9
9
  }
10
10
  const MODS = ['approx', 'start', 'mid', 'end'];
11
- const DIRS = ['next', 'prev', 'nearest'];
11
+ const DIRS = ['next', 'prev', 'nearest', 'this'];
12
12
  /**
13
13
  * Structural validation of untrusted input (LLM output, stored IR, network)
14
14
  * into a typed TimeExpr. Mirrors schema/timeexpr.schema.json; the JSON Schema
@@ -66,10 +66,17 @@ export function validateExpr(input, path = '$') {
66
66
  validateExpr(obj.end, `${path}.end`);
67
67
  return obj;
68
68
  }
69
+ case 'until': {
70
+ validateExpr(obj.end, `${path}.end`);
71
+ if (obj.exclusive !== undefined && typeof obj.exclusive !== 'boolean') {
72
+ throw new IRValidationError('exclusive must be boolean', `${path}.exclusive`);
73
+ }
74
+ return obj;
75
+ }
69
76
  case 'seek': {
70
77
  validateExpr(obj.base, `${path}.base`);
71
78
  if (!DIRS.includes(obj.dir)) {
72
- throw new IRValidationError('dir must be next|prev|nearest', `${path}.dir`);
79
+ throw new IRValidationError('dir must be next|prev|nearest|this', `${path}.dir`);
73
80
  }
74
81
  validateTarget(obj.target, `${path}.target`);
75
82
  if (obj.n !== undefined)
package/dist/parse/en.js CHANGED
@@ -156,8 +156,23 @@ const TIME_PREPOSITIONS = new Set([
156
156
  ]);
157
157
  // --- rules -----------------------------------------------------------------
158
158
  /** today / yesterday / tomorrow / tonight / day before yesterday / day after tomorrow */
159
- const ruleDeicticDay = (tokens, i) => {
159
+ const ruleDeicticDay = (tokens, i, ctx) => {
160
160
  const w = word(tokens[i]);
161
+ // "overnight" (#23): the night just ended, or tonight once it is afternoon
162
+ // and the context looks forward (bias 'future', or 'none' after noon). A
163
+ // night still in progress is "last night" (the night period wraps midnight).
164
+ if (w === 'overnight') {
165
+ const tonight = ctx.bias === 'future' || (ctx.bias === 'none' && ctx.zonedNow.hour >= 12);
166
+ return {
167
+ expr: {
168
+ op: 'intersect',
169
+ parts: [tonight ? snapNow('day') : snapOffset(-1, 'day'), { op: 'literal', dayPeriod: 'night' }],
170
+ },
171
+ consumed: 1,
172
+ confidence: 0.85,
173
+ role: 'datetime',
174
+ };
175
+ }
161
176
  if (w === 'today' || w === 'tdy')
162
177
  return { expr: snapNow('day'), consumed: 1, confidence: 1, role: 'date' };
163
178
  if (w === 'yesterday')
@@ -416,30 +431,33 @@ const ruleRange = (tokens, i, ctx) => {
416
431
  }
417
432
  return { expr, consumed: end - i, confidence: 0.95, role: rangeRole(a, b) };
418
433
  };
434
+ /** "end of 2024" / "beginning of the year 2024" → the year's boundary point. */
435
+ function readYearEdge(tokens, i) {
436
+ const w0 = word(tokens[i]);
437
+ if ((w0 !== 'end' && w0 !== 'beginning' && w0 !== 'start') || word(tokens[i + 1]) !== 'of')
438
+ return undefined;
439
+ let yAt = i + 2;
440
+ if (word(tokens[yAt]) === 'the')
441
+ yAt += 1;
442
+ const year = yearAt(tokens, yAt);
443
+ if (year === undefined)
444
+ return undefined;
445
+ return {
446
+ expr: { op: 'snap', base: { op: 'literal', date: { year } }, unit: 'year', edge: w0 === 'end' ? 'end' : 'start' },
447
+ consumed: yAt + 1 - i,
448
+ };
449
+ }
419
450
  function readOperand(tokens, i, ctx) {
420
451
  // "between the end of 2007 and the end of 2008" — inside a range, "end of
421
452
  // YEAR" means the year's end boundary (a point), not its final third.
422
- const w0 = word(tokens[i]);
423
- if ((w0 === 'end' || w0 === 'beginning' || w0 === 'start') && word(tokens[i + 1]) === 'of') {
424
- let yAt = i + 2;
425
- if (word(tokens[yAt]) === 'the')
426
- yAt += 1;
427
- const year = yearAt(tokens, yAt);
428
- if (year !== undefined) {
429
- return {
430
- operand: {
431
- kind: 'date',
432
- expr: {
433
- op: 'snap',
434
- base: { op: 'literal', date: { year } },
435
- unit: 'year',
436
- edge: w0 === 'end' ? 'end' : 'start',
437
- },
438
- },
439
- consumed: yAt + 1 - i,
440
- };
441
- }
442
- }
453
+ // "through the end of 2024" (#32): an article before the boundary word.
454
+ const lead = word(tokens[i]) === 'the' ? 1 : 0;
455
+ const edge = readYearEdge(tokens, i + lead);
456
+ if (edge)
457
+ return { operand: { kind: 'date', expr: edge.expr }, consumed: lead + edge.consumed };
458
+ const wdd = readWeekdayDate(tokens, i, ctx);
459
+ if (wdd)
460
+ return { operand: { kind: 'date', expr: wdd.expr }, consumed: wdd.consumed };
443
461
  const rel = ruleLastThisNext(tokens, i, ctx);
444
462
  if (rel && rel.role === 'date') {
445
463
  // "this friday 5/12": an explicit date after the weekday supersedes it.
@@ -638,6 +656,18 @@ const ruleOpenRange = (tokens, i, ctx) => {
638
656
  (ruleCalendarDate(tokens, at + 1, ctx) || ruleEndOf(tokens, at + 1, ctx))) {
639
657
  at += 1;
640
658
  }
659
+ // "through the end of 2024" (#32): a year's end is its boundary point here,
660
+ // as in range operands, not the year's late part.
661
+ const yearEdge = readYearEdge(tokens, word(tokens[at]) === 'the' ? at + 1 : at);
662
+ if (yearEdge && mode === 'until') {
663
+ const consumed = (word(tokens[at]) === 'the' ? 1 : 0) + yearEdge.consumed;
664
+ return {
665
+ expr: { op: 'until', end: yearEdge.expr, ...(w === 'before' ? { exclusive: true } : {}) },
666
+ consumed: at + consumed - i,
667
+ confidence: 0.9,
668
+ role: 'datetime',
669
+ };
670
+ }
641
671
  const inner = ruleDeicticDay(tokens, at, ctx) ??
642
672
  ruleLastThisNext(tokens, at, ctx) ??
643
673
  ruleHoliday(tokens, at, ctx) ??
@@ -669,19 +699,97 @@ const ruleOpenRange = (tokens, i, ctx) => {
669
699
  target = target.start;
670
700
  }
671
701
  }
702
+ // Deadlines become `until`: [now, X) while X is ahead, else the history
703
+ // up to X with an unbounded start ("orders before March 2022", #32).
672
704
  const expr = mode === 'since'
673
705
  ? { op: 'between', start: target, end: NOW }
674
- : { op: 'between', start: NOW, end: target };
706
+ : { op: 'until', end: target, ...(w === 'before' ? { exclusive: true } : {}) };
675
707
  return { expr, consumed: at + inner.consumed - i, confidence: 0.9, role: 'datetime' };
676
708
  };
677
709
  /** "week of April 10th", "the first/last week of July", "the week starting on Feb 4". */
710
+ /**
711
+ * A weekday written next to an explicit month-day ("Thursday 2026-08-20",
712
+ * "Monday, March 3, 2025") checks the date rather than naming a second,
713
+ * relative weekday: the date wins (#34). A weekday that contradicts a full
714
+ * date keeps the date at lowered confidence.
715
+ */
716
+ function readWeekdayDate(tokens, i, ctx) {
717
+ const w = word(tokens[i]);
718
+ const weekday = w !== undefined ? WEEKDAY_WORDS[w] : undefined;
719
+ if (!weekday)
720
+ return undefined;
721
+ const d = ruleCalendarDate(tokens, i + 1, ctx);
722
+ if (!d || d.role !== 'date' || d.expr.op !== 'literal')
723
+ return undefined;
724
+ const date = d.expr.date;
725
+ if (!date || date.month === undefined || date.day === undefined)
726
+ return undefined;
727
+ let confidence = Math.max(d.confidence, 0.9);
728
+ if (date.year !== undefined && isoWeekday(date.year, date.month, date.day) !== weekday)
729
+ confidence = 0.6;
730
+ return { expr: d.expr, consumed: 1 + d.consumed, confidence };
731
+ }
732
+ const WEEKDAY_BY_ISO = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
733
+ /** Weekday of a proleptic Gregorian date (Sakamoto), without Temporal. */
734
+ function isoWeekday(year, month, day) {
735
+ const t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
736
+ const y = month < 3 ? year - 1 : year;
737
+ const sun0 = (y + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) + t[month - 1] + day) % 7;
738
+ return WEEKDAY_BY_ISO[(sun0 + 6) % 7];
739
+ }
740
+ /** "Thursday 2026-08-20" → the date (#34). */
741
+ const ruleWeekdayDate = (tokens, i, ctx) => {
742
+ const r = readWeekdayDate(tokens, i, ctx);
743
+ return r ? { expr: r.expr, consumed: r.consumed, confidence: r.confidence, role: 'date' } : undefined;
744
+ };
745
+ /**
746
+ * "twelve months ended June", "the 12 months ending June 30, 2026", "the
747
+ * three years ended 2025" (#36): the N units up to and including the date's
748
+ * period — span(date, -N units), i.e. [end(date) − N units, end(date)).
749
+ */
750
+ const ruleNUnitsEnded = (tokens, i, ctx) => {
751
+ let at = i;
752
+ if (word(tokens[at]) === 'the')
753
+ at += 1;
754
+ const n = readNumber(tokens, at);
755
+ if (!n || !Number.isInteger(n.value) || n.value < 1)
756
+ return undefined;
757
+ at += n.consumed;
758
+ const unit = UNIT_WORDS[word(tokens[at]) ?? ''];
759
+ if (unit !== 'day' && unit !== 'week' && unit !== 'month' && unit !== 'quarter' && unit !== 'year')
760
+ return undefined;
761
+ at += 1;
762
+ const conn = word(tokens[at]);
763
+ if (conn !== 'ended' && conn !== 'ending')
764
+ return undefined;
765
+ at += 1;
766
+ if (word(tokens[at]) === 'on' || word(tokens[at]) === 'in')
767
+ at += 1;
768
+ if (word(tokens[at]) === 'the')
769
+ at += 1;
770
+ const year = yearAt(tokens, at);
771
+ const inner = ruleCalendarDate(tokens, at, ctx) ??
772
+ ruleLastThisNext(tokens, at, ctx) ??
773
+ (year !== undefined
774
+ ? { expr: { op: 'literal', date: { year } }, consumed: 1, confidence: 0.85, role: 'date' }
775
+ : undefined);
776
+ if (!inner || inner.role !== 'date')
777
+ return undefined;
778
+ const amount = unit === 'quarter' ? { months: -3 * n.value } : { [`${unit}s`]: -n.value };
779
+ return {
780
+ expr: { op: 'span', anchor: inner.expr, amount },
781
+ consumed: at + inner.consumed - i,
782
+ confidence: 0.95,
783
+ role: 'date',
784
+ };
785
+ };
678
786
  const ruleWeekOf = (tokens, i, ctx) => {
679
787
  let at = i;
680
788
  if (word(tokens[at]) === 'the')
681
789
  at += 1;
682
790
  // "w/c Feb 4" (week commencing)
683
791
  if (word(tokens[at]) === 'w/c' || word(tokens[at]) === 'wc') {
684
- const date = ruleCalendarDate(tokens, at + 1, ctx);
792
+ const date = ruleWeekdayDate(tokens, at + 1, ctx) ?? ruleCalendarDate(tokens, at + 1, ctx);
685
793
  if (date && date.role === 'date') {
686
794
  return {
687
795
  expr: { op: 'snap', base: date.expr, unit: 'week' },
@@ -696,7 +804,7 @@ const ruleWeekOf = (tokens, i, ctx) => {
696
804
  let dAt = at + 2;
697
805
  if (word(tokens[dAt]) === 'on' || word(tokens[dAt]) === 'from')
698
806
  dAt += 1;
699
- const date = ruleCalendarDate(tokens, dAt, ctx) ?? ruleDeicticDay(tokens, dAt, ctx);
807
+ const date = ruleWeekdayDate(tokens, dAt, ctx) ?? ruleCalendarDate(tokens, dAt, ctx) ?? ruleDeicticDay(tokens, dAt, ctx);
700
808
  if (date && date.role === 'date') {
701
809
  return {
702
810
  expr: { op: 'snap', base: date.expr, unit: 'week' },
@@ -739,7 +847,7 @@ const ruleWeekOf = (tokens, i, ctx) => {
739
847
  let scope;
740
848
  let consumed = 0;
741
849
  const year = yearAt(tokens, at);
742
- const date = ruleCalendarDate(tokens, at, ctx);
850
+ const date = ruleWeekdayDate(tokens, at, ctx) ?? ruleCalendarDate(tokens, at, ctx);
743
851
  if (date) {
744
852
  scope = date.expr;
745
853
  consumed = date.consumed;
@@ -1013,11 +1121,20 @@ const ruleHalfOf = (tokens, i, ctx) => {
1013
1121
  }
1014
1122
  if (which === undefined)
1015
1123
  return undefined;
1016
- if (word(tokens[at + 1]) !== 'half' || word(tokens[at + 2]) !== 'of')
1124
+ if (word(tokens[at + 1]) !== 'half')
1017
1125
  return undefined;
1018
- at += 3;
1019
- if (word(tokens[at]) === 'the')
1020
- at += 1;
1126
+ if (word(tokens[at + 2]) === 'of') {
1127
+ at += 3;
1128
+ if (word(tokens[at]) === 'the')
1129
+ at += 1;
1130
+ }
1131
+ else if (yearAt(tokens, at + 2) !== undefined) {
1132
+ // "first half 2025" (#36): no "of" before a bare year.
1133
+ at += 2;
1134
+ }
1135
+ else {
1136
+ return undefined;
1137
+ }
1021
1138
  // Scope: a bare unit ("the year"), a relative period ("next month"), a
1022
1139
  // month name, a quarter, or a year.
1023
1140
  let scope;
@@ -1030,9 +1147,13 @@ const ruleHalfOf = (tokens, i, ctx) => {
1030
1147
  consumed = 1;
1031
1148
  }
1032
1149
  else {
1150
+ const bareYear = yearAt(tokens, at);
1033
1151
  const inner = ruleLastThisNext(tokens, at, ctx) ??
1034
1152
  ruleBareYear(tokens, at, ctx) ??
1035
- ruleCalendarDate(tokens, at, ctx);
1153
+ ruleCalendarDate(tokens, at, ctx) ??
1154
+ (bareYear !== undefined
1155
+ ? { expr: { op: 'literal', date: { year: bareYear } }, consumed: 1, confidence: 0.85, role: 'date' }
1156
+ : undefined);
1036
1157
  if (inner && inner.role === 'date') {
1037
1158
  unit = halfScopeUnit(inner.expr);
1038
1159
  if (unit) {
@@ -1063,7 +1184,8 @@ const ruleHalfOf = (tokens, i, ctx) => {
1063
1184
  };
1064
1185
  const REL_SYNONYMS = {
1065
1186
  last: 'last', previous: 'last', prior: 'last',
1066
- this: 'this', current: 'this', present: 'this', that: 'this',
1187
+ // Not "that": "that year" points back into the conversation, not at now (#35).
1188
+ this: 'this', current: 'this', present: 'this',
1067
1189
  next: 'next', coming: 'next', upcoming: 'next', following: 'next',
1068
1190
  };
1069
1191
  function weekendExpr(dir) {
@@ -1103,7 +1225,8 @@ const ruleLastThisNext = (tokens, i, ctx) => {
1103
1225
  return undefined;
1104
1226
  }
1105
1227
  if (nextWord === 'weekend') {
1106
- const dir = w === 'last' ? 'prev' : w === 'next' ? 'next' : 'nearest';
1228
+ // "this weekend" is deictic: the current/upcoming one leads (#23).
1229
+ const dir = w === 'last' ? 'prev' : w === 'next' ? 'next' : 'this';
1107
1230
  return { expr: weekendExpr(dir), consumed: 2, confidence: 1, role: 'date' };
1108
1231
  }
1109
1232
  if (nextWord === 'workweek' || ((nextWord === 'work' || nextWord === 'working') && word(tokens[i + 2]) === 'week')) {
@@ -1140,11 +1263,12 @@ const ruleLastThisNext = (tokens, i, ctx) => {
1140
1263
  }
1141
1264
  const weekday = WEEKDAY_WORDS[nextWord];
1142
1265
  if (weekday) {
1143
- const dir = w === 'last' ? 'prev' : w === 'next' ? 'next' : 'nearest';
1266
+ // "this Friday" is deictic: today-or-next leads, whatever the bias (#23).
1267
+ const dir = w === 'last' ? 'prev' : w === 'next' ? 'next' : 'this';
1144
1268
  // Only the literal words "next"/"last" carry the dialect ambiguity;
1145
1269
  // "upcoming Friday" is strictly the adjacent occurrence (n: 1).
1146
1270
  const literalWord = word(tokens[i]);
1147
- const strict = (literalWord === 'upcoming' || literalWord === 'coming') && dir !== 'nearest';
1271
+ const strict = (literalWord === 'upcoming' || literalWord === 'coming') && dir !== 'this';
1148
1272
  return {
1149
1273
  expr: {
1150
1274
  op: 'seek',
@@ -1411,6 +1535,101 @@ const ruleNextN = (tokens, i) => {
1411
1535
  };
1412
1536
  };
1413
1537
  /** "the year [2008]", "the week [31]", "the month", "the day" — with ISO week numbers. */
1538
+ /** Words that open a relative clause after "the <unit>" ("the year we …"). */
1539
+ const RELATIVE_CLAUSE_STARTS = new Set([
1540
+ 'the', 'a', 'an', 'we', 'i', 'you', 'they', 'he', 'she', 'it', 'that', 'which', 'when', 'where', 'our',
1541
+ 'my', 'your', 'their', 'his', 'her', 'its', 'this', 'these', 'those', 'in', 'during', 'after', 'before',
1542
+ ]);
1543
+ /**
1544
+ * "year to date", "YTD", "so far this month", "this quarter so far" (#36):
1545
+ * from the start of the current period up to now — the period itself as the
1546
+ * start operand keeps the period's grain. Ends at now, not at the start of
1547
+ * today, whatever `partialPeriod` says (owner decision D3).
1548
+ */
1549
+ function toDate(unit) {
1550
+ return { op: 'between', start: snapNow(unit), end: NOW };
1551
+ }
1552
+ const TO_DATE_ABBREV = { ytd: 'year', qtd: 'quarter', mtd: 'month', wtd: 'week' };
1553
+ const ruleToDate = (tokens, i) => {
1554
+ const w = word(tokens[i]);
1555
+ if (w !== undefined && TO_DATE_ABBREV[w]) {
1556
+ return { expr: toDate(TO_DATE_ABBREV[w]), consumed: 1, confidence: 0.95, role: 'date' };
1557
+ }
1558
+ const periodUnit = (u) => {
1559
+ const unit = UNIT_WORDS[u ?? ''];
1560
+ return unit === 'year' || unit === 'quarter' || unit === 'month' || unit === 'week' || unit === 'day'
1561
+ ? unit
1562
+ : undefined;
1563
+ };
1564
+ // "year to date" / "year-to-date" (the article form is in ruleTheUnit).
1565
+ const u0 = periodUnit(w);
1566
+ if (u0) {
1567
+ let at = i + 1;
1568
+ if (word(tokens[at]) === '-')
1569
+ at += 1;
1570
+ if (word(tokens[at]) === 'to') {
1571
+ at += 1;
1572
+ if (word(tokens[at]) === '-')
1573
+ at += 1;
1574
+ if (word(tokens[at]) === 'date')
1575
+ return { expr: toDate(u0), consumed: at + 1 - i, confidence: 0.95, role: 'date' };
1576
+ }
1577
+ }
1578
+ // "so far this year" / "so far in the month"
1579
+ if (w === 'so' && word(tokens[i + 1]) === 'far') {
1580
+ let at = i + 2;
1581
+ if (word(tokens[at]) === 'in' || word(tokens[at]) === 'during')
1582
+ at += 1;
1583
+ if (word(tokens[at]) === 'this' || word(tokens[at]) === 'the') {
1584
+ const u = periodUnit(word(tokens[at + 1]));
1585
+ if (u)
1586
+ return { expr: toDate(u), consumed: at + 2 - i, confidence: 0.95, role: 'date' };
1587
+ }
1588
+ }
1589
+ // "this year so far" / "this month to date"
1590
+ if (w === 'this') {
1591
+ const u = periodUnit(word(tokens[i + 1]));
1592
+ const tail = word(tokens[i + 2]);
1593
+ if (u && ((tail === 'so' && word(tokens[i + 3]) === 'far') || (tail === 'to' && word(tokens[i + 3]) === 'date'))) {
1594
+ return { expr: toDate(u), consumed: 4, confidence: 0.95, role: 'date' };
1595
+ }
1596
+ }
1597
+ return undefined;
1598
+ };
1599
+ /**
1600
+ * "trailing twelve months", "TTM", "LTM", "the trailing 4 quarters" (#36):
1601
+ * the N most recent COMPLETE units before the current one — [start of the
1602
+ * current unit − N, start of the current unit). The finance/BI convention
1603
+ * (complete periods compare like with like); "the past 12 months" stays the
1604
+ * rolling window.
1605
+ */
1606
+ const ruleTrailing = (tokens, i) => {
1607
+ const w = word(tokens[i]);
1608
+ const trailing = (unit, n, consumed) => ({
1609
+ expr: {
1610
+ op: 'span',
1611
+ anchor: { op: 'snap', base: NOW, unit, edge: 'start' },
1612
+ amount: unit === 'quarter' ? { months: -3 * n } : { [`${unit}s`]: -n },
1613
+ },
1614
+ consumed,
1615
+ confidence: 0.95,
1616
+ role: 'date',
1617
+ });
1618
+ if (w === 'ttm' || w === 'ltm')
1619
+ return trailing('month', 12, 1);
1620
+ let at = i;
1621
+ if (w === 'the')
1622
+ at += 1;
1623
+ if (word(tokens[at]) !== 'trailing')
1624
+ return undefined;
1625
+ const n = readNumber(tokens, at + 1);
1626
+ if (!n || !Number.isInteger(n.value) || n.value < 1)
1627
+ return undefined;
1628
+ const unit = UNIT_WORDS[word(tokens[at + 1 + n.consumed]) ?? ''];
1629
+ if (unit !== 'day' && unit !== 'week' && unit !== 'month' && unit !== 'quarter' && unit !== 'year')
1630
+ return undefined;
1631
+ return trailing(unit, n.value, at + 2 + n.consumed - i);
1632
+ };
1414
1633
  const ruleTheUnit = (tokens, i) => {
1415
1634
  if (word(tokens[i]) !== 'the')
1416
1635
  return undefined;
@@ -1446,14 +1665,17 @@ const ruleTheUnit = (tokens, i) => {
1446
1665
  const unit = UNIT_WORDS[uw];
1447
1666
  if (!unit || unit === 'second' || unit === 'minute' || unit === 'hour')
1448
1667
  return undefined;
1668
+ // "over the years" is generic, and "the year the invoice was posted" /
1669
+ // "the month we launched" name a period by a clause, not the current one:
1670
+ // no match rather than a confident "this year" (#35).
1671
+ if (uw.endsWith('s') && UNIT_WORDS[uw.slice(0, -1)] === unit)
1672
+ return undefined;
1673
+ if (RELATIVE_CLAUSE_STARTS.has(word(tokens[i + 2]) ?? ''))
1674
+ return undefined;
1449
1675
  // "the year to date", "the month to date"
1450
1676
  if (word(tokens[i + 2]) === 'to' && word(tokens[i + 3]) === 'date') {
1451
1677
  return {
1452
- expr: {
1453
- op: 'between',
1454
- start: { op: 'snap', base: NOW, unit, edge: 'start' },
1455
- end: { op: 'snap', base: NOW, unit: 'day', edge: 'start' },
1456
- },
1678
+ expr: toDate(unit),
1457
1679
  consumed: 4,
1458
1680
  confidence: 1,
1459
1681
  role: 'date',
@@ -1926,11 +2148,28 @@ const ruleBareYear = (tokens, i) => {
1926
2148
  if (!y || y.value < 1500 || y.value > 2199)
1927
2149
  return undefined;
1928
2150
  const prev = word(tokens[i - 1]);
1929
- if (prev !== undefined && !['in', 'since', 'by', 'until', 'during', 'year', 'of', 'from'].includes(prev)) {
1930
- return undefined;
2151
+ // "2024 or 2025", "in 2024 and 2025" (#35): the second year of a
2152
+ // coordination is its own match instead of being dropped.
2153
+ const coordinated = prev !== undefined && ['or', 'and', '&', 'vs', 'versus'].includes(prev) && yearAt(tokens, i - 2) !== undefined;
2154
+ if (prev !== undefined &&
2155
+ !coordinated &&
2156
+ !['in', 'since', 'by', 'until', 'during', 'year', 'of', 'from', 'for', 'calendar', 'cy', 'throughout'].includes(prev)) {
2157
+ return undefined;
2158
+ }
2159
+ // "for 2024" / "revenue for 2024" (#36) — but not "for 2000 people": after
2160
+ // "for", the year must end the phrase or be followed by a function word.
2161
+ if (prev === 'for') {
2162
+ const next = tokens[i + y.consumed];
2163
+ const nw = word(next);
2164
+ if (next !== undefined && (nw === undefined || !YEAR_FOLLOWERS.has(nw)))
2165
+ return undefined;
1931
2166
  }
1932
2167
  return { expr: { op: 'literal', date: { year: y.value } }, consumed: y.consumed, confidence: 0.85, role: 'date' };
1933
2168
  };
2169
+ const YEAR_FOLLOWERS = new Set([
2170
+ 'and', 'or', 'vs', 'versus', 'compared', 'to', 'through', 'thru', 'by', 'in', 'on', 'at', 'from', 'with',
2171
+ 'was', 'were', 'is', 'are', 'only', 'alone', 'so', 'than', 'but', '-', '?', '.', ';', ':', ')',
2172
+ ]);
1934
2173
  /** Decades ("1990s", "the 90s") and centuries ("21st century"). */
1935
2174
  const ruleDecadeCentury = (tokens, i) => {
1936
2175
  const t = tokens[i];
@@ -2963,6 +3202,10 @@ export const EN_RULE_ENTRIES = [
2963
3202
  { name: 'bare-year', rule: ruleBareYear },
2964
3203
  { name: 'calendar-date', rule: ruleCalendarDate },
2965
3204
  { name: 'clock-time', rule: ruleClockTime },
3205
+ { name: 'weekday-date', rule: ruleWeekdayDate },
3206
+ { name: 'n-units-ended', rule: ruleNUnitsEnded },
3207
+ { name: 'to-date', rule: ruleToDate },
3208
+ { name: 'trailing', rule: ruleTrailing },
2966
3209
  { name: 'weekday-day', rule: ruleWeekdayDay },
2967
3210
  { name: 'weekday-alone', rule: ruleWeekdayAlone },
2968
3211
  { name: 'period-alone', rule: rulePeriodAlone },
@@ -71,7 +71,7 @@ function parseWithRules(text, ctx, rules) {
71
71
  text: text.slice(startTok.start, endTok.end),
72
72
  start: startTok.start,
73
73
  end: endTok.end,
74
- expr: m.expr,
74
+ expr: wholeAmounts(m.expr),
75
75
  confidence: m.confidence,
76
76
  source: 'rules',
77
77
  };
@@ -156,8 +156,14 @@ function mergeAdjacent(matches, tokens, connectors) {
156
156
  dateExpr = stripDayPeriod(dateExpr) ?? dateExpr;
157
157
  }
158
158
  }
159
+ // "end of day yesterday" / "yesterday at start of day": a bare day
160
+ // edge (built against today) moves onto the date it modifies instead
161
+ // of intersecting today's edge with that date, which is empty (#33).
162
+ const dayEdge = todayDayEdge(timeExpr);
159
163
  out.push({
160
- expr: { op: 'intersect', parts: [dateExpr, timeExpr] },
164
+ expr: dayEdge
165
+ ? { op: 'snap', base: dateExpr, unit: 'day', edge: dayEdge }
166
+ : { op: 'intersect', parts: [dateExpr, timeExpr] },
161
167
  consumed: next.tokenEnd - cur.tokenStart,
162
168
  confidence: Math.max(cur.confidence, next.confidence),
163
169
  role: 'datetime',
@@ -238,6 +244,38 @@ function forceMeridiem(expr, period) {
238
244
  const meridiem = period === 'morning' ? 'am' : (period === 'night' || period === 'evening') && hour <= 5 ? 'am' : 'pm';
239
245
  return { ...expr, time: { ...expr.time, meridiem } };
240
246
  }
247
+ const AMOUNT_SECONDS = {
248
+ seconds: 1,
249
+ minutes: 60,
250
+ hours: 3600,
251
+ days: 86400,
252
+ weeks: 604800,
253
+ months: 2592000,
254
+ years: 31536000,
255
+ };
256
+ /**
257
+ * Calendar amounts are integers in the IR. A fractional one ("3,5 años",
258
+ * "半年", "123,45 secondes") becomes an exact duration, using the same
259
+ * nominal unit lengths as the English duration rules (#16).
260
+ */
261
+ function wholeAmounts(expr) {
262
+ if (expr.op !== 'amount')
263
+ return expr;
264
+ const entries = Object.entries(expr.amount);
265
+ if (entries.every(([, v]) => Number.isInteger(v)))
266
+ return expr;
267
+ let secs = 0;
268
+ for (const [k, v] of entries)
269
+ secs += v * (AMOUNT_SECONDS[k] ?? 0);
270
+ return { op: 'duration', iso: `PT${Math.round(secs * 100) / 100}S` };
271
+ }
272
+ /** The edge of a bare "start/end of day" point anchored to today, if `expr` is one. */
273
+ function todayDayEdge(expr) {
274
+ if (expr.op !== 'snap' || expr.unit !== 'day' || !expr.edge)
275
+ return undefined;
276
+ const b = expr.base;
277
+ return b.op === 'snap' && b.unit === 'day' && !b.edge && b.base.op === 'now' ? expr.edge : undefined;
278
+ }
241
279
  /** Remove day-period literals from an intersect; undefined if nothing remains. */
242
280
  function stripDayPeriod(expr) {
243
281
  if (expr.op === 'literal' && expr.dayPeriod && !expr.date && !expr.time)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nl2time",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Bidirectional natural language ⇄ date/time: parse expressions like 'last week' into concrete intervals, and describe instants as '9pm last night' — deterministic core, optional LLM assist.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,12 +48,12 @@
48
48
  "locale"
49
49
  ],
50
50
  "dependencies": {
51
- "temporal-polyfill": "^0.3.0"
51
+ "temporal-polyfill": "^1.0.6"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^26.1.1",
55
- "typescript": "^5.6.0",
56
- "vitest": "^3.0.0"
55
+ "typescript": "^7.0.2",
56
+ "vitest": "^5.0.0"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">=22"
@@ -13,6 +13,7 @@
13
13
  { "$ref": "#/$defs/Snap" },
14
14
  { "$ref": "#/$defs/Span" },
15
15
  { "$ref": "#/$defs/Between" },
16
+ { "$ref": "#/$defs/Until" },
16
17
  { "$ref": "#/$defs/Seek" },
17
18
  { "$ref": "#/$defs/Intersect" },
18
19
  { "$ref": "#/$defs/Duration" },
@@ -157,13 +158,25 @@
157
158
  "required": ["op", "start", "end"],
158
159
  "additionalProperties": false
159
160
  },
161
+ "Until": {
162
+ "type": "object",
163
+ "description": "The stretch up to end ('by Friday', 'until 2025-06-30', 'before March 2022'): [now, end) while end is ahead of now, otherwise an open-start range ending at end (resolved values carry start: null). exclusive ('before') ranks the reading that stops at the start of end first.",
164
+ "properties": {
165
+ "op": { "const": "until" },
166
+ "end": { "$ref": "#/$defs/TimeExpr" },
167
+ "exclusive": { "type": "boolean" },
168
+ "mod": { "$ref": "#/$defs/Mod" }
169
+ },
170
+ "required": ["op", "end"],
171
+ "additionalProperties": false
172
+ },
160
173
  "Seek": {
161
174
  "type": "object",
162
175
  "description": "Directed calendar navigation: seek(now, next, weekday tue) is 'next Tuesday' (exact semantics owned by the context nextWeekday policy); with n, the nth occurrence within the base interval ('the 2nd Monday of March').",
163
176
  "properties": {
164
177
  "op": { "const": "seek" },
165
178
  "base": { "$ref": "#/$defs/TimeExpr" },
166
- "dir": { "enum": ["next", "prev", "nearest"] },
179
+ "dir": { "enum": ["next", "prev", "nearest", "this"] },
167
180
  "target": { "$ref": "#/$defs/Target" },
168
181
  "n": { "type": "integer", "minimum": 1 },
169
182
  "mod": { "$ref": "#/$defs/Mod" }