nl2time 0.1.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/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/clock/index.d.ts +40 -0
- package/dist/clock/index.js +99 -0
- package/dist/context.d.ts +48 -0
- package/dist/context.js +75 -0
- package/dist/corpus/runner.d.ts +81 -0
- package/dist/corpus/runner.js +220 -0
- package/dist/data/dayPeriods.d.ts +18 -0
- package/dist/data/dayPeriods.js +24 -0
- package/dist/data/weekData.d.ts +11 -0
- package/dist/data/weekData.js +25 -0
- package/dist/describe/index.d.ts +25 -0
- package/dist/describe/index.js +289 -0
- package/dist/engine/resolve.d.ts +31 -0
- package/dist/engine/resolve.js +684 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/ir/types.d.ts +165 -0
- package/dist/ir/types.js +31 -0
- package/dist/ir/validate.d.ts +11 -0
- package/dist/ir/validate.js +205 -0
- package/dist/llm/index.d.ts +26 -0
- package/dist/llm/index.js +89 -0
- package/dist/parse/en.d.ts +18 -0
- package/dist/parse/en.js +2708 -0
- package/dist/parse/index.d.ts +20 -0
- package/dist/parse/index.js +230 -0
- package/dist/parse/tokenizer.d.ts +33 -0
- package/dist/parse/tokenizer.js +118 -0
- package/package.json +56 -0
- package/schema/timeexpr.schema.json +239 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nl2time contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# nl2time
|
|
2
|
+
|
|
3
|
+
Bidirectional natural language ⇄ date/time. Parse expressions like *"last week"* into concrete, timezone- and locale-correct intervals — and describe instants back as *"9pm last night"*. Deterministic core; optional LLM assist that can only ever emit the same symbolic IR the rule parser emits.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { TimeContext, parse, resolve, describe, Temporal } from 'nl2time';
|
|
7
|
+
|
|
8
|
+
const ctx = TimeContext.make({
|
|
9
|
+
now: '2026-07-20T17:00:00Z', // omit for system time; pass for reproducibility
|
|
10
|
+
timeZone: 'America/Los_Angeles', // the user's zone, not the machine's
|
|
11
|
+
locale: 'en-US', // region matters: week start, date order, …
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// NL → time -----------------------------------------------------------------
|
|
15
|
+
const { matches } = parse('how many shoes did I sell last week', ctx);
|
|
16
|
+
resolve(matches[0].expr, ctx).candidates[0];
|
|
17
|
+
// { kind: 'interval', start: 2026-07-12T07:00:00Z, end: 2026-07-19T07:00:00Z, grain: 'week' }
|
|
18
|
+
// …with locale 'en-GB' the same words give Jul 13 – Jul 20 (Monday week start).
|
|
19
|
+
|
|
20
|
+
// time → NL -----------------------------------------------------------------
|
|
21
|
+
describe(Temporal.Instant.from('2026-07-20T04:00:00Z'), ctx, { style: 'casual' }).text;
|
|
22
|
+
// '9pm last night' (4am UTC Jul 20 = 9pm Jul 19 in LA; today is Jul 20 there)
|
|
23
|
+
describe(Temporal.Instant.from('2026-07-20T04:00:00Z'), ctx).text;
|
|
24
|
+
// 'yesterday at 9:00 PM'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Why another date library?
|
|
28
|
+
|
|
29
|
+
Existing tools do one direction, half-way:
|
|
30
|
+
|
|
31
|
+
- **Parsers** (chrono-node, Duckling, dateparser) collapse text straight to a concrete datetime, losing granularity ("July" is a month, not a millisecond), ambiguity (which "Friday"?), and the week-start question entirely.
|
|
32
|
+
- **Formatters** (`Intl.RelativeTimeFormat`, Luxon, timeago) take a pre-computed `(value, unit)` — *you* decide the framing, the unit, and when "23 hours ago" should become "yesterday". Nobody owns that decision.
|
|
33
|
+
- **LLMs** are demonstrably bad at raw date arithmetic (leap years, DST, large offsets) but good at language.
|
|
34
|
+
|
|
35
|
+
nl2time puts a small symbolic IR in the middle of both directions:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
text ──parse──►┐ ┌──◄─select── instant/interval
|
|
39
|
+
│ TimeExpr (JSON IR) │
|
|
40
|
+
└──►─resolve──► value └──render──► "9pm last night"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- **`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).
|
|
44
|
+
- **`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.
|
|
45
|
+
- **`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`.
|
|
46
|
+
- **Everything language refers to is an interval with a grain.** "Last week" is seven days; "9pm" is an hour; "March" is a month.
|
|
47
|
+
|
|
48
|
+
## The LLM boundary (optional)
|
|
49
|
+
|
|
50
|
+
nl2time never makes network calls. Rules run first; you can supply a fallback for the long tail, and its output is schema-validated **IR, never concrete dates** — all arithmetic stays deterministic and testable:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { parseWithFallback, buildPrompt, irJsonSchema } from 'nl2time/llm';
|
|
54
|
+
|
|
55
|
+
const result = await parseWithFallback(text, ctx, async (text, ctxSummary) => {
|
|
56
|
+
// call your LLM with buildPrompt(text, ctx) + irJsonSchema() constrained output
|
|
57
|
+
return llmClient.structured(buildPrompt(text, ctx), irJsonSchema());
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
See [docs/agents.md](docs/agents.md) for reference architectures (analytics agents, tool-calling, deferred resolution).
|
|
62
|
+
|
|
63
|
+
## Policy, not guesses
|
|
64
|
+
|
|
65
|
+
Cultural/ambiguous semantics are explicit `TimeContext` knobs with CLDR-derived defaults:
|
|
66
|
+
|
|
67
|
+
| Question | Knob | Default |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| Does a week start Sunday or Monday? | `weekStart` | CLDR, by locale *region* (en-US: sun, en-GB: mon) |
|
|
70
|
+
| Is 5/2 May 2nd or Feb 5th? | `dateOrder` | CLDR-style by region |
|
|
71
|
+
| "Friday" — which one? | `bias: 'past' \| 'future' \| 'none'` | `none` (nearest) |
|
|
72
|
+
| "Next Tuesday" on a Sunday? | `nextWeekday: 'nearest' \| 'week-after'` | `nearest` |
|
|
73
|
+
| Does "last 3 days" include today? | `partialPeriod: 'include' \| 'exclude'` | `include` |
|
|
74
|
+
| "At 4" — am or pm? | — | both candidates, plausibility-ordered |
|
|
75
|
+
|
|
76
|
+
## Status
|
|
77
|
+
|
|
78
|
+
v0.1 — English parsing, en-* describe, core engine with a DST/edge-case battery. Recurrence (`every Tuesday`) is representable in the IR but resolves in v2.
|
|
79
|
+
|
|
80
|
+
**Corpus** ([corpus/](corpus/), runner exported as `nl2time/corpus`): a bidirectional golden set with per-case license provenance — 41 hand-authored NL→time cases (all passing), a 65-case datetime→NL golden set (all passing; to our knowledge the first published eval set for humanized time generation), and 693 cases imported from Microsoft Recognizers-Text (MIT) of which 12.6% currently pass — the honest coverage baseline the parser climbs against (`npm run eval`, `npm run baselines`). See [corpus/ATTRIBUTIONS.md](corpus/ATTRIBUTIONS.md) and [docs/porting.md](docs/porting.md).
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
npm install
|
|
86
|
+
npm test # vitest: conformance fixtures + DST battery + round-trip invariant
|
|
87
|
+
npm run typecheck
|
|
88
|
+
npm run build
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
MIT.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The clock seam: every Temporal usage in nl2time flows through this module.
|
|
3
|
+
*
|
|
4
|
+
* Portability contract (see docs/porting.md): a Python port replaces this
|
|
5
|
+
* module with `whenever` (Temporal-inspired) — Temporal.Instant ↔
|
|
6
|
+
* whenever.Instant, Temporal.ZonedDateTime ↔ whenever.ZonedDateTime,
|
|
7
|
+
* Temporal.PlainDate ↔ whenever.Date. No other module may import Temporal
|
|
8
|
+
* directly.
|
|
9
|
+
*/
|
|
10
|
+
import { Temporal } from 'temporal-polyfill';
|
|
11
|
+
import type { Grain, Unit, Weekday } from '../ir/types.js';
|
|
12
|
+
export { Temporal };
|
|
13
|
+
export type Instant = Temporal.Instant;
|
|
14
|
+
export type Zoned = Temporal.ZonedDateTime;
|
|
15
|
+
/** Half-open zoned interval [start, end) carrying its granularity. */
|
|
16
|
+
export interface ZInterval {
|
|
17
|
+
start: Zoned;
|
|
18
|
+
end: Zoned;
|
|
19
|
+
grain: Grain;
|
|
20
|
+
}
|
|
21
|
+
export declare function systemNow(): Instant;
|
|
22
|
+
export declare function systemTimeZone(): string;
|
|
23
|
+
export declare function toZoned(instant: Instant, timeZone: string): Zoned;
|
|
24
|
+
export declare function weekdayNumber(w: Weekday): number;
|
|
25
|
+
/** Point interval at z (grain 'instant'). */
|
|
26
|
+
export declare function pointInterval(z: Zoned): ZInterval;
|
|
27
|
+
/**
|
|
28
|
+
* The containing unit interval of z: floor to the unit boundary, extend one
|
|
29
|
+
* unit. Weeks honor weekStart; quarters are calendar quarters.
|
|
30
|
+
*/
|
|
31
|
+
export declare function containingUnit(z: Zoned, unit: Unit, weekStart: Weekday): ZInterval;
|
|
32
|
+
export declare function floorTo(z: Zoned, unit: Unit, weekStart: Weekday): Zoned;
|
|
33
|
+
/**
|
|
34
|
+
* Calendar-aware unit addition: day/week/month/quarter/year offsets preserve
|
|
35
|
+
* wall-clock time across DST (Temporal semantics); hour/minute/second are
|
|
36
|
+
* exact.
|
|
37
|
+
*/
|
|
38
|
+
export declare function addUnits(z: Zoned, unit: Unit, n: number): Zoned;
|
|
39
|
+
/** Whole calendar days between two zoned datetimes (calendar difference, not /86400s). */
|
|
40
|
+
export declare function calendarDaysBetween(from: Zoned, to: Zoned): number;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The clock seam: every Temporal usage in nl2time flows through this module.
|
|
3
|
+
*
|
|
4
|
+
* Portability contract (see docs/porting.md): a Python port replaces this
|
|
5
|
+
* module with `whenever` (Temporal-inspired) — Temporal.Instant ↔
|
|
6
|
+
* whenever.Instant, Temporal.ZonedDateTime ↔ whenever.ZonedDateTime,
|
|
7
|
+
* Temporal.PlainDate ↔ whenever.Date. No other module may import Temporal
|
|
8
|
+
* directly.
|
|
9
|
+
*/
|
|
10
|
+
import { Temporal } from 'temporal-polyfill';
|
|
11
|
+
export { Temporal };
|
|
12
|
+
export function systemNow() {
|
|
13
|
+
return Temporal.Now.instant();
|
|
14
|
+
}
|
|
15
|
+
export function systemTimeZone() {
|
|
16
|
+
return Temporal.Now.timeZoneId();
|
|
17
|
+
}
|
|
18
|
+
export function toZoned(instant, timeZone) {
|
|
19
|
+
return instant.toZonedDateTimeISO(timeZone);
|
|
20
|
+
}
|
|
21
|
+
const WEEKDAY_NUM = {
|
|
22
|
+
mon: 1,
|
|
23
|
+
tue: 2,
|
|
24
|
+
wed: 3,
|
|
25
|
+
thu: 4,
|
|
26
|
+
fri: 5,
|
|
27
|
+
sat: 6,
|
|
28
|
+
sun: 7,
|
|
29
|
+
};
|
|
30
|
+
export function weekdayNumber(w) {
|
|
31
|
+
return WEEKDAY_NUM[w];
|
|
32
|
+
}
|
|
33
|
+
/** Point interval at z (grain 'instant'). */
|
|
34
|
+
export function pointInterval(z) {
|
|
35
|
+
return { start: z, end: z, grain: 'instant' };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The containing unit interval of z: floor to the unit boundary, extend one
|
|
39
|
+
* unit. Weeks honor weekStart; quarters are calendar quarters.
|
|
40
|
+
*/
|
|
41
|
+
export function containingUnit(z, unit, weekStart) {
|
|
42
|
+
const start = floorTo(z, unit, weekStart);
|
|
43
|
+
return { start, end: addUnits(start, unit, 1), grain: unit };
|
|
44
|
+
}
|
|
45
|
+
export function floorTo(z, unit, weekStart) {
|
|
46
|
+
switch (unit) {
|
|
47
|
+
case 'second':
|
|
48
|
+
return z.round({ smallestUnit: 'second', roundingMode: 'floor' });
|
|
49
|
+
case 'minute':
|
|
50
|
+
return z.round({ smallestUnit: 'minute', roundingMode: 'floor' });
|
|
51
|
+
case 'hour':
|
|
52
|
+
return z.round({ smallestUnit: 'hour', roundingMode: 'floor' });
|
|
53
|
+
case 'day':
|
|
54
|
+
return z.startOfDay();
|
|
55
|
+
case 'week': {
|
|
56
|
+
const target = WEEKDAY_NUM[weekStart];
|
|
57
|
+
// dayOfWeek is ISO (mon=1..sun=7); step back to the week-start day.
|
|
58
|
+
const delta = (z.dayOfWeek - target + 7) % 7;
|
|
59
|
+
return z.startOfDay().subtract({ days: delta });
|
|
60
|
+
}
|
|
61
|
+
case 'month':
|
|
62
|
+
return z.with({ day: 1 }).startOfDay();
|
|
63
|
+
case 'quarter': {
|
|
64
|
+
const qStartMonth = Math.floor((z.month - 1) / 3) * 3 + 1;
|
|
65
|
+
return z.with({ month: qStartMonth, day: 1 }).startOfDay();
|
|
66
|
+
}
|
|
67
|
+
case 'year':
|
|
68
|
+
return z.with({ month: 1, day: 1 }).startOfDay();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Calendar-aware unit addition: day/week/month/quarter/year offsets preserve
|
|
73
|
+
* wall-clock time across DST (Temporal semantics); hour/minute/second are
|
|
74
|
+
* exact.
|
|
75
|
+
*/
|
|
76
|
+
export function addUnits(z, unit, n) {
|
|
77
|
+
switch (unit) {
|
|
78
|
+
case 'second':
|
|
79
|
+
return z.add({ seconds: n });
|
|
80
|
+
case 'minute':
|
|
81
|
+
return z.add({ minutes: n });
|
|
82
|
+
case 'hour':
|
|
83
|
+
return z.add({ hours: n });
|
|
84
|
+
case 'day':
|
|
85
|
+
return z.add({ days: n });
|
|
86
|
+
case 'week':
|
|
87
|
+
return z.add({ weeks: n });
|
|
88
|
+
case 'month':
|
|
89
|
+
return z.add({ months: n });
|
|
90
|
+
case 'quarter':
|
|
91
|
+
return z.add({ months: 3 * n });
|
|
92
|
+
case 'year':
|
|
93
|
+
return z.add({ years: n });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Whole calendar days between two zoned datetimes (calendar difference, not /86400s). */
|
|
97
|
+
export function calendarDaysBetween(from, to) {
|
|
98
|
+
return to.startOfDay().since(from.startOfDay(), { largestUnit: 'day' }).days;
|
|
99
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Instant, type Zoned } from './clock/index.js';
|
|
2
|
+
import type { DayPeriodRule } from './data/dayPeriods.js';
|
|
3
|
+
import type { Weekday } from './ir/types.js';
|
|
4
|
+
export declare class ConfigError extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
export type DateOrder = 'MDY' | 'DMY' | 'YMD';
|
|
8
|
+
export type Bias = 'past' | 'future' | 'none';
|
|
9
|
+
/** The dialectal "next Tuesday" question: nearest upcoming vs Tuesday of next week. */
|
|
10
|
+
export type NextWeekdayPolicy = 'nearest' | 'week-after';
|
|
11
|
+
/** Does "the last 3 days" include the partial current day? */
|
|
12
|
+
export type PartialPeriodPolicy = 'include' | 'exclude';
|
|
13
|
+
export interface TimeContextOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Reference instant. Defaults to the system clock; pass an explicit value
|
|
16
|
+
* (Temporal.Instant or ISO string) for reproducible resolution and tests.
|
|
17
|
+
*/
|
|
18
|
+
now?: Instant | string;
|
|
19
|
+
/** IANA timezone of the *user/utterance*, not the machine. Defaults to system. */
|
|
20
|
+
timeZone?: string;
|
|
21
|
+
/** BCP-47 language-REGION tag. Region drives week-start & date-order defaults. */
|
|
22
|
+
locale?: string;
|
|
23
|
+
weekStart?: Weekday;
|
|
24
|
+
dateOrder?: DateOrder;
|
|
25
|
+
/** Direction preference for underspecified expressions ("Friday", "May 29"). */
|
|
26
|
+
bias?: Bias;
|
|
27
|
+
nextWeekday?: NextWeekdayPolicy;
|
|
28
|
+
partialPeriod?: PartialPeriodPolicy;
|
|
29
|
+
/** Override the locale's day-period boundaries (e.g. to match another system's conventions). */
|
|
30
|
+
dayPeriods?: DayPeriodRule[];
|
|
31
|
+
}
|
|
32
|
+
export declare class TimeContext {
|
|
33
|
+
readonly now: Instant;
|
|
34
|
+
readonly timeZone: string;
|
|
35
|
+
readonly locale: string;
|
|
36
|
+
readonly language: string;
|
|
37
|
+
readonly region: string | undefined;
|
|
38
|
+
readonly weekStart: Weekday;
|
|
39
|
+
readonly dateOrder: DateOrder;
|
|
40
|
+
readonly bias: Bias;
|
|
41
|
+
readonly nextWeekday: NextWeekdayPolicy;
|
|
42
|
+
readonly partialPeriod: PartialPeriodPolicy;
|
|
43
|
+
readonly dayPeriods: DayPeriodRule[] | undefined;
|
|
44
|
+
private constructor();
|
|
45
|
+
static make(opts?: TimeContextOptions): TimeContext;
|
|
46
|
+
/** The reference instant projected into the context timezone. */
|
|
47
|
+
get zonedNow(): Zoned;
|
|
48
|
+
}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Temporal, systemNow, systemTimeZone, toZoned, } from './clock/index.js';
|
|
2
|
+
import { firstDayForRegion } from './data/weekData.js';
|
|
3
|
+
export class ConfigError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'ConfigError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const MDY_REGIONS = ['US', 'PH', 'UM', 'VI', 'GU', 'AS', 'PR'];
|
|
10
|
+
const YMD_REGIONS = ['CN', 'JP', 'KR', 'TW', 'HU', 'MN', 'LT'];
|
|
11
|
+
export class TimeContext {
|
|
12
|
+
now;
|
|
13
|
+
timeZone;
|
|
14
|
+
locale;
|
|
15
|
+
language;
|
|
16
|
+
region;
|
|
17
|
+
weekStart;
|
|
18
|
+
dateOrder;
|
|
19
|
+
bias;
|
|
20
|
+
nextWeekday;
|
|
21
|
+
partialPeriod;
|
|
22
|
+
dayPeriods;
|
|
23
|
+
constructor(opts) {
|
|
24
|
+
const rawNow = opts.now ?? systemNow();
|
|
25
|
+
try {
|
|
26
|
+
this.now = typeof rawNow === 'string' ? parseInstant(rawNow) : rawNow;
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
throw new ConfigError(`invalid now: ${String(e)}`);
|
|
30
|
+
}
|
|
31
|
+
this.timeZone = opts.timeZone ?? systemTimeZone();
|
|
32
|
+
this.locale = opts.locale ?? 'en-US';
|
|
33
|
+
let language;
|
|
34
|
+
let region;
|
|
35
|
+
try {
|
|
36
|
+
const tag = new Intl.Locale(this.locale);
|
|
37
|
+
language = tag.language;
|
|
38
|
+
region = tag.region;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new ConfigError(`invalid locale: ${this.locale}`);
|
|
42
|
+
}
|
|
43
|
+
this.language = language;
|
|
44
|
+
this.region = region;
|
|
45
|
+
this.weekStart = opts.weekStart ?? firstDayForRegion(region);
|
|
46
|
+
this.dateOrder =
|
|
47
|
+
opts.dateOrder ??
|
|
48
|
+
(region && MDY_REGIONS.includes(region)
|
|
49
|
+
? 'MDY'
|
|
50
|
+
: region && YMD_REGIONS.includes(region)
|
|
51
|
+
? 'YMD'
|
|
52
|
+
: 'DMY');
|
|
53
|
+
this.bias = opts.bias ?? 'none';
|
|
54
|
+
this.nextWeekday = opts.nextWeekday ?? 'nearest';
|
|
55
|
+
this.partialPeriod = opts.partialPeriod ?? 'include';
|
|
56
|
+
this.dayPeriods = opts.dayPeriods;
|
|
57
|
+
// Validate the timezone eagerly so failures surface at construction.
|
|
58
|
+
try {
|
|
59
|
+
toZoned(this.now, this.timeZone);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new ConfigError(`invalid timeZone: ${this.timeZone}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
static make(opts = {}) {
|
|
66
|
+
return new TimeContext(opts);
|
|
67
|
+
}
|
|
68
|
+
/** The reference instant projected into the context timezone. */
|
|
69
|
+
get zonedNow() {
|
|
70
|
+
return toZoned(this.now, this.timeZone);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function parseInstant(s) {
|
|
74
|
+
return Temporal.Instant.from(s);
|
|
75
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type TimeContextOptions } from '../context.js';
|
|
2
|
+
import { type DescribeOptions } from '../describe/index.js';
|
|
3
|
+
import type { CalendarAmount, Grain } from '../ir/types.js';
|
|
4
|
+
export interface SourceInfo {
|
|
5
|
+
name: string;
|
|
6
|
+
license: string;
|
|
7
|
+
url?: string;
|
|
8
|
+
path?: string;
|
|
9
|
+
commit?: string;
|
|
10
|
+
index?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface IntervalExpect {
|
|
13
|
+
/** Instant expectations (ISO with offset/Z). */
|
|
14
|
+
start?: string;
|
|
15
|
+
end?: string;
|
|
16
|
+
/** Civil expectations, compared in the case's timeZone ("YYYY-MM-DDTHH:MM:SS"). */
|
|
17
|
+
startLocal?: string;
|
|
18
|
+
endLocal?: string;
|
|
19
|
+
/** Civil point: candidate interval start must equal it. */
|
|
20
|
+
pointLocal?: string;
|
|
21
|
+
/** Omit or null to skip the grain check (imported cases). */
|
|
22
|
+
grain?: Grain | null;
|
|
23
|
+
}
|
|
24
|
+
export interface ForwardExpect {
|
|
25
|
+
noMatch?: boolean;
|
|
26
|
+
amount?: CalendarAmount;
|
|
27
|
+
durationSeconds?: number;
|
|
28
|
+
/** Ordered: candidate i must match spec i (hand-authored cases). */
|
|
29
|
+
first?: IntervalExpect;
|
|
30
|
+
candidates?: IntervalExpect[];
|
|
31
|
+
/** Unordered: every value must appear among resolved candidates (imported). */
|
|
32
|
+
values?: IntervalExpect[];
|
|
33
|
+
}
|
|
34
|
+
export interface ForwardCase {
|
|
35
|
+
id: string;
|
|
36
|
+
text: string;
|
|
37
|
+
ctx: TimeContextOptions;
|
|
38
|
+
expect: ForwardExpect;
|
|
39
|
+
/** Default: 'core'. */
|
|
40
|
+
level?: 'core' | 'aspirational';
|
|
41
|
+
source?: SourceInfo;
|
|
42
|
+
tags?: string[];
|
|
43
|
+
note?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface ReverseCase {
|
|
46
|
+
id: string;
|
|
47
|
+
value: {
|
|
48
|
+
instant?: string;
|
|
49
|
+
interval?: {
|
|
50
|
+
start: string;
|
|
51
|
+
end: string;
|
|
52
|
+
grain: Grain;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
ctx: TimeContextOptions;
|
|
56
|
+
opts?: DescribeOptions;
|
|
57
|
+
/** Expected nl2time output (normalized). Graded exactly for level 'core'. */
|
|
58
|
+
primary: string;
|
|
59
|
+
/** Additional acceptable renderings, for grading other systems/LLM output. */
|
|
60
|
+
accept?: string[];
|
|
61
|
+
framing?: string;
|
|
62
|
+
/** Default: 'core'. */
|
|
63
|
+
level?: 'core' | 'aspirational';
|
|
64
|
+
source?: SourceInfo;
|
|
65
|
+
tags?: string[];
|
|
66
|
+
note?: string;
|
|
67
|
+
}
|
|
68
|
+
export interface CaseOutcome {
|
|
69
|
+
id: string;
|
|
70
|
+
pass: boolean;
|
|
71
|
+
detail?: string;
|
|
72
|
+
}
|
|
73
|
+
/** Lowercase, unify NBSP/NNBSP, collapse whitespace, strip trailing period. */
|
|
74
|
+
export declare function normalizeText(s: string): string;
|
|
75
|
+
export declare function runForwardCase(c: ForwardCase): CaseOutcome;
|
|
76
|
+
export declare function runReverseCase(c: ReverseCase): CaseOutcome;
|
|
77
|
+
/**
|
|
78
|
+
* Grade arbitrary text (another system, an LLM) against a reverse case:
|
|
79
|
+
* primary and every accept entry are acceptable.
|
|
80
|
+
*/
|
|
81
|
+
export declare function gradeReverseText(c: ReverseCase, text: string): boolean;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Corpus runner: executes golden-set cases (corpus/) against this
|
|
3
|
+
* implementation. Exported as `nl2time/corpus` so evals, ports, and consumers
|
|
4
|
+
* can grade their own pipelines against the same cases.
|
|
5
|
+
*
|
|
6
|
+
* Case format: see corpus/README.md. All comparisons here are pure functions
|
|
7
|
+
* of (case, implementation output) — no I/O.
|
|
8
|
+
*/
|
|
9
|
+
import { Temporal, toZoned } from '../clock/index.js';
|
|
10
|
+
import { TimeContext } from '../context.js';
|
|
11
|
+
import { describe } from '../describe/index.js';
|
|
12
|
+
import { resolve } from '../engine/resolve.js';
|
|
13
|
+
import { parse } from '../parse/index.js';
|
|
14
|
+
/** Lowercase, unify NBSP/NNBSP, collapse whitespace, strip trailing period. */
|
|
15
|
+
export function normalizeText(s) {
|
|
16
|
+
return s
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[ ]/g, ' ')
|
|
19
|
+
.replace(/\s+/g, ' ')
|
|
20
|
+
.replace(/\.$/, '')
|
|
21
|
+
.trim();
|
|
22
|
+
}
|
|
23
|
+
function localString(instant, timeZone) {
|
|
24
|
+
const pdt = toZoned(instant, timeZone).toPlainDateTime();
|
|
25
|
+
// Normalize to second precision: "YYYY-MM-DDTHH:MM:SS".
|
|
26
|
+
return pdt.toString({ smallestUnit: 'second' });
|
|
27
|
+
}
|
|
28
|
+
function normLocal(s) {
|
|
29
|
+
// Pad "2016-11-07T16:12" → "2016-11-07T16:12:00".
|
|
30
|
+
return s.length === 16 ? `${s}:00` : s;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Equality with inclusive-end tolerance: some upstream corpora express
|
|
34
|
+
* interval ends as the last included second ("23:59:59"); our half-open
|
|
35
|
+
* convention lands one second later ("00:00:00" next day). Accept both.
|
|
36
|
+
*/
|
|
37
|
+
function localEq(actual, expected) {
|
|
38
|
+
if (actual === expected)
|
|
39
|
+
return true;
|
|
40
|
+
if (!expected.endsWith(':59:59') && !expected.endsWith(':59'))
|
|
41
|
+
return false;
|
|
42
|
+
const bumped = Temporal.PlainDateTime.from(expected).add({ seconds: 1 }).toString({
|
|
43
|
+
smallestUnit: 'second',
|
|
44
|
+
});
|
|
45
|
+
return actual === bumped;
|
|
46
|
+
}
|
|
47
|
+
const AMOUNT_SECONDS = {
|
|
48
|
+
years: 31536000,
|
|
49
|
+
months: 2592000,
|
|
50
|
+
weeks: 604800,
|
|
51
|
+
days: 86400,
|
|
52
|
+
hours: 3600,
|
|
53
|
+
minutes: 60,
|
|
54
|
+
seconds: 1,
|
|
55
|
+
};
|
|
56
|
+
function amountToSeconds(amount) {
|
|
57
|
+
let total = 0;
|
|
58
|
+
for (const [k, v] of Object.entries(amount)) {
|
|
59
|
+
if (typeof v === 'number')
|
|
60
|
+
total += v * (AMOUNT_SECONDS[k] ?? 0);
|
|
61
|
+
}
|
|
62
|
+
return total;
|
|
63
|
+
}
|
|
64
|
+
function isoDurationToSeconds(iso) {
|
|
65
|
+
const m = iso.match(/^(-)?P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/);
|
|
66
|
+
if (!m)
|
|
67
|
+
return undefined;
|
|
68
|
+
const [, neg, y, mo, w, d, h, mi, s] = m;
|
|
69
|
+
const total = (Number(y ?? 0) * 31536000) +
|
|
70
|
+
(Number(mo ?? 0) * 2592000) +
|
|
71
|
+
(Number(w ?? 0) * 604800) +
|
|
72
|
+
(Number(d ?? 0) * 86400) +
|
|
73
|
+
(Number(h ?? 0) * 3600) +
|
|
74
|
+
(Number(mi ?? 0) * 60) +
|
|
75
|
+
Number(s ?? 0);
|
|
76
|
+
return neg ? -total : total;
|
|
77
|
+
}
|
|
78
|
+
function matchesInterval(v, spec, timeZone) {
|
|
79
|
+
if (v.kind !== 'interval')
|
|
80
|
+
return false;
|
|
81
|
+
if (spec.grain !== undefined && spec.grain !== null && v.grain !== spec.grain)
|
|
82
|
+
return false;
|
|
83
|
+
if (spec.start !== undefined && v.start.toString() !== spec.start)
|
|
84
|
+
return false;
|
|
85
|
+
if (spec.end !== undefined && v.end.toString() !== spec.end)
|
|
86
|
+
return false;
|
|
87
|
+
if (spec.startLocal !== undefined && !localEq(localString(v.start, timeZone), normLocal(spec.startLocal)))
|
|
88
|
+
return false;
|
|
89
|
+
if (spec.endLocal !== undefined) {
|
|
90
|
+
const actualEnd = localString(v.end, timeZone);
|
|
91
|
+
let expectedEnd = normLocal(spec.endLocal);
|
|
92
|
+
// Some upstream time ranges emit a cross-midnight end without the date
|
|
93
|
+
// roll (end textually before start) — accept the next-day equivalent.
|
|
94
|
+
if (spec.startLocal !== undefined && expectedEnd < normLocal(spec.startLocal)) {
|
|
95
|
+
const rolled = Temporal.PlainDateTime.from(expectedEnd).add({ days: 1 }).toString({ smallestUnit: 'second' });
|
|
96
|
+
if (!localEq(actualEnd, expectedEnd) && !localEq(actualEnd, rolled))
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
else if (!localEq(actualEnd, expectedEnd)) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (spec.pointLocal !== undefined && !localEq(localString(v.start, timeZone), normLocal(spec.pointLocal)))
|
|
104
|
+
return false;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
function matchesAmountish(v, seconds) {
|
|
108
|
+
if (v.kind === 'amount')
|
|
109
|
+
return amountToSeconds(v.amount) === seconds;
|
|
110
|
+
if (v.kind === 'duration')
|
|
111
|
+
return isoDurationToSeconds(v.iso) === seconds;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
export function runForwardCase(c) {
|
|
115
|
+
let ctx;
|
|
116
|
+
try {
|
|
117
|
+
ctx = TimeContext.make(c.ctx);
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
return { id: c.id, pass: false, detail: `context: ${String(e)}` };
|
|
121
|
+
}
|
|
122
|
+
const { matches } = parse(c.text, ctx);
|
|
123
|
+
if (c.expect.noMatch) {
|
|
124
|
+
return matches.length === 0
|
|
125
|
+
? { id: c.id, pass: true }
|
|
126
|
+
: { id: c.id, pass: false, detail: `unexpected match "${matches[0].text}"` };
|
|
127
|
+
}
|
|
128
|
+
if (matches.length === 0)
|
|
129
|
+
return { id: c.id, pass: false, detail: 'no match' };
|
|
130
|
+
const tz = ctx.timeZone;
|
|
131
|
+
// Hand-authored expectations grade the first match; imported `values`
|
|
132
|
+
// expectations accept any match (multi-span utterances).
|
|
133
|
+
for (const [mi, m] of matches.entries()) {
|
|
134
|
+
let candidates;
|
|
135
|
+
try {
|
|
136
|
+
candidates = resolve(m.expr, ctx).candidates;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
let ok = true;
|
|
142
|
+
if (c.expect.amount) {
|
|
143
|
+
ok = candidates.some((v) => v.kind === 'amount' && JSON.stringify(v.amount) === JSON.stringify(c.expect.amount));
|
|
144
|
+
}
|
|
145
|
+
if (ok && c.expect.durationSeconds !== undefined) {
|
|
146
|
+
ok = candidates.some((v) => matchesAmountish(v, c.expect.durationSeconds));
|
|
147
|
+
}
|
|
148
|
+
if (ok && c.expect.first) {
|
|
149
|
+
ok = candidates.length > 0 && matchesInterval(candidates[0], c.expect.first, tz);
|
|
150
|
+
}
|
|
151
|
+
if (ok && c.expect.candidates) {
|
|
152
|
+
ok =
|
|
153
|
+
candidates.length >= c.expect.candidates.length &&
|
|
154
|
+
c.expect.candidates.every((spec, i) => matchesInterval(candidates[i], spec, tz));
|
|
155
|
+
}
|
|
156
|
+
if (ok && c.expect.values) {
|
|
157
|
+
ok = c.expect.values.every((spec) => candidates.some((v) => matchesInterval(v, spec, tz)));
|
|
158
|
+
}
|
|
159
|
+
if (ok)
|
|
160
|
+
return { id: c.id, pass: true };
|
|
161
|
+
if (mi === 0 && (c.expect.first || c.expect.candidates)) {
|
|
162
|
+
// Ordered expectations are strict about the first match.
|
|
163
|
+
return { id: c.id, pass: false, detail: describeMismatch(candidates, tz) };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { id: c.id, pass: false, detail: describeMismatch(resolveSafe(matches[0].expr, ctx), tz) };
|
|
167
|
+
}
|
|
168
|
+
function resolveSafe(expr, ctx) {
|
|
169
|
+
try {
|
|
170
|
+
return resolve(expr, ctx).candidates;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function describeMismatch(candidates, tz) {
|
|
177
|
+
const shown = candidates
|
|
178
|
+
.slice(0, 3)
|
|
179
|
+
.map((v) => v.kind === 'interval'
|
|
180
|
+
? `[${localString(v.start, tz)}, ${localString(v.end, tz)}) ${v.grain}`
|
|
181
|
+
: JSON.stringify(v))
|
|
182
|
+
.join(' | ');
|
|
183
|
+
return `got: ${shown || '(none)'}`;
|
|
184
|
+
}
|
|
185
|
+
export function runReverseCase(c) {
|
|
186
|
+
let ctx;
|
|
187
|
+
try {
|
|
188
|
+
ctx = TimeContext.make(c.ctx);
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
return { id: c.id, pass: false, detail: `context: ${String(e)}` };
|
|
192
|
+
}
|
|
193
|
+
const value = c.value.instant
|
|
194
|
+
? Temporal.Instant.from(c.value.instant)
|
|
195
|
+
: {
|
|
196
|
+
kind: 'interval',
|
|
197
|
+
start: Temporal.Instant.from(c.value.interval.start),
|
|
198
|
+
end: Temporal.Instant.from(c.value.interval.end),
|
|
199
|
+
grain: c.value.interval.grain,
|
|
200
|
+
};
|
|
201
|
+
const d = describe(value, ctx, c.opts);
|
|
202
|
+
const got = normalizeText(d.text);
|
|
203
|
+
if (got === normalizeText(c.primary)) {
|
|
204
|
+
if (c.framing && d.framing !== c.framing) {
|
|
205
|
+
return { id: c.id, pass: false, detail: `framing ${d.framing} ≠ ${c.framing}` };
|
|
206
|
+
}
|
|
207
|
+
return { id: c.id, pass: true };
|
|
208
|
+
}
|
|
209
|
+
return { id: c.id, pass: false, detail: `got "${d.text}"` };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Grade arbitrary text (another system, an LLM) against a reverse case:
|
|
213
|
+
* primary and every accept entry are acceptable.
|
|
214
|
+
*/
|
|
215
|
+
export function gradeReverseText(c, text) {
|
|
216
|
+
const got = normalizeText(text);
|
|
217
|
+
if (got === normalizeText(c.primary))
|
|
218
|
+
return true;
|
|
219
|
+
return (c.accept ?? []).some((a) => normalizeText(a) === got);
|
|
220
|
+
}
|