nl2time 0.3.2 → 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,11 +120,11 @@ 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,782 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
 
127
- See **[CHANGELOG.md](CHANGELOG.md)** and [GitHub releases](https://github.com/AndyFooBlah/nl2time/releases). Current: v0.3.2 (JS) · py-v0.1.2 (Python engine). Releases publish to npm/PyPI automatically via OIDC trusted publishing — see [docs/RELEASING.md](docs/RELEASING.md).
127
+ See **[CHANGELOG.md](CHANGELOG.md)** and [GitHub releases](https://github.com/AndyFooBlah/nl2time/releases). Current: v0.3.3 (JS) · py-v0.1.2 (Python engine). Releases publish to npm/PyPI automatically via OIDC trusted publishing — see [docs/RELEASING.md](docs/RELEASING.md).
128
128
 
129
129
  ## Development
130
130
 
@@ -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.
@@ -612,9 +630,9 @@ function buildRange(a, b, scope, period, connWord) {
612
630
  }
613
631
  /**
614
632
  * Open-range connectors (#18): "since X" → [start(X), now]; "until X" /
615
- * "till X" / "through X" / "up to X" / "by X" (deadline) → [now, end(X)].
616
- * These only fire when the connector opens the range — "A until B" and
617
- * "from A through B" are consumed earlier by the range rule.
633
+ * "till X" / "through X" / "up to X" / "by X" / "before X" (deadline) →
634
+ * [now, end(X)]. These only fire when the connector opens the range —
635
+ * "A until B" and "from A through B" are consumed earlier by the range rule.
618
636
  */
619
637
  const ruleOpenRange = (tokens, i, ctx) => {
620
638
  const w = word(tokens[i]);
@@ -622,7 +640,7 @@ const ruleOpenRange = (tokens, i, ctx) => {
622
640
  let at = i + 1;
623
641
  if (w === 'since')
624
642
  mode = 'since';
625
- else if (w === 'until' || w === 'till' || w === 'through' || w === 'thru' || w === 'by') {
643
+ else if (w === 'until' || w === 'till' || w === 'through' || w === 'thru' || w === 'by' || w === 'before') {
626
644
  mode = 'until';
627
645
  }
628
646
  else if (w === 'up' && word(tokens[i + 1]) === 'to') {
@@ -631,10 +649,25 @@ const ruleOpenRange = (tokens, i, ctx) => {
631
649
  }
632
650
  if (!mode)
633
651
  return undefined;
634
- // "by the 15th": a bare article only before an explicit calendar date —
635
- // NOT "through the week"-style prose, which stays unmatched.
636
- if (word(tokens[at]) === 'the' && ruleCalendarDate(tokens, at + 1, ctx))
652
+ // "by the 15th" / "by the end of the month": a bare article only before an
653
+ // explicit calendar date or an edge phrase (#29) — NOT "through the
654
+ // week"-style prose, which stays unmatched.
655
+ if (word(tokens[at]) === 'the' &&
656
+ (ruleCalendarDate(tokens, at + 1, ctx) || ruleEndOf(tokens, at + 1, ctx))) {
637
657
  at += 1;
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
+ }
638
671
  const inner = ruleDeicticDay(tokens, at, ctx) ??
639
672
  ruleLastThisNext(tokens, at, ctx) ??
640
673
  ruleHoliday(tokens, at, ctx) ??
@@ -646,23 +679,117 @@ const ruleOpenRange = (tokens, i, ctx) => {
646
679
  ruleWeekdayAlone(tokens, at, ctx);
647
680
  if (!inner || inner.role === 'duration')
648
681
  return undefined;
649
- // Boundary points ("by EOD", "by the end of the month") keep their point
650
- // reading — the deadline instant is the answer there, not a range.
682
+ // Boundary points ("by EOD") keep their point reading — the deadline
683
+ // instant is the answer there, not a range.
651
684
  if (inner.expr.op === 'snap' && inner.expr.edge !== undefined)
652
685
  return undefined;
686
+ // Late-part edge phrases ("end of month/year/quarter/week") parse as the
687
+ // period's late part — between(offset(start), end). As a deadline the
688
+ // answer is the period's END point, not the whole late part: using the
689
+ // interval would make the engine emit the inclusive/exclusive pair (#17)
690
+ // as a spurious second candidate whenever `now` precedes the late-part
691
+ // start (#29). Symmetrically, "since the beginning of the month" opens at
692
+ // the period's START point.
693
+ let target = inner.expr;
694
+ if (target.op === 'between') {
695
+ if (mode === 'until' && target.end.op === 'snap' && target.end.edge === 'end') {
696
+ target = target.end;
697
+ }
698
+ else if (mode === 'since' && target.start.op === 'snap' && target.start.edge === 'start') {
699
+ target = target.start;
700
+ }
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).
653
704
  const expr = mode === 'since'
654
- ? { op: 'between', start: inner.expr, end: NOW }
655
- : { op: 'between', start: NOW, end: inner.expr };
705
+ ? { op: 'between', start: target, end: NOW }
706
+ : { op: 'until', end: target, ...(w === 'before' ? { exclusive: true } : {}) };
656
707
  return { expr, consumed: at + inner.consumed - i, confidence: 0.9, role: 'datetime' };
657
708
  };
658
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
+ };
659
786
  const ruleWeekOf = (tokens, i, ctx) => {
660
787
  let at = i;
661
788
  if (word(tokens[at]) === 'the')
662
789
  at += 1;
663
790
  // "w/c Feb 4" (week commencing)
664
791
  if (word(tokens[at]) === 'w/c' || word(tokens[at]) === 'wc') {
665
- const date = ruleCalendarDate(tokens, at + 1, ctx);
792
+ const date = ruleWeekdayDate(tokens, at + 1, ctx) ?? ruleCalendarDate(tokens, at + 1, ctx);
666
793
  if (date && date.role === 'date') {
667
794
  return {
668
795
  expr: { op: 'snap', base: date.expr, unit: 'week' },
@@ -677,7 +804,7 @@ const ruleWeekOf = (tokens, i, ctx) => {
677
804
  let dAt = at + 2;
678
805
  if (word(tokens[dAt]) === 'on' || word(tokens[dAt]) === 'from')
679
806
  dAt += 1;
680
- 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);
681
808
  if (date && date.role === 'date') {
682
809
  return {
683
810
  expr: { op: 'snap', base: date.expr, unit: 'week' },
@@ -720,7 +847,7 @@ const ruleWeekOf = (tokens, i, ctx) => {
720
847
  let scope;
721
848
  let consumed = 0;
722
849
  const year = yearAt(tokens, at);
723
- const date = ruleCalendarDate(tokens, at, ctx);
850
+ const date = ruleWeekdayDate(tokens, at, ctx) ?? ruleCalendarDate(tokens, at, ctx);
724
851
  if (date) {
725
852
  scope = date.expr;
726
853
  consumed = date.consumed;
@@ -994,11 +1121,20 @@ const ruleHalfOf = (tokens, i, ctx) => {
994
1121
  }
995
1122
  if (which === undefined)
996
1123
  return undefined;
997
- if (word(tokens[at + 1]) !== 'half' || word(tokens[at + 2]) !== 'of')
1124
+ if (word(tokens[at + 1]) !== 'half')
998
1125
  return undefined;
999
- at += 3;
1000
- if (word(tokens[at]) === 'the')
1001
- 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
+ }
1002
1138
  // Scope: a bare unit ("the year"), a relative period ("next month"), a
1003
1139
  // month name, a quarter, or a year.
1004
1140
  let scope;
@@ -1011,9 +1147,13 @@ const ruleHalfOf = (tokens, i, ctx) => {
1011
1147
  consumed = 1;
1012
1148
  }
1013
1149
  else {
1150
+ const bareYear = yearAt(tokens, at);
1014
1151
  const inner = ruleLastThisNext(tokens, at, ctx) ??
1015
1152
  ruleBareYear(tokens, at, ctx) ??
1016
- 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);
1017
1157
  if (inner && inner.role === 'date') {
1018
1158
  unit = halfScopeUnit(inner.expr);
1019
1159
  if (unit) {
@@ -1044,7 +1184,8 @@ const ruleHalfOf = (tokens, i, ctx) => {
1044
1184
  };
1045
1185
  const REL_SYNONYMS = {
1046
1186
  last: 'last', previous: 'last', prior: 'last',
1047
- 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',
1048
1189
  next: 'next', coming: 'next', upcoming: 'next', following: 'next',
1049
1190
  };
1050
1191
  function weekendExpr(dir) {
@@ -1084,7 +1225,8 @@ const ruleLastThisNext = (tokens, i, ctx) => {
1084
1225
  return undefined;
1085
1226
  }
1086
1227
  if (nextWord === 'weekend') {
1087
- 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';
1088
1230
  return { expr: weekendExpr(dir), consumed: 2, confidence: 1, role: 'date' };
1089
1231
  }
1090
1232
  if (nextWord === 'workweek' || ((nextWord === 'work' || nextWord === 'working') && word(tokens[i + 2]) === 'week')) {
@@ -1121,11 +1263,12 @@ const ruleLastThisNext = (tokens, i, ctx) => {
1121
1263
  }
1122
1264
  const weekday = WEEKDAY_WORDS[nextWord];
1123
1265
  if (weekday) {
1124
- 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';
1125
1268
  // Only the literal words "next"/"last" carry the dialect ambiguity;
1126
1269
  // "upcoming Friday" is strictly the adjacent occurrence (n: 1).
1127
1270
  const literalWord = word(tokens[i]);
1128
- const strict = (literalWord === 'upcoming' || literalWord === 'coming') && dir !== 'nearest';
1271
+ const strict = (literalWord === 'upcoming' || literalWord === 'coming') && dir !== 'this';
1129
1272
  return {
1130
1273
  expr: {
1131
1274
  op: 'seek',
@@ -1392,6 +1535,101 @@ const ruleNextN = (tokens, i) => {
1392
1535
  };
1393
1536
  };
1394
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
+ };
1395
1633
  const ruleTheUnit = (tokens, i) => {
1396
1634
  if (word(tokens[i]) !== 'the')
1397
1635
  return undefined;
@@ -1427,14 +1665,17 @@ const ruleTheUnit = (tokens, i) => {
1427
1665
  const unit = UNIT_WORDS[uw];
1428
1666
  if (!unit || unit === 'second' || unit === 'minute' || unit === 'hour')
1429
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;
1430
1675
  // "the year to date", "the month to date"
1431
1676
  if (word(tokens[i + 2]) === 'to' && word(tokens[i + 3]) === 'date') {
1432
1677
  return {
1433
- expr: {
1434
- op: 'between',
1435
- start: { op: 'snap', base: NOW, unit, edge: 'start' },
1436
- end: { op: 'snap', base: NOW, unit: 'day', edge: 'start' },
1437
- },
1678
+ expr: toDate(unit),
1438
1679
  consumed: 4,
1439
1680
  confidence: 1,
1440
1681
  role: 'date',
@@ -1907,11 +2148,28 @@ const ruleBareYear = (tokens, i) => {
1907
2148
  if (!y || y.value < 1500 || y.value > 2199)
1908
2149
  return undefined;
1909
2150
  const prev = word(tokens[i - 1]);
1910
- if (prev !== undefined && !['in', 'since', 'by', 'until', 'during', 'year', 'of', 'from'].includes(prev)) {
1911
- 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;
1912
2166
  }
1913
2167
  return { expr: { op: 'literal', date: { year: y.value } }, consumed: y.consumed, confidence: 0.85, role: 'date' };
1914
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
+ ]);
1915
2173
  /** Decades ("1990s", "the 90s") and centuries ("21st century"). */
1916
2174
  const ruleDecadeCentury = (tokens, i) => {
1917
2175
  const t = tokens[i];
@@ -2944,6 +3202,10 @@ export const EN_RULE_ENTRIES = [
2944
3202
  { name: 'bare-year', rule: ruleBareYear },
2945
3203
  { name: 'calendar-date', rule: ruleCalendarDate },
2946
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 },
2947
3209
  { name: 'weekday-day', rule: ruleWeekdayDay },
2948
3210
  { name: 'weekday-alone', rule: ruleWeekdayAlone },
2949
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,7 +1,7 @@
1
1
  {
2
2
  "name": "nl2time",
3
- "version": "0.3.2",
4
- "description": "Bidirectional natural language \u21c4 date/time: parse expressions like 'last week' into concrete intervals, and describe instants as '9pm last night' \u2014 deterministic core, optional LLM assist.",
3
+ "version": "0.4.0",
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",
7
7
  "main": "./dist/index.js",
@@ -48,14 +48,14 @@
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"
60
60
  }
61
- }
61
+ }
@@ -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" }