nl2time 0.3.1 → 0.3.3

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
@@ -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,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.
123
+ The Python engine port lives in [`python/`](python/) (`whenever`-based time model): IR validation, `TimeContext`, and the full deterministic resolver at **100% bit-exact parity** with the JS reference across all 2,790 engine-parity fixtures (`corpus/ir/`), enforced in CI on every push. Parsers and `describe()` are JS-only so far — [docs/porting.md](docs/porting.md) has the strategy and the divergence gates.
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.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).
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
 
@@ -179,7 +179,12 @@ function evalExprInner(expr, ctx, anchor) {
179
179
  if (e.type !== 'interval')
180
180
  continue;
181
181
  const isPoint = Temporal_compare(e.zi.start, e.zi.end) === 0;
182
- const timeGrained = GRAIN_ORDER.indexOf(e.zi.grain) < GRAIN_ORDER.indexOf('day');
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);
183
188
  // Day-grain-or-coarser ends are conversationally inclusive:
184
189
  // "between July 4th and July 10th" covers the 10th. Emit the
185
190
  // inclusive reading (end operand contributes its END) first, with
package/dist/parse/en.js CHANGED
@@ -612,9 +612,9 @@ function buildRange(a, b, scope, period, connWord) {
612
612
  }
613
613
  /**
614
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.
615
+ * "till X" / "through X" / "up to X" / "by X" / "before X" (deadline) →
616
+ * [now, end(X)]. These only fire when the connector opens the range —
617
+ * "A until B" and "from A through B" are consumed earlier by the range rule.
618
618
  */
619
619
  const ruleOpenRange = (tokens, i, ctx) => {
620
620
  const w = word(tokens[i]);
@@ -622,7 +622,7 @@ const ruleOpenRange = (tokens, i, ctx) => {
622
622
  let at = i + 1;
623
623
  if (w === 'since')
624
624
  mode = 'since';
625
- else if (w === 'until' || w === 'till' || w === 'through' || w === 'thru' || w === 'by') {
625
+ else if (w === 'until' || w === 'till' || w === 'through' || w === 'thru' || w === 'by' || w === 'before') {
626
626
  mode = 'until';
627
627
  }
628
628
  else if (w === 'up' && word(tokens[i + 1]) === 'to') {
@@ -631,10 +631,13 @@ const ruleOpenRange = (tokens, i, ctx) => {
631
631
  }
632
632
  if (!mode)
633
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))
634
+ // "by the 15th" / "by the end of the month": a bare article only before an
635
+ // explicit calendar date or an edge phrase (#29) — NOT "through the
636
+ // week"-style prose, which stays unmatched.
637
+ if (word(tokens[at]) === 'the' &&
638
+ (ruleCalendarDate(tokens, at + 1, ctx) || ruleEndOf(tokens, at + 1, ctx))) {
637
639
  at += 1;
640
+ }
638
641
  const inner = ruleDeicticDay(tokens, at, ctx) ??
639
642
  ruleLastThisNext(tokens, at, ctx) ??
640
643
  ruleHoliday(tokens, at, ctx) ??
@@ -646,13 +649,29 @@ const ruleOpenRange = (tokens, i, ctx) => {
646
649
  ruleWeekdayAlone(tokens, at, ctx);
647
650
  if (!inner || inner.role === 'duration')
648
651
  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.
652
+ // Boundary points ("by EOD") keep their point reading — the deadline
653
+ // instant is the answer there, not a range.
651
654
  if (inner.expr.op === 'snap' && inner.expr.edge !== undefined)
652
655
  return undefined;
656
+ // Late-part edge phrases ("end of month/year/quarter/week") parse as the
657
+ // period's late part — between(offset(start), end). As a deadline the
658
+ // answer is the period's END point, not the whole late part: using the
659
+ // interval would make the engine emit the inclusive/exclusive pair (#17)
660
+ // as a spurious second candidate whenever `now` precedes the late-part
661
+ // start (#29). Symmetrically, "since the beginning of the month" opens at
662
+ // the period's START point.
663
+ let target = inner.expr;
664
+ if (target.op === 'between') {
665
+ if (mode === 'until' && target.end.op === 'snap' && target.end.edge === 'end') {
666
+ target = target.end;
667
+ }
668
+ else if (mode === 'since' && target.start.op === 'snap' && target.start.edge === 'start') {
669
+ target = target.start;
670
+ }
671
+ }
653
672
  const expr = mode === 'since'
654
- ? { op: 'between', start: inner.expr, end: NOW }
655
- : { op: 'between', start: NOW, end: inner.expr };
673
+ ? { op: 'between', start: target, end: NOW }
674
+ : { op: 'between', start: NOW, end: target };
656
675
  return { expr, consumed: at + inner.consumed - i, confidence: 0.9, role: 'datetime' };
657
676
  };
658
677
  /** "week of April 10th", "the first/last week of July", "the week starting on Feb 4". */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nl2time",
3
- "version": "0.3.1",
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.3.3",
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",
@@ -32,7 +32,8 @@
32
32
  "typecheck": "tsc --noEmit",
33
33
  "import:recognizers": "node scripts/import-recognizers.mjs",
34
34
  "baselines": "npm run build && node scripts/update-baselines.mjs",
35
- "eval": "npm run build && node scripts/eval.mjs"
35
+ "eval": "npm run build && node scripts/eval.mjs",
36
+ "fixtures": "npm run build && node scripts/generate-ir-fixtures.mjs"
36
37
  },
37
38
  "keywords": [
38
39
  "date",
@@ -55,6 +56,6 @@
55
56
  "vitest": "^3.0.0"
56
57
  },
57
58
  "engines": {
58
- "node": ">=20"
59
+ "node": ">=22"
59
60
  }
60
- }
61
+ }