nl2time 0.3.0 → 0.3.1

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
@@ -114,11 +114,17 @@ Every imported conformance case carries machine-readable provenance (upstream pr
114
114
 
115
115
  ## Python
116
116
 
117
- A Python engine port lives in [`python/`](python/) (PyPI packaging prepared; `whenever`-based time model): IR validation, `TimeContext`, and the full deterministic resolver at **100% bit-exact parity** with the JS reference across all 2,760 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.
117
+ [![PyPI](https://img.shields.io/pypi/v/nl2time)](https://pypi.org/project/nl2time/)
118
+
119
+ ```
120
+ pip install nl2time
121
+ ```
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,760 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.
118
124
 
119
125
  ## Release history
120
126
 
121
- See **[CHANGELOG.md](CHANGELOG.md)** and [GitHub releases](https://github.com/AndyFooBlah/nl2time/releases). Current: v0.3.0 (multilingual parsing).
127
+ See **[CHANGELOG.md](CHANGELOG.md)** and [GitHub releases](https://github.com/AndyFooBlah/nl2time/releases). Current: v0.3.0 (JS, multilingual parsing) · py-v0.1.0 (Python engine). Releases publish to npm/PyPI automatically via OIDC trusted publishing — see [docs/RELEASING.md](docs/RELEASING.md).
122
128
 
123
129
  ## Development
124
130
 
@@ -67,9 +67,13 @@ function evalExpr(expr, ctx, anchor) {
67
67
  // inside the interval it tightens the bound ("earlier this year" ends
68
68
  // today, not at midyear).
69
69
  if (expr.mod === 'start' || expr.mod === 'end') {
70
- return intervalMap(cands, (zi) => {
71
- if (zi.grain === 'instant')
72
- return zi;
70
+ const out = [];
71
+ for (const c of cands) {
72
+ if (c.type !== 'interval' || c.zi.grain === 'instant') {
73
+ out.push(c);
74
+ continue;
75
+ }
76
+ const zi = c.zi;
73
77
  const totalMs = zi.end.epochMilliseconds - zi.start.epochMilliseconds;
74
78
  let mid = zi.start.add({ milliseconds: Math.floor(totalMs / 2) });
75
79
  const gi = GRAIN_ORDER.indexOf(zi.grain);
@@ -78,18 +82,25 @@ function evalExpr(expr, ctx, anchor) {
78
82
  else if (gi > GRAIN_ORDER.indexOf('day'))
79
83
  mid = floorTo(mid, 'day', ctx.weekStart);
80
84
  let bound = mid;
85
+ let altHalf = false;
81
86
  if (gi > GRAIN_ORDER.indexOf('day')) {
82
87
  const refDay = floorTo(ctx.zonedNow, 'day', ctx.weekStart);
83
88
  const inRange = Temporal_compare(refDay, zi.start) > 0 && Temporal_compare(refDay, zi.end) < 0;
84
89
  if (inRange) {
90
+ // 'start' ("earlier this week/year") ends at the start of the
91
+ // reference day: on a Thursday, "earlier this week" reaches
92
+ // through Wednesday, not just past the midpoint (#20). When the
93
+ // reference sits beyond the midpoint the plain first half stays
94
+ // as an alternative (the Recognizers corpus pins it for "earlier
95
+ // this month"). 'end' ("later this week") starts at the
96
+ // reference day only once the midpoint has passed.
85
97
  bound =
86
98
  expr.mod === 'start'
87
- ? Temporal_compare(refDay, mid) < 0
88
- ? refDay
89
- : mid
99
+ ? refDay
90
100
  : Temporal_compare(refDay, mid) > 0
91
101
  ? refDay
92
102
  : mid;
103
+ altHalf = expr.mod === 'start' && Temporal_compare(refDay, mid) > 0;
93
104
  }
94
105
  }
95
106
  const narrowed = expr.mod === 'start'
@@ -97,12 +108,20 @@ function evalExpr(expr, ctx, anchor) {
97
108
  : { start: bound, end: zi.end, grain: zi.grain };
98
109
  // A degenerate clamp (bound at an edge) falls back to the plain half.
99
110
  if (Temporal_compare(narrowed.start, narrowed.end) >= 0) {
100
- return expr.mod === 'start'
101
- ? { start: zi.start, end: mid, grain: zi.grain }
102
- : { start: mid, end: zi.end, grain: zi.grain };
111
+ out.push({
112
+ type: 'interval',
113
+ zi: expr.mod === 'start'
114
+ ? { start: zi.start, end: mid, grain: zi.grain }
115
+ : { start: mid, end: zi.end, grain: zi.grain },
116
+ });
117
+ continue;
103
118
  }
104
- return narrowed;
105
- });
119
+ out.push({ type: 'interval', zi: narrowed });
120
+ if (altHalf && Temporal_compare(zi.start, mid) < 0) {
121
+ out.push({ type: 'interval', zi: { start: zi.start, end: mid, grain: zi.grain } });
122
+ }
123
+ }
124
+ return out;
106
125
  }
107
126
  // mod 'mid': the middle stretch. Conventions follow common usage: mid-month
108
127
  // = the 10th through the 20th; mid-day = 10:00–14:00; otherwise the middle
@@ -159,10 +178,27 @@ function evalExprInner(expr, ctx, anchor) {
159
178
  for (const e of evalExpr(expr.end, ctx, s.zi)) {
160
179
  if (e.type !== 'interval')
161
180
  continue;
162
- // Non-point ends are exclusive at their *start* ("May 2 to May 7"
163
- // ends at May 7 00:00); point ends are used directly.
164
- let endPoint = Temporal_compare(e.zi.start, e.zi.end) === 0 ? e.zi.end : e.zi.start;
181
+ const isPoint = Temporal_compare(e.zi.start, e.zi.end) === 0;
165
182
  const timeGrained = GRAIN_ORDER.indexOf(e.zi.grain) < GRAIN_ORDER.indexOf('day');
183
+ // Day-grain-or-coarser ends are conversationally inclusive:
184
+ // "between July 4th and July 10th" covers the 10th. Emit the
185
+ // inclusive reading (end operand contributes its END) first, with
186
+ // the strict exclusive-at-start reading as an alternative — the
187
+ // Recognizers-derived corpus pins the exclusive value, so both
188
+ // must remain candidates (#17).
189
+ if (!isPoint && !timeGrained) {
190
+ const grain = finerGrain(s.zi.grain, e.zi.grain);
191
+ if (Temporal_compare(s.zi.start, e.zi.end) < 0) {
192
+ out.push({ type: 'interval', zi: { start: s.zi.start, end: e.zi.end, grain } });
193
+ }
194
+ if (Temporal_compare(s.zi.start, e.zi.start) < 0) {
195
+ out.push({ type: 'interval', zi: { start: s.zi.start, end: e.zi.start, grain } });
196
+ }
197
+ continue;
198
+ }
199
+ // Non-point time ends are exclusive at their *start* ("9 to 5pm"
200
+ // ends at 17:00); point ends are used directly.
201
+ let endPoint = isPoint ? e.zi.end : e.zi.start;
166
202
  if (Temporal_compare(s.zi.start, endPoint) >= 0) {
167
203
  // A clock-time range wrapping midnight ("9:30pm to 7:30am") rolls
168
204
  // the end into the next day; date ranges just drop.
package/dist/parse/en.js CHANGED
@@ -610,6 +610,51 @@ function buildRange(a, b, scope, period, connWord) {
610
610
  }
611
611
  return undefined;
612
612
  }
613
+ /**
614
+ * 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.
618
+ */
619
+ const ruleOpenRange = (tokens, i, ctx) => {
620
+ const w = word(tokens[i]);
621
+ let mode;
622
+ let at = i + 1;
623
+ if (w === 'since')
624
+ mode = 'since';
625
+ else if (w === 'until' || w === 'till' || w === 'through' || w === 'thru' || w === 'by') {
626
+ mode = 'until';
627
+ }
628
+ else if (w === 'up' && word(tokens[i + 1]) === 'to') {
629
+ mode = 'until';
630
+ at = i + 2;
631
+ }
632
+ if (!mode)
633
+ 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))
637
+ at += 1;
638
+ const inner = ruleDeicticDay(tokens, at, ctx) ??
639
+ ruleLastThisNext(tokens, at, ctx) ??
640
+ ruleHoliday(tokens, at, ctx) ??
641
+ ruleEndOf(tokens, at, ctx) ??
642
+ ruleWeekOf(tokens, at, ctx) ??
643
+ ruleBareYear(tokens, at, ctx) ??
644
+ ruleCalendarDate(tokens, at, ctx) ??
645
+ ruleClockTime(tokens, at, ctx) ??
646
+ ruleWeekdayAlone(tokens, at, ctx);
647
+ if (!inner || inner.role === 'duration')
648
+ 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.
651
+ if (inner.expr.op === 'snap' && inner.expr.edge !== undefined)
652
+ return undefined;
653
+ const expr = mode === 'since'
654
+ ? { op: 'between', start: inner.expr, end: NOW }
655
+ : { op: 'between', start: NOW, end: inner.expr };
656
+ return { expr, consumed: at + inner.consumed - i, confidence: 0.9, role: 'datetime' };
657
+ };
613
658
  /** "week of April 10th", "the first/last week of July", "the week starting on Feb 4". */
614
659
  const ruleWeekOf = (tokens, i, ctx) => {
615
660
  let at = i;
@@ -915,6 +960,88 @@ const ruleEndOf = (tokens, i, ctx) => {
915
960
  : { ...inner.expr, mod: isEnd ? 'end' : isMid ? 'mid' : 'start' };
916
961
  return { expr, consumed: at + inner.consumed - i, confidence: 0.95, role: 'date' };
917
962
  };
963
+ /** The period unit a half-of scope denotes, if determinable. */
964
+ function halfScopeUnit(expr) {
965
+ if (expr.op === 'snap') {
966
+ return expr.edge === undefined &&
967
+ (expr.unit === 'week' || expr.unit === 'month' || expr.unit === 'quarter' || expr.unit === 'year')
968
+ ? expr.unit
969
+ : undefined;
970
+ }
971
+ if (expr.op === 'literal' && expr.date !== undefined && expr.time === undefined) {
972
+ if (expr.date.day !== undefined)
973
+ return undefined;
974
+ return expr.date.month !== undefined ? 'month' : 'year';
975
+ }
976
+ if (expr.op === 'seek' && expr.target.kind === 'month')
977
+ return 'month';
978
+ return undefined;
979
+ }
980
+ /** "the first/second half of <period>" → [start, mid) / [mid, end) (#24). */
981
+ const ruleHalfOf = (tokens, i, ctx) => {
982
+ let at = i;
983
+ if (word(tokens[at]) === 'the')
984
+ at += 1;
985
+ const w = word(tokens[at]);
986
+ const t = tokens[at];
987
+ let which;
988
+ if (w === 'first')
989
+ which = 1;
990
+ else if (w === 'second' || w === 'latter' || w === 'back')
991
+ which = 2;
992
+ else if (t?.type === 'number' && t.ordinal && (t.value === 1 || t.value === 2)) {
993
+ which = t.value;
994
+ }
995
+ if (which === undefined)
996
+ return undefined;
997
+ if (word(tokens[at + 1]) !== 'half' || word(tokens[at + 2]) !== 'of')
998
+ return undefined;
999
+ at += 3;
1000
+ if (word(tokens[at]) === 'the')
1001
+ at += 1;
1002
+ // Scope: a bare unit ("the year"), a relative period ("next month"), a
1003
+ // month name, a quarter, or a year.
1004
+ let scope;
1005
+ let unit;
1006
+ let consumed = 0;
1007
+ const uw = word(tokens[at]) !== 'night' ? UNIT_WORDS[word(tokens[at]) ?? ''] : undefined;
1008
+ if (uw === 'week' || uw === 'month' || uw === 'quarter' || uw === 'year') {
1009
+ scope = snapNow(uw);
1010
+ unit = uw;
1011
+ consumed = 1;
1012
+ }
1013
+ else {
1014
+ const inner = ruleLastThisNext(tokens, at, ctx) ??
1015
+ ruleBareYear(tokens, at, ctx) ??
1016
+ ruleCalendarDate(tokens, at, ctx);
1017
+ if (inner && inner.role === 'date') {
1018
+ unit = halfScopeUnit(inner.expr);
1019
+ if (unit) {
1020
+ scope = inner.expr;
1021
+ consumed = inner.consumed;
1022
+ }
1023
+ }
1024
+ }
1025
+ if (!scope || !unit)
1026
+ return undefined;
1027
+ // Fixed split, deliberately not reference-clamped (unlike mod
1028
+ // 'start'/'end'): "the first half of the year" is Jan–Jun regardless of
1029
+ // today. Midpoints: year +6 months, quarter +45 days, month +15 days
1030
+ // (the 16th), week +84 hours.
1031
+ const startPoint = { op: 'snap', base: scope, unit, edge: 'start' };
1032
+ const endPoint = { op: 'snap', base: scope, unit, edge: 'end' };
1033
+ const mid = unit === 'year'
1034
+ ? { op: 'offset', base: startPoint, amount: 6, unit: 'month' }
1035
+ : unit === 'quarter'
1036
+ ? { op: 'offset', base: startPoint, amount: 45, unit: 'day' }
1037
+ : unit === 'month'
1038
+ ? { op: 'offset', base: startPoint, amount: 15, unit: 'day' }
1039
+ : { op: 'offset', base: startPoint, amount: 84, unit: 'hour' };
1040
+ const expr = which === 1
1041
+ ? { op: 'between', start: startPoint, end: mid }
1042
+ : { op: 'between', start: mid, end: endPoint };
1043
+ return { expr, consumed: at + consumed - i, confidence: 1, role: 'date' };
1044
+ };
918
1045
  const REL_SYNONYMS = {
919
1046
  last: 'last', previous: 'last', prior: 'last',
920
1047
  this: 'this', current: 'this', present: 'this', that: 'this',
@@ -1164,6 +1291,33 @@ const ruleUnitAfterNext = (tokens, i) => {
1164
1291
  return undefined;
1165
1292
  return { expr: snapOffset(2, unit), consumed: 3, confidence: 1, role: 'date' };
1166
1293
  };
1294
+ /** "[the] <weekday> before last" → two occurrences back; "the week before last" → two units back (#21). */
1295
+ const ruleBeforeLast = (tokens, i) => {
1296
+ let at = i;
1297
+ if (word(tokens[at]) === 'the')
1298
+ at += 1;
1299
+ const w = word(tokens[at]);
1300
+ if (w === undefined)
1301
+ return undefined;
1302
+ if (word(tokens[at + 1]) !== 'before' || word(tokens[at + 2]) !== 'last')
1303
+ return undefined;
1304
+ const consumed = at + 3 - i;
1305
+ const weekday = WEEKDAY_WORDS[w];
1306
+ if (weekday) {
1307
+ // Strict occurrence counting: the second one strictly in the past.
1308
+ return {
1309
+ expr: { op: 'seek', base: NOW, dir: 'prev', target: { kind: 'weekday', weekday }, n: 2 },
1310
+ consumed,
1311
+ confidence: 1,
1312
+ role: 'date',
1313
+ };
1314
+ }
1315
+ const unit = w !== 'night' ? UNIT_WORDS[w] : undefined;
1316
+ if (unit && unit !== 'second' && unit !== 'minute' && unit !== 'hour') {
1317
+ return { expr: snapOffset(-2, unit), consumed, confidence: 1, role: 'date' };
1318
+ }
1319
+ return undefined;
1320
+ };
1167
1321
  /** "last/past/previous N units" → span backwards; "the past week" → trailing 7 days. */
1168
1322
  const rulePastN = (tokens, i) => {
1169
1323
  const w = word(tokens[i]);
@@ -1440,16 +1594,57 @@ const ruleWeekendOf = (tokens, i, ctx) => {
1440
1594
  if (word(tokens[at]) !== 'weekend' || word(tokens[at + 1]) !== 'of')
1441
1595
  return undefined;
1442
1596
  at += 2;
1443
- const inner = ruleHoliday(tokens, at, ctx) ?? ruleCalendarDate(tokens, at, ctx);
1597
+ // Holidays keep the long-weekend reading: Saturday before through the end
1598
+ // of the holiday ("Memorial Day weekend" = Sat–Mon).
1599
+ const holiday = ruleHoliday(tokens, at, ctx);
1600
+ if (holiday && holiday.role === 'date') {
1601
+ return {
1602
+ expr: {
1603
+ op: 'between',
1604
+ start: { op: 'seek', base: holiday.expr, dir: 'prev', target: { kind: 'weekday', weekday: 'sat' }, n: 1 },
1605
+ end: { op: 'snap', base: holiday.expr, unit: 'day', edge: 'end' },
1606
+ },
1607
+ consumed: at + holiday.consumed - i,
1608
+ confidence: 0.95,
1609
+ role: 'date',
1610
+ };
1611
+ }
1612
+ if (word(tokens[at]) === 'the')
1613
+ at += 1; // "the weekend of the 18th"
1614
+ const inner = ruleCalendarDate(tokens, at, ctx);
1444
1615
  if (!inner || inner.role !== 'date')
1445
1616
  return undefined;
1617
+ let consumed = at + inner.consumed - i;
1618
+ // "the weekend of July 18th and 19th": the conjunct names the weekend's
1619
+ // other day — absorb it (#22).
1620
+ const andAt = at + inner.consumed;
1621
+ if (word(tokens[andAt]) === 'and') {
1622
+ const t = tokens[andAt + 1];
1623
+ if (t?.type === 'number' && t.ordinal && t.value >= 1 && t.value <= 31) {
1624
+ consumed = andAt + 2 - i;
1625
+ }
1626
+ else {
1627
+ const ord = readOrdinalDayWord(tokens, andAt + 1);
1628
+ if (ord)
1629
+ consumed = andAt + 1 + ord.consumed - i;
1630
+ }
1631
+ }
1632
+ // The Sat–Sun weekend containing the date, or the following one for a
1633
+ // midweek date: the first Saturday on-or-after (date − 1 day), so a
1634
+ // Sunday still maps back to its own weekend (#22).
1446
1635
  return {
1447
1636
  expr: {
1448
- op: 'between',
1449
- start: { op: 'seek', base: inner.expr, dir: 'prev', target: { kind: 'weekday', weekday: 'sat' }, n: 1 },
1450
- end: { op: 'snap', base: inner.expr, unit: 'day', edge: 'end' },
1637
+ op: 'span',
1638
+ anchor: {
1639
+ op: 'seek',
1640
+ base: { op: 'offset', base: inner.expr, amount: -2, unit: 'day' },
1641
+ dir: 'next',
1642
+ target: { kind: 'weekday', weekday: 'sat' },
1643
+ n: 1,
1644
+ },
1645
+ amount: { days: 2 },
1451
1646
  },
1452
- consumed: at + inner.consumed - i,
1647
+ consumed,
1453
1648
  confidence: 0.95,
1454
1649
  role: 'date',
1455
1650
  };
@@ -2722,17 +2917,20 @@ const ruleAllUnit = (tokens, i) => {
2722
2917
  */
2723
2918
  export const EN_RULE_ENTRIES = [
2724
2919
  { name: 'range', rule: ruleRange },
2920
+ { name: 'open-range', rule: ruleOpenRange },
2725
2921
  { name: 'week-of', rule: ruleWeekOf },
2726
2922
  { name: 'nth-weekday-of', rule: ruleNthWeekdayOf },
2727
2923
  { name: 'day-of-scope', rule: ruleDayOfScope },
2728
2924
  { name: 'weekend-of', rule: ruleWeekendOf },
2729
2925
  { name: 'holiday', rule: ruleHoliday },
2730
2926
  { name: 'early-late', rule: ruleEarlyLate },
2927
+ { name: 'half-of', rule: ruleHalfOf },
2731
2928
  { name: 'end-of', rule: ruleEndOf },
2732
2929
  { name: 'deictic-day', rule: ruleDeicticDay },
2733
2930
  { name: 'within', rule: ruleWithin },
2734
2931
  { name: 'n-after-date', rule: ruleNAfterDate },
2735
2932
  { name: 'unit-after-next', rule: ruleUnitAfterNext },
2933
+ { name: 'before-last', rule: ruleBeforeLast },
2736
2934
  { name: 'past-n', rule: rulePastN },
2737
2935
  { name: 'next-n', rule: ruleNextN },
2738
2936
  { name: 'the-n-past-next', rule: ruleTheNPastNext },
@@ -137,7 +137,10 @@ function mergeAdjacent(matches, tokens, connectors) {
137
137
  let timeExpr = inferMeridiem(dateExpr, timePart.expr);
138
138
  // A day-period on the date side pushes its meridiem into an explicit
139
139
  // time range and then steps aside: "Monday morning 6-8" is Monday
140
- // 06:00–08:00, not clipped to the morning boundaries.
140
+ // 06:00–08:00, not clipped to the morning boundaries. A cross-midnight
141
+ // range ("last night between 11pm and 1am") keeps the period part:
142
+ // its window extends past midnight, so the range can roll into the
143
+ // next day instead of being clipped at the day anchor's end (#19).
141
144
  if (timeExpr.op === 'between') {
142
145
  const period = findDayPeriod(dateExpr);
143
146
  if (period) {
@@ -146,7 +149,11 @@ function mergeAdjacent(matches, tokens, connectors) {
146
149
  start: forceMeridiem(timeExpr.start, period),
147
150
  end: forceMeridiem(timeExpr.end, period),
148
151
  };
149
- dateExpr = stripDayPeriod(dateExpr) ?? dateExpr;
152
+ const sm = literalMinuteOfDay(timeExpr.start);
153
+ const em = literalMinuteOfDay(timeExpr.end);
154
+ const wraps = sm !== undefined && em !== undefined && em <= sm;
155
+ if (!wraps)
156
+ dateExpr = stripDayPeriod(dateExpr) ?? dateExpr;
150
157
  }
151
158
  }
152
159
  out.push({
@@ -211,6 +218,18 @@ function inferMeridiem(dateExpr, timeExpr) {
211
218
  time: { ...timeExpr.time, meridiem },
212
219
  };
213
220
  }
221
+ /** Minutes-since-midnight of a literal time with a known meridiem, if any. */
222
+ function literalMinuteOfDay(expr) {
223
+ if (expr.op !== 'literal' || expr.time?.hour === undefined)
224
+ return undefined;
225
+ const h = expr.time.hour % 12;
226
+ const min = expr.time.minute ?? 0;
227
+ if (expr.time.meridiem === 'am')
228
+ return h * 60 + min;
229
+ if (expr.time.meridiem === 'pm')
230
+ return (h + 12) * 60 + min;
231
+ return undefined;
232
+ }
214
233
  function forceMeridiem(expr, period) {
215
234
  if (expr.op !== 'literal' || !expr.time || (expr.time.meridiem && expr.time.meridiem !== 'unknown')) {
216
235
  return expr;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nl2time",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
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.",
5
5
  "license": "MIT",
6
6
  "type": "module",