nl2time 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,8 @@ describe(Temporal.Instant.from('2026-07-20T04:00:00Z'), ctx).text;
24
24
  // 'yesterday at 9:00 PM'
25
25
  ```
26
26
 
27
+ > **New here?** Read the **[step-by-step walkthrough](docs/walkthrough.md)** — two fully-traced examples showing exactly what happens at each stage in both directions (tokens → rules → IR → deterministic resolution, and instant → framing → IR → rendered speech).
28
+
27
29
  ## Why another date library?
28
30
 
29
31
  Existing tools do one direction, half-way:
@@ -60,6 +62,10 @@ const result = await parseWithFallback(text, ctx, async (text, ctxSummary) => {
60
62
 
61
63
  See [docs/agents.md](docs/agents.md) for reference architectures (analytics agents, tool-calling, deferred resolution).
62
64
 
65
+ ## Domain adaptation
66
+
67
+ Domains with conflicting or novel vocabulary ("FY26", "EOD means 5pm", "swing shift") extend the parser **without forking** via declarative JSON packs — phrase→IR templates with integer/year captures that compete with (and can shadow) the built-in rules, plus a `disable` list and a code-level `Rule` escape hatch. Packs ship their own golden cases, runnable with `nl2time/corpus`. See [docs/extending.md](docs/extending.md) and the worked [fiscal-calendar example](examples/fiscal-july/).
68
+
63
69
  ## Policy, not guesses
64
70
 
65
71
  Cultural/ambiguous semantics are explicit `TimeContext` knobs with CLDR-derived defaults:
@@ -73,11 +79,46 @@ Cultural/ambiguous semantics are explicit `TimeContext` knobs with CLDR-derived
73
79
  | Does "last 3 days" include today? | `partialPeriod: 'include' \| 'exclude'` | `include` |
74
80
  | "At 4" — am or pm? | — | both candidates, plausibility-ordered |
75
81
 
82
+ ## Install
83
+
84
+ [![npm](https://img.shields.io/npm/v/nl2time)](https://www.npmjs.com/package/nl2time)
85
+
86
+ ```
87
+ npm install nl2time
88
+ ```
89
+
90
+ ## Languages
91
+
92
+ Parsing is multilingual, dispatched by the context's locale. Each language climbs its own imported conformance corpus (Microsoft Recognizers-Text specs, MIT) with a CI-gated baseline:
93
+
94
+ | Language | Corpus cases | Passing |
95
+ |---|---|---|
96
+ | English (en-US / en-GB) | 1,031 | 84% |
97
+ | German | 157 | 97% |
98
+ | Japanese | 393 | 96% |
99
+ | Chinese (Simplified) | 175 | 95% |
100
+ | French | 406 | 90% |
101
+ | Spanish | 579 | 96% |
102
+
103
+ Latin-script languages share a parameterized rule factory (`makeLatinRules` + a lexicon); CJK languages use per-character tokenization with bespoke rule modules. `describe()` output is currently English; localized rendering is on the roadmap. Remaining failure mass is dominated by documented upstream divergences (e.g. issue #14).
104
+
76
105
  ## Status
77
106
 
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.
107
+ v0.3 — multilingual parsing (6 locales), en-* describe, core engine with a DST/edge-case battery, holidays (fixed-date, nth-weekday, computed Easter), business-day spans, domain packs. Recurrence (`every Tuesday`) is representable in the IR but resolves in v2.
108
+
109
+ **Corpus** ([corpus/](corpus/), runner exported as `nl2time/corpus`): bidirectional golden sets with per-case license provenance — hand-authored forward + reverse sets (100% passing, including the machine-inverted reverse set), and ~2,700 gradeable imported cases across six languages with per-language CI regression baselines (`npm run eval`, `npm run baselines`). See [corpus/ATTRIBUTIONS.md](corpus/ATTRIBUTIONS.md) and [docs/porting.md](docs/porting.md).
110
+
111
+ ## Test-data provenance
112
+
113
+ Every imported conformance case carries machine-readable provenance (upstream project, license, file path, pinned commit, index), and upstream licenses are vendored verbatim beside the data. **[corpus/ATTRIBUTIONS.md](corpus/ATTRIBUTIONS.md)** is the authoritative record: what is vendored (Microsoft Recognizers-Text, MIT), what is original (the hand-authored and machine-inverted golden sets), what is used eval-only and never redistributed (TempEval-3 Platinum, WikiWars), what was deliberately excluded on license grounds (GPL/LDC/ODbL sources), and the academic lineage (TimeML/TIMEX3, SCATE, TempEval-3, and the LLM date-arithmetic benchmarks that motivated the deterministic-IR design). Known upstream data defects are documented there too, with the exclusion rules applied at import.
114
+
115
+ ## Python
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.
118
+
119
+ ## Release history
79
120
 
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).
121
+ See **[CHANGELOG.md](CHANGELOG.md)** and [GitHub releases](https://github.com/AndyFooBlah/nl2time/releases). Current: v0.3.0 (multilingual parsing).
81
122
 
82
123
  ## Development
83
124
 
@@ -1,6 +1,7 @@
1
1
  import { type TimeContextOptions } from '../context.js';
2
2
  import { type DescribeOptions } from '../describe/index.js';
3
3
  import type { CalendarAmount, Grain } from '../ir/types.js';
4
+ import type { DomainPack } from '../packs/index.js';
4
5
  export interface SourceInfo {
5
6
  name: string;
6
7
  license: string;
@@ -36,6 +37,8 @@ export interface ForwardCase {
36
37
  text: string;
37
38
  ctx: TimeContextOptions;
38
39
  expect: ForwardExpect;
40
+ /** Domain packs to activate for this case (docs/extending.md). */
41
+ packs?: DomainPack[];
39
42
  /** Default: 'core'. */
40
43
  level?: 'core' | 'aspirational';
41
44
  source?: SourceInfo;
@@ -119,7 +119,7 @@ export function runForwardCase(c) {
119
119
  catch (e) {
120
120
  return { id: c.id, pass: false, detail: `context: ${String(e)}` };
121
121
  }
122
- const { matches } = parse(c.text, ctx);
122
+ const { matches } = parse(c.text, ctx, c.packs ? { packs: c.packs } : undefined);
123
123
  if (c.expect.noMatch) {
124
124
  return matches.length === 0
125
125
  ? { id: c.id, pass: true }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,10 @@ export type { TimeExpr, Grain, Unit, Weekday, DayPeriod, CalendarAmount, Mod, Ta
3
3
  export { IR_VERSION } from './ir/types.js';
4
4
  export { validateExpr, IRValidationError } from './ir/validate.js';
5
5
  export { resolve, resolveOne, NotResolvableError, AmbiguityError, type TimeValue, type Resolution, } from './engine/resolve.js';
6
- export { parse, type ParseResult, type ParseMatch } from './parse/index.js';
6
+ export { parse, createParser, type ParseResult, type ParseMatch, type ParseOptions, } from './parse/index.js';
7
+ export { EN_RULE_ENTRIES, type Rule, type RuleMatch } from './parse/en.js';
8
+ export { SUPPORTED_LANGUAGES, languageDef } from './parse/languages.js';
9
+ export { makeLatinRules, type LatinLexicon } from './parse/latin.js';
10
+ export { compilePack, validatePack, PackError, type DomainPack, type VocabEntry, } from './packs/index.js';
7
11
  export { describe, type Description, type DescribeOptions, type Framing, } from './describe/index.js';
8
12
  export { Temporal } from './clock/index.js';
package/dist/index.js CHANGED
@@ -2,6 +2,10 @@ export { TimeContext, ConfigError, } from './context.js';
2
2
  export { IR_VERSION } from './ir/types.js';
3
3
  export { validateExpr, IRValidationError } from './ir/validate.js';
4
4
  export { resolve, resolveOne, NotResolvableError, AmbiguityError, } from './engine/resolve.js';
5
- export { parse } from './parse/index.js';
5
+ export { parse, createParser, } from './parse/index.js';
6
+ export { EN_RULE_ENTRIES } from './parse/en.js';
7
+ export { SUPPORTED_LANGUAGES, languageDef } from './parse/languages.js';
8
+ export { makeLatinRules } from './parse/latin.js';
9
+ export { compilePack, validatePack, PackError, } from './packs/index.js';
6
10
  export { describe, } from './describe/index.js';
7
11
  export { Temporal } from './clock/index.js';
@@ -0,0 +1,39 @@
1
+ import type { Rule } from '../parse/en.js';
2
+ export interface VocabEntry {
3
+ /** Alternative phrasings, matched case-insensitively over tokens. */
4
+ phrases: string[];
5
+ /** TimeExpr template; integers may be capture references (see module doc). */
6
+ expr?: unknown;
7
+ /**
8
+ * Shorthand for a named time-of-day interval in local 24h hours
9
+ * ("swing shift" 16→24). `before` ≤ `from` wraps past midnight.
10
+ * Exactly one of `expr` / `period` is required.
11
+ */
12
+ period?: {
13
+ from: number;
14
+ before: number;
15
+ };
16
+ /** How the match combines with neighbors (default: 'date'; periods: 'time'). */
17
+ role?: 'date' | 'time' | 'datetime' | 'duration';
18
+ /** Default 0.98 — above built-ins, so equal-length matches shadow them. */
19
+ confidence?: number;
20
+ }
21
+ export interface DomainPack {
22
+ name: string;
23
+ /** Built-in rule names to turn off entirely (see EN_RULE_ENTRIES). */
24
+ disable?: string[];
25
+ vocabulary: VocabEntry[];
26
+ /**
27
+ * Suggested TimeContext options for this domain (weekStart, dayPeriods, …).
28
+ * Not applied automatically — merge into TimeContext.make() yourself, since
29
+ * the context may also carry per-user settings.
30
+ */
31
+ context?: Record<string, unknown>;
32
+ }
33
+ export declare class PackError extends Error {
34
+ constructor(message: string);
35
+ }
36
+ /** Compile a pack's vocabulary into a single scanner rule. */
37
+ export declare function compilePack(pack: DomainPack): Rule;
38
+ /** Structural validation for untrusted pack JSON (config files, uploads). */
39
+ export declare function validatePack(input: unknown): DomainPack;
@@ -0,0 +1,156 @@
1
+ import { IRValidationError, validateExpr } from '../ir/validate.js';
2
+ export class PackError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'PackError';
6
+ }
7
+ }
8
+ const CAPTURE_RE = /\{(n|yr)\}/g;
9
+ function compilePhrase(phrase) {
10
+ return phrase
11
+ .trim()
12
+ .toLowerCase()
13
+ .split(/\s+/)
14
+ .map((seg) => {
15
+ const captures = [...seg.matchAll(CAPTURE_RE)].map((m) => m[1]);
16
+ if (captures.length === 0)
17
+ return { kind: 'word', value: seg };
18
+ if (seg === '{n}' || seg === '{yr}')
19
+ return { kind: 'number', capture: captures[0] };
20
+ // Embedded captures: "fy{yr}" → /^fy(\d{1,4})$/
21
+ const source = seg
22
+ .replace(/[.*+?^${}()|[\]\\]/g, (c) => (c === '{' || c === '}' ? c : `\\${c}`))
23
+ .replace(CAPTURE_RE, '(\\d{1,4})');
24
+ return { kind: 'embedded', regex: new RegExp(`^${source}$`), captures };
25
+ });
26
+ }
27
+ function normalizeCapture(name, raw) {
28
+ if (name === 'yr' && raw < 100)
29
+ return 2000 + raw;
30
+ return raw;
31
+ }
32
+ /** Deep-substitute capture references in a template; returns a fresh tree. */
33
+ function substitute(node, captures) {
34
+ if (Array.isArray(node))
35
+ return node.map((x) => substitute(x, captures));
36
+ if (typeof node === 'object' && node !== null) {
37
+ const obj = node;
38
+ if (typeof obj['$'] === 'string') {
39
+ const base = captures[obj['$']];
40
+ if (base === undefined)
41
+ throw new PackError(`unbound capture "${obj['$']}"`);
42
+ const scale = typeof obj['scale'] === 'number' ? obj['scale'] : 1;
43
+ const offset = typeof obj['offset'] === 'number' ? obj['offset'] : 0;
44
+ return base * scale + offset;
45
+ }
46
+ const out = {};
47
+ for (const [k, v] of Object.entries(obj))
48
+ out[k] = substitute(v, captures);
49
+ return out;
50
+ }
51
+ return node;
52
+ }
53
+ function periodTemplate(period) {
54
+ return {
55
+ op: 'between',
56
+ start: { op: 'literal', time: { hour: period.from } },
57
+ end: { op: 'literal', time: { hour: period.before === 24 ? 0 : period.before } },
58
+ };
59
+ }
60
+ function word(t) {
61
+ return t?.type === 'word' ? t.value : undefined;
62
+ }
63
+ function compileEntry(entry, packName, index) {
64
+ if ((entry.expr === undefined) === (entry.period === undefined)) {
65
+ throw new PackError(`${packName}[${index}]: exactly one of expr/period is required`);
66
+ }
67
+ const template = entry.expr ?? periodTemplate(entry.period);
68
+ const role = entry.role ?? (entry.period ? 'time' : 'date');
69
+ const confidence = entry.confidence ?? 0.98;
70
+ // Fail fast: validate the template with sample captures.
71
+ try {
72
+ validateExpr(substitute(template, { n: 1, yr: 2026 }));
73
+ }
74
+ catch (e) {
75
+ if (e instanceof IRValidationError || e instanceof PackError) {
76
+ throw new PackError(`${packName}[${index}]: invalid template — ${e.message}`);
77
+ }
78
+ throw e;
79
+ }
80
+ return entry.phrases.map((p) => ({
81
+ segments: compilePhrase(p),
82
+ template,
83
+ role,
84
+ confidence,
85
+ }));
86
+ }
87
+ /** Compile a pack's vocabulary into a single scanner rule. */
88
+ export function compilePack(pack) {
89
+ const entries = pack.vocabulary.flatMap((e, idx) => compileEntry(e, pack.name, idx));
90
+ return (tokens, i) => {
91
+ let best;
92
+ for (const entry of entries) {
93
+ const captures = {};
94
+ let ok = true;
95
+ for (const [si, seg] of entry.segments.entries()) {
96
+ const t = tokens[i + si];
97
+ if (seg.kind === 'word') {
98
+ const w = word(t);
99
+ // Literal numeric segments ("4") also match number tokens.
100
+ if (w !== seg.value && !(t?.type === 'number' && String(t.value) === seg.value)) {
101
+ ok = false;
102
+ break;
103
+ }
104
+ }
105
+ else if (seg.kind === 'number') {
106
+ if (t?.type !== 'number') {
107
+ ok = false;
108
+ break;
109
+ }
110
+ captures[seg.capture] = normalizeCapture(seg.capture, t.value);
111
+ }
112
+ else {
113
+ const w = word(t);
114
+ const m = w?.match(seg.regex);
115
+ if (!m) {
116
+ ok = false;
117
+ break;
118
+ }
119
+ seg.captures.forEach((name, ci) => {
120
+ captures[name] = normalizeCapture(name, Number(m[ci + 1]));
121
+ });
122
+ }
123
+ }
124
+ if (!ok)
125
+ continue;
126
+ const consumed = entry.segments.length;
127
+ if (best && consumed <= best.consumed)
128
+ continue;
129
+ best = {
130
+ expr: substitute(entry.template, captures),
131
+ consumed,
132
+ confidence: entry.confidence,
133
+ role: entry.role,
134
+ };
135
+ }
136
+ return best;
137
+ };
138
+ }
139
+ /** Structural validation for untrusted pack JSON (config files, uploads). */
140
+ export function validatePack(input) {
141
+ if (typeof input !== 'object' || input === null)
142
+ throw new PackError('pack must be an object');
143
+ const p = input;
144
+ if (typeof p.name !== 'string' || p.name === '')
145
+ throw new PackError('pack.name required');
146
+ if (!Array.isArray(p.vocabulary) || p.vocabulary.length === 0) {
147
+ throw new PackError('pack.vocabulary must be a non-empty array');
148
+ }
149
+ if (p.disable !== undefined && !Array.isArray(p.disable)) {
150
+ throw new PackError('pack.disable must be an array of rule names');
151
+ }
152
+ // compileEntry performs the deep checks (and template validation).
153
+ const pack = input;
154
+ pack.vocabulary.forEach((e, idx) => compileEntry(e, pack.name, idx));
155
+ return pack;
156
+ }
@@ -0,0 +1,9 @@
1
+ import type { Rule } from './en.js';
2
+ import { type LatinLexicon } from './latin.js';
3
+ export declare const DE_LEXICON: LatinLexicon;
4
+ export declare const DE_RULE_ENTRIES: {
5
+ name: string;
6
+ rule: Rule;
7
+ }[];
8
+ /** Connector words allowed between merged date & time parts. */
9
+ export declare const DE_CONNECTORS: string[];