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 +2 -2
- package/dist/corpus/runner.d.ts +2 -0
- package/dist/corpus/runner.js +10 -1
- package/dist/describe/index.js +5 -0
- package/dist/engine/resolve.d.ts +4 -2
- package/dist/engine/resolve.js +173 -54
- package/dist/ir/types.d.ts +12 -1
- package/dist/ir/validate.js +9 -2
- package/dist/parse/en.js +285 -42
- package/dist/parse/index.js +40 -2
- package/package.json +4 -4
- package/schema/timeexpr.schema.json +14 -1
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,
|
|
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
|
|
package/dist/corpus/runner.d.ts
CHANGED
|
@@ -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). */
|
package/dist/corpus/runner.js
CHANGED
|
@@ -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)'}`;
|
package/dist/describe/index.js
CHANGED
|
@@ -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
|
}
|
package/dist/engine/resolve.d.ts
CHANGED
|
@@ -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
|
} | {
|
package/dist/engine/resolve.js
CHANGED
|
@@ -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
|
|
271
|
+
const touchesNow = expr.start.op === 'now' || expr.end.op === 'now';
|
|
172
272
|
const out = [];
|
|
173
|
-
for (const
|
|
174
|
-
if (
|
|
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,
|
|
278
|
+
for (const e of evalExpr(expr.end, ctx, st.zi)) {
|
|
179
279
|
if (e.type !== 'interval')
|
|
180
280
|
continue;
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
650
|
-
|
|
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
|
package/dist/ir/types.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/ir/validate.js
CHANGED
|
@@ -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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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: '
|
|
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'
|
|
1124
|
+
if (word(tokens[at + 1]) !== 'half')
|
|
1017
1125
|
return undefined;
|
|
1018
|
-
at
|
|
1019
|
-
|
|
1020
|
-
at
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 !== '
|
|
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
|
-
|
|
1930
|
-
|
|
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 },
|
package/dist/parse/index.js
CHANGED
|
@@ -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:
|
|
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
|
+
"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.
|
|
51
|
+
"temporal-polyfill": "^1.0.6"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^26.1.1",
|
|
55
|
-
"typescript": "^
|
|
56
|
-
"vitest": "^
|
|
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" }
|