@lokascript/framework 4.6.0 → 4.7.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +87 -1
  2. package/dist/api/create-dsl.d.ts +13 -0
  3. package/dist/api/create-dsl.d.ts.map +1 -1
  4. package/dist/api/index.d.ts +1 -0
  5. package/dist/api/index.d.ts.map +1 -1
  6. package/dist/api/index.js +1756 -710
  7. package/dist/api/index.js.map +1 -1
  8. package/dist/api/translation-loss.d.ts +84 -0
  9. package/dist/api/translation-loss.d.ts.map +1 -0
  10. package/dist/core/index.d.ts +1 -0
  11. package/dist/core/index.d.ts.map +1 -1
  12. package/dist/core/index.js +1047 -684
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/core/matching/confidence-model.d.ts +54 -0
  15. package/dist/core/matching/confidence-model.d.ts.map +1 -0
  16. package/dist/core/matching/index.d.ts +12 -0
  17. package/dist/core/matching/index.d.ts.map +1 -0
  18. package/dist/core/matching/match-skeleton.d.ts +114 -0
  19. package/dist/core/matching/match-skeleton.d.ts.map +1 -0
  20. package/dist/core/matching/stock-confidence.d.ts +15 -0
  21. package/dist/core/matching/stock-confidence.d.ts.map +1 -0
  22. package/dist/core/matching/stock-reader.d.ts +56 -0
  23. package/dist/core/matching/stock-reader.d.ts.map +1 -0
  24. package/dist/core/matching/stock-reading.d.ts +44 -0
  25. package/dist/core/matching/stock-reading.d.ts.map +1 -0
  26. package/dist/core/matching/value-reader.d.ts +62 -0
  27. package/dist/core/matching/value-reader.d.ts.map +1 -0
  28. package/dist/core/pattern-matching/index.js +561 -660
  29. package/dist/core/pattern-matching/index.js.map +1 -1
  30. package/dist/core/pattern-matching/pattern-matcher.d.ts +0 -117
  31. package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
  32. package/dist/core/types.d.ts +48 -0
  33. package/dist/core/types.d.ts.map +1 -1
  34. package/dist/core/types.js.map +1 -1
  35. package/dist/generation/index.d.ts +2 -0
  36. package/dist/generation/index.d.ts.map +1 -1
  37. package/dist/generation/index.js +583 -1
  38. package/dist/generation/index.js.map +1 -1
  39. package/dist/generation/pattern-render.d.ts +72 -0
  40. package/dist/generation/pattern-render.d.ts.map +1 -0
  41. package/dist/generation/schema-patterns.d.ts +152 -0
  42. package/dist/generation/schema-patterns.d.ts.map +1 -0
  43. package/dist/index.cjs +1979 -907
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.js +1961 -906
  46. package/dist/index.js.map +1 -1
  47. package/dist/ir/index.js.map +1 -1
  48. package/dist/parsing/index.js +561 -660
  49. package/dist/parsing/index.js.map +1 -1
  50. package/dist/testing/gate/index.d.ts +8 -0
  51. package/dist/testing/gate/index.d.ts.map +1 -0
  52. package/dist/testing/gate/index.js +632 -0
  53. package/dist/testing/gate/index.js.map +1 -0
  54. package/dist/testing/gate/lint/format.d.ts +7 -0
  55. package/dist/testing/gate/lint/format.d.ts.map +1 -0
  56. package/dist/testing/gate/lint/index.d.ts +30 -0
  57. package/dist/testing/gate/lint/index.d.ts.map +1 -0
  58. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts +18 -0
  59. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts.map +1 -0
  60. package/dist/testing/gate/lint/rules/index.d.ts +14 -0
  61. package/dist/testing/gate/lint/rules/index.d.ts.map +1 -0
  62. package/dist/testing/gate/lint/rules/keyword-classification.d.ts +19 -0
  63. package/dist/testing/gate/lint/rules/keyword-classification.d.ts.map +1 -0
  64. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts +13 -0
  65. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts.map +1 -0
  66. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts +20 -0
  67. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts.map +1 -0
  68. package/dist/testing/gate/lint/rules/position-ordering.d.ts +25 -0
  69. package/dist/testing/gate/lint/rules/position-ordering.d.ts.map +1 -0
  70. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts +24 -0
  71. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts.map +1 -0
  72. package/dist/testing/gate/lint/rules/schema-rules.d.ts +10 -0
  73. package/dist/testing/gate/lint/rules/schema-rules.d.ts.map +1 -0
  74. package/dist/testing/gate/lint/types.d.ts +74 -0
  75. package/dist/testing/gate/lint/types.d.ts.map +1 -0
  76. package/dist/testing/gate/round-trip.d.ts +163 -0
  77. package/dist/testing/gate/round-trip.d.ts.map +1 -0
  78. package/package.json +6 -2
  79. package/src/__test__/fixtures/sql-dsl.ts +8 -2
  80. package/src/api/create-dsl-engine.test.ts +86 -0
  81. package/src/api/create-dsl.ts +141 -18
  82. package/src/api/index.ts +1 -0
  83. package/src/api/translation-loss.test.ts +89 -0
  84. package/src/api/translation-loss.ts +128 -0
  85. package/src/core/index.ts +1 -0
  86. package/src/core/matching/confidence-model.ts +58 -0
  87. package/src/core/matching/index.ts +12 -0
  88. package/src/core/matching/match-skeleton.test.ts +179 -0
  89. package/src/core/matching/match-skeleton.ts +621 -0
  90. package/src/core/matching/stock-confidence.ts +238 -0
  91. package/src/core/matching/stock-reader.test.ts +181 -0
  92. package/src/core/matching/stock-reader.ts +186 -0
  93. package/src/core/matching/stock-reading.ts +681 -0
  94. package/src/core/matching/value-reader.ts +85 -0
  95. package/src/core/pattern-matching/pattern-matcher.ts +11 -876
  96. package/src/core/types.ts +48 -0
  97. package/src/generation/index.ts +2 -0
  98. package/src/generation/pattern-render.ts +378 -0
  99. package/src/generation/schema-patterns.test.ts +99 -0
  100. package/src/generation/schema-patterns.ts +962 -0
  101. package/src/testing/gate/index.ts +8 -0
  102. package/src/testing/gate/lint/format.ts +23 -0
  103. package/src/testing/gate/lint/index.ts +73 -0
  104. package/src/testing/gate/lint/lint.test.ts +131 -0
  105. package/src/testing/gate/lint/rules/extractor-coverage.ts +63 -0
  106. package/src/testing/gate/lint/rules/index.ts +37 -0
  107. package/src/testing/gate/lint/rules/keyword-classification.ts +52 -0
  108. package/src/testing/gate/lint/rules/keyword-coverage.ts +38 -0
  109. package/src/testing/gate/lint/rules/marker-tokenization.ts +129 -0
  110. package/src/testing/gate/lint/rules/position-ordering.ts +87 -0
  111. package/src/testing/gate/lint/rules/renderer-coherence.ts +100 -0
  112. package/src/testing/gate/lint/rules/schema-rules.ts +35 -0
  113. package/src/testing/gate/lint/types.ts +83 -0
  114. package/src/testing/gate/round-trip.test.ts +118 -0
  115. package/src/testing/gate/round-trip.ts +438 -0
@@ -0,0 +1,621 @@
1
+ /**
2
+ * The pattern matcher's skeleton: the grammar-neutral half of semantic's matcher
3
+ * (domains rethink Phase 4.2-4.3, `docs-internal/DOMAINS_RETHINK.md`).
4
+ *
5
+ * It walks a pattern's literals, groups and role slots against the token
6
+ * stream: literal matching (exact, normalized, stem, case- and
7
+ * diacritic-insensitive, shattered compounds), optional groups with exact
8
+ * rollback and the marker discipline of a postpositional group, the
9
+ * speculative skip of an optional slot, role spans, memoized ranking by
10
+ * priority then confidence, the confidence model, and the extraction rules.
11
+ * Every value is read by the grammar's `ValueReader` (`value-reader.ts`);
12
+ * hyperscript's is `@lokascript/semantic`'s `HyperscriptValueReader`, and its
13
+ * confidence model semantic's `DefaultConfidenceModel`.
14
+ */
15
+
16
+ import type {
17
+ LanguagePattern,
18
+ PatternToken,
19
+ PatternMatchResult,
20
+ SemanticRole,
21
+ SemanticValue,
22
+ TokenStream,
23
+ LanguageToken,
24
+ } from '../types';
25
+ import { stripOptionalDiacritics } from '../tokenization/token-utils';
26
+ import type { ConfidenceModel } from './confidence-model';
27
+ import type { MatchView, ValueReader } from './value-reader';
28
+
29
+ /** Whether a group binds a role, at any depth. */
30
+ function groupHasRole(group: PatternToken & { type: 'group' }): boolean {
31
+ return group.tokens.some(t => t.type === 'role' || (t.type === 'group' && groupHasRole(t)));
32
+ }
33
+
34
+ export class MatchSkeleton {
35
+ /**
36
+ * The pattern tokens still to match after the one being matched, through
37
+ * every enclosing group: a value stops at any marker the pattern still owes.
38
+ */
39
+ private patternRest: PatternToken[] = [];
40
+
41
+ /**
42
+ * Whether the role slot being matched directly follows a literal the pattern
43
+ * matched: its marker (`[por {quantity}]`), or the command's verb.
44
+ */
45
+ private slotAfterLiteral = false;
46
+
47
+ /** Injectable confidence scoring model (Phase 3.3) */
48
+ private readonly confidenceModel: ConfidenceModel;
49
+
50
+ /**
51
+ * Selective memoization cache (Phase 6.1).
52
+ * Caches pattern match results keyed by `tokenPosition:patternId`.
53
+ * Cleared before each top-level matchBest() call.
54
+ */
55
+ private matchCache = new Map<string, PatternMatchResult | null>();
56
+
57
+ /**
58
+ * Stream index where each role's capture began, for the current
59
+ * `matchPattern` call. Rebuilt per call, written on the same success path as
60
+ * `captured` — see `PatternMatchResult.roleStarts`.
61
+ */
62
+ private roleStarts = new Map<SemanticRole, number>();
63
+
64
+ /** The grammar's value reading: every role slot, and the grammar's hooks. */
65
+ private readonly reader: ValueReader;
66
+
67
+ /**
68
+ * @param makeReader - builds the grammar's reader, handed this matcher's view
69
+ * of the match in progress (one reader per matcher).
70
+ */
71
+ constructor(makeReader: (view: MatchView) => ValueReader, confidenceModel: ConfidenceModel) {
72
+ this.confidenceModel = confidenceModel;
73
+ this.reader = makeReader({
74
+ patternRest: () => this.patternRest,
75
+ slotAfterLiteral: () => this.slotAfterLiteral,
76
+ wouldMatch: (pt, token) => this.patternTokenWouldMatch(pt, token),
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Try to match a single pattern against the token stream.
82
+ * Returns the match result or null if no match.
83
+ */
84
+ matchPattern(tokens: TokenStream, pattern: LanguagePattern): PatternMatchResult | null {
85
+ const mark = tokens.mark();
86
+ const captured = new Map<SemanticRole, SemanticValue>();
87
+
88
+ this.reader.beginPattern(pattern);
89
+
90
+ // Reset match counters for this pattern
91
+ this.stemMatchCount = 0;
92
+ this.totalKeywordMatches = 0;
93
+ this.roleStarts = new Map();
94
+
95
+ this.patternRest = [];
96
+ const success = this.matchTokenSequence(tokens, pattern.template.tokens, captured);
97
+
98
+ if (!success) {
99
+ tokens.reset(mark);
100
+ return null;
101
+ }
102
+
103
+ // Calculate confidence BEFORE applying defaults
104
+ // This ensures defaulted roles don't artificially inflate confidence
105
+ const confidence = this.confidenceModel.calculate({
106
+ pattern,
107
+ captured,
108
+ stemMatchCount: this.stemMatchCount,
109
+ totalKeywordMatches: this.totalKeywordMatches,
110
+ });
111
+
112
+ // Apply extraction rules to fill in any missing roles with defaults
113
+ this.applyExtractionRules(pattern, captured);
114
+
115
+ return {
116
+ pattern,
117
+ captured,
118
+ consumedTokens: tokens.position() - mark.position,
119
+ confidence,
120
+ roleStarts: this.roleStarts,
121
+ ...this.reader.resultFields?.(),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Try to match multiple patterns, return the best match.
127
+ */
128
+ matchBest(tokens: TokenStream, patterns: LanguagePattern[]): PatternMatchResult | null {
129
+ // Clear memoization cache for this matching round (Phase 6.1)
130
+ this.matchCache.clear();
131
+
132
+ const matches: PatternMatchResult[] = [];
133
+ const startPos = tokens.position();
134
+
135
+ for (const pattern of patterns) {
136
+ const mark = tokens.mark();
137
+
138
+ // Check memoization cache (Phase 6.1)
139
+ const cacheKey = `${startPos}:${pattern.id}`;
140
+ let result: PatternMatchResult | null;
141
+
142
+ if (this.matchCache.has(cacheKey)) {
143
+ result = this.matchCache.get(cacheKey)!;
144
+ } else {
145
+ result = this.matchPattern(tokens, pattern);
146
+ this.matchCache.set(cacheKey, result);
147
+ }
148
+
149
+ if (result) {
150
+ matches.push(result);
151
+ }
152
+
153
+ tokens.reset(mark);
154
+ }
155
+
156
+ if (matches.length === 0) {
157
+ return null;
158
+ }
159
+
160
+ // Sort by confidence and priority
161
+ matches.sort((a, b) => {
162
+ // First by priority
163
+ const priorityDiff = b.pattern.priority - a.pattern.priority;
164
+ if (priorityDiff !== 0) return priorityDiff;
165
+
166
+ // Then by confidence
167
+ return b.confidence - a.confidence;
168
+ });
169
+
170
+ // Re-consume tokens for the best match
171
+ const best = matches[0];
172
+ this.matchPattern(tokens, best.pattern);
173
+
174
+ return best;
175
+ }
176
+
177
+ /**
178
+ * Match a sequence of pattern tokens against the token stream.
179
+ */
180
+ private matchTokenSequence(
181
+ tokens: TokenStream,
182
+ patternTokens: PatternToken[],
183
+ captured: Map<SemanticRole, SemanticValue>,
184
+ // What follows the sequence itself — a GROUP's last inner token must see the
185
+ // token after the group as its `next` (the trailing-slot verb guard in
186
+ // matchRoleToken keys on nextPatternToken === undefined meaning "pattern
187
+ // truly ends here", and a mid-pattern single-token group is not the end).
188
+ nextAfterSequence?: PatternToken
189
+ ): boolean {
190
+ this.reader.skipLeading?.(tokens, patternTokens);
191
+
192
+ for (let i = 0; i < patternTokens.length; i++) {
193
+ const patternToken = patternTokens[i];
194
+
195
+ this.reader.beforePatternToken?.(tokens, patternTokens, i, captured);
196
+
197
+ // Marker-less optional slot about to eat a command VERB, mid-pattern.
198
+ //
199
+ // The reader's own guard (hyperscript's matchRoleToken) skips such a slot only when the pattern's
200
+ // very next token is a literal the verb itself satisfies. Where an
201
+ // OPTIONAL group sits between the slot and the verb literal
202
+ // (`[{method}] [using view {manner}] 交換`) that test looks at the
203
+ // group's first token, sees no match, and lets the slot swallow the verb
204
+ // — so `swap #a with #b` rendered SOV (`#a に #b を 交換`) did not parse
205
+ // at all in bn/hi/ja/ko/qu/tr.
206
+ //
207
+ // Widening that test to look THROUGH skippable groups was measured to
208
+ // work and to BREAK the ja goal-reclaim lock: the no-goal transition
209
+ // variant's mid-pattern slot must keep capturing 遷移 and failing, so the
210
+ // verb-anchoring fallback can reclaim goal+duration. Both shapes are
211
+ // structurally identical at the slot, so no per-slot test can separate
212
+ // them.
213
+ //
214
+ // What separates them is the OUTCOME. Skipping the slot lets swap's
215
+ // pattern consume its clause ENTIRELY (`#a に #b を 交換` → end), while
216
+ // the ja no-goal variant completes having eaten only `opacity を 遷移`
217
+ // and STRANDS `0 に 300ms` — the sloppy match the lock exists to prevent.
218
+ // So: speculatively skip, match the rest of THIS pattern, and adopt the
219
+ // skip only when it consumes the whole clause. A stranded tail falls
220
+ // through to the capture-and-fail below, unchanged.
221
+ //
222
+ // Bounded: fires only on an optional marker-less role slot facing a
223
+ // command-verb keyword the next pattern token does not want, and recurses
224
+ // on a strictly shorter token list — never a re-run of other patterns
225
+ // (the abandoned last-resort retry in matchBest, which compounded).
226
+ if (
227
+ this.reader.maySkipSlot(
228
+ patternToken,
229
+ tokens.peek(),
230
+ patternTokens[i + 1] ?? nextAfterSequence
231
+ )
232
+ ) {
233
+ const skipMark = tokens.mark();
234
+ const speculative = new Map(captured);
235
+ if (
236
+ this.matchTokenSequence(
237
+ tokens,
238
+ patternTokens.slice(i + 1),
239
+ speculative,
240
+ nextAfterSequence
241
+ ) &&
242
+ this.reader.atClauseEnd(tokens)
243
+ ) {
244
+ captured.clear();
245
+ for (const [role, value] of speculative) captured.set(role, value);
246
+ return true;
247
+ }
248
+ tokens.reset(skipMark);
249
+ }
250
+
251
+ const outerRest = this.patternRest;
252
+ this.patternRest = [...patternTokens.slice(i + 1), ...outerRest];
253
+ this.slotAfterLiteral =
254
+ patternToken.type === 'role' && patternTokens[i - 1]?.type === 'literal';
255
+ const matched = this.matchPatternToken(
256
+ tokens,
257
+ patternToken,
258
+ captured,
259
+ patternTokens[i + 1] ?? nextAfterSequence
260
+ );
261
+ this.patternRest = outerRest;
262
+
263
+ if (!matched) {
264
+ // If token is optional, continue
265
+ if (this.isOptional(patternToken)) {
266
+ continue;
267
+ }
268
+ return false;
269
+ }
270
+ }
271
+
272
+ return true;
273
+ }
274
+
275
+ /**
276
+ * Match a single pattern token against the current position in the stream.
277
+ */
278
+ private matchPatternToken(
279
+ tokens: TokenStream,
280
+ patternToken: PatternToken,
281
+ captured: Map<SemanticRole, SemanticValue>,
282
+ nextPatternToken?: PatternToken
283
+ ): boolean {
284
+ switch (patternToken.type) {
285
+ case 'literal':
286
+ return this.matchLiteralToken(tokens, patternToken);
287
+
288
+ case 'role': {
289
+ // Record where this slot's own span begins, but only once it actually
290
+ // captured — an optional role that matched nothing must not claim a
291
+ // position. See `PatternMatchResult.roleStarts`.
292
+ const roleStart = tokens.position();
293
+ const matchedRole = this.reader.readRole(tokens, patternToken, captured, nextPatternToken);
294
+ if (matchedRole && captured.has(patternToken.role)) {
295
+ this.roleStarts.set(patternToken.role, roleStart);
296
+ }
297
+ return matchedRole;
298
+ }
299
+
300
+ case 'group':
301
+ return this.matchGroupToken(tokens, patternToken, captured, nextPatternToken);
302
+
303
+ default:
304
+ return false;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Match a literal pattern token (keyword or particle).
310
+ */
311
+ private matchLiteralToken(
312
+ tokens: TokenStream,
313
+ patternToken: PatternToken & { type: 'literal' }
314
+ ): boolean {
315
+ const token = tokens.peek();
316
+ if (!token) return false;
317
+
318
+ // Check main value
319
+ const matchType = this.getMatchType(token, patternToken.value);
320
+ if (matchType !== 'none') {
321
+ this.totalKeywordMatches++;
322
+ if (matchType === 'stem') {
323
+ this.stemMatchCount++;
324
+ }
325
+ tokens.advance();
326
+ return true;
327
+ }
328
+
329
+ // Check alternatives
330
+ if (patternToken.alternatives) {
331
+ for (const alt of patternToken.alternatives) {
332
+ const altMatchType = this.getMatchType(token, alt);
333
+ if (altMatchType !== 'none') {
334
+ this.totalKeywordMatches++;
335
+ if (altMatchType === 'stem') {
336
+ this.stemMatchCount++;
337
+ }
338
+ tokens.advance();
339
+ return true;
340
+ }
341
+ }
342
+ }
343
+
344
+ // Hyphen/underscore-compound fold: many keyword translations are compounds
345
+ // (ms `titik-henti`, es `punto-interrupción`, ru `по_умолчанию`,
346
+ // qu `ñawpaq_kaq`) that the tokenizers shatter into word/sep/word runs —
347
+ // and a segment can be ANOTHER command's keyword (ms `henti` → halt,
348
+ // qu `ñawpaq` → first), so the run mis-anchors that command instead.
349
+ // Join the run and compare it to any separator-bearing expected value;
350
+ // only fires after every single-token match above has failed.
351
+ for (const value of [patternToken.value, ...(patternToken.alternatives ?? [])]) {
352
+ for (const sep of ['-', '_'] as const) {
353
+ if (value.includes(sep) && this.tryMatchShatteredCompound(tokens, value, sep)) {
354
+ this.totalKeywordMatches++;
355
+ return true;
356
+ }
357
+ }
358
+ }
359
+
360
+ return false;
361
+ }
362
+
363
+ /**
364
+ * Match a compound keyword (`titik-henti`, `по_умолчанию`) against a
365
+ * shattered token run (`titik` `-` `henti` / `по` `_` `умолчанию`): word
366
+ * segments must be identifier/keyword/particle tokens equal to the expected
367
+ * segments (case-insensitive; particle because prepositions like ru `по` /
368
+ * uk `за` lead some underscore compounds), separators lone `sep` tokens.
369
+ * Consumes the run on success, resets the stream on failure.
370
+ */
371
+ private tryMatchShatteredCompound(
372
+ tokens: TokenStream,
373
+ expected: string,
374
+ sep: '-' | '_'
375
+ ): boolean {
376
+ const segments = expected.split(sep);
377
+ if (segments.length < 2 || segments.some(s => s.length === 0)) return false;
378
+ const mark = tokens.mark();
379
+ for (let i = 0; i < segments.length; i++) {
380
+ if (i > 0) {
381
+ const sepToken = tokens.peek();
382
+ if (!sepToken || sepToken.value !== sep) {
383
+ tokens.reset(mark);
384
+ return false;
385
+ }
386
+ tokens.advance();
387
+ }
388
+ const word = tokens.peek();
389
+ if (
390
+ !word ||
391
+ (word.kind !== 'identifier' && word.kind !== 'keyword' && word.kind !== 'particle') ||
392
+ word.value.toLowerCase() !== segments[i]!.toLowerCase()
393
+ ) {
394
+ tokens.reset(mark);
395
+ return false;
396
+ }
397
+ tokens.advance();
398
+ }
399
+ return true;
400
+ }
401
+
402
+ /**
403
+ * Match a group pattern token (optional sequence).
404
+ */
405
+ private matchGroupToken(
406
+ tokens: TokenStream,
407
+ patternToken: PatternToken & { type: 'group' },
408
+ captured: Map<SemanticRole, SemanticValue>,
409
+ nextPatternToken?: PatternToken
410
+ ): boolean {
411
+ const mark = tokens.mark();
412
+
413
+ // Snapshot the captured roles' VALUES, not just their keys. Clearing only
414
+ // the newly-added keys leaves an OVERWRITE by a failed group in place.
415
+ //
416
+ // 90 shipped patterns bind one role in TWO optional groups — every
417
+ // `<cmd>-event-<lang>-sov` has a pre-verb `[{destination} <marker>]` and a
418
+ // post-verb one, because the transformer emits the to-phrase on either side
419
+ // depending on the command. When the trailing group speculatively captures
420
+ // the next token into that role and then fails on its marker, the token
421
+ // stream rolls back but the corrupted value survives: ja
422
+ // `クリック で #panel に .active を トグル ゴミ` returned
423
+ // `destination="ゴミ"` with the real `#panel` captured and discarded.
424
+ const capturedBefore = new Map(captured);
425
+
426
+ let success = this.matchTokenSequence(tokens, patternToken.tokens, captured, nextPatternToken);
427
+
428
+ // A group that read its marker and bound no role (`take .foo from .div for
429
+ // #d3`: `for` matched, and the recipient slot declined `#d3`) took a word
430
+ // that now stands for nothing, and the clause after it lost its head.
431
+ if (
432
+ success &&
433
+ tokens.position() > mark.position &&
434
+ groupHasRole(patternToken) &&
435
+ [...captured].every(([role, value]) => capturedBefore.get(role) === value) &&
436
+ captured.size === capturedBefore.size
437
+ ) {
438
+ success = false;
439
+ }
440
+
441
+ // A postpositional marker group ([{destination} [e]]) can "succeed" by
442
+ // capturing ONLY the role: the role is optional-in-group, the trailing
443
+ // marker sub-group is optional too, so `.loading i 2s değiştir` binds
444
+ // `.loading` as the DESTINATION and the pattern then dies on `patient` —
445
+ // with no backtracking, the whole (correct) parse is lost. A role captured
446
+ // without its own marker is indistinguishable from the next slot's value,
447
+ // so require the marker: if this group added a role capture but none of
448
+ // the tokens it consumed matched a `renderRequired` marker sub-group's
449
+ // literal, fail the group and roll back — the value stays available for
450
+ // the slot that really owns it. Scoped to groups that carry a
451
+ // renderRequired literal-only sub-group (the generated postpositional
452
+ // shape); prepositional groups ([จาก {source}]) fail fast on their leading
453
+ // literal and never reach this.
454
+ //
455
+ // The marker is the group's LAST token: the sub-group follows the role. A
456
+ // marker word inside the role's own run is the value's, not the role's
457
+ // marker: tr `in` is also the operator (`2 dir in arr`), `a` the article
458
+ // (`id dir a Number`), `nin` the possessive (`length nin textContent nin
459
+ // #a < 4`). Counted anywhere, they let the unmarked destination take an
460
+ // `if` condition's tail, so `if 2 is in arr add .a end` read `if 2 add .a
461
+ // to is in arr end` (P54).
462
+ if (success) {
463
+ const markerLiterals: string[] = [];
464
+ for (const inner of patternToken.tokens) {
465
+ if (inner.type === 'group' && (inner as { renderRequired?: boolean }).renderRequired) {
466
+ for (const t of inner.tokens) {
467
+ if (t.type === 'literal') markerLiterals.push(t.value, ...(t.alternatives ?? []));
468
+ }
469
+ }
470
+ }
471
+ if (markerLiterals.length > 0) {
472
+ const addedRole = [...captured.keys()].some(role => !capturedBefore.has(role));
473
+ if (addedRole) {
474
+ const last = tokens.tokens[tokens.position() - 1] as LanguageToken | undefined;
475
+ const markerSeen =
476
+ last !== undefined &&
477
+ tokens.position() > mark.position &&
478
+ markerLiterals.some(lit => this.getMatchType(last, lit) !== 'none');
479
+ if (!markerSeen) success = false;
480
+ }
481
+ }
482
+ }
483
+
484
+ if (!success) {
485
+ tokens.reset(mark);
486
+ // Restore the pre-group capture state exactly: drop roles the failed group
487
+ // added, and put back the values it overwrote.
488
+ for (const role of captured.keys()) {
489
+ if (!capturedBefore.has(role)) {
490
+ captured.delete(role);
491
+ }
492
+ }
493
+ for (const [role, value] of capturedBefore) {
494
+ if (captured.get(role) !== value) {
495
+ captured.set(role, value);
496
+ }
497
+ }
498
+ return patternToken.optional || false;
499
+ }
500
+
501
+ return true;
502
+ }
503
+
504
+ /**
505
+ * Whether a pattern token (literal, or a group starting with a literal) would
506
+ * match the given stream token. Used to keep the event-head source-clause
507
+ * consumption from stealing a marker the pattern explicitly expects.
508
+ */
509
+ private patternTokenWouldMatch(pt: PatternToken | undefined, token: LanguageToken): boolean {
510
+ if (!pt) return false;
511
+ if (pt.type === 'literal') {
512
+ if (this.getMatchType(token, pt.value) !== 'none') return true;
513
+ return !!pt.alternatives?.some(a => this.getMatchType(token, a) !== 'none');
514
+ }
515
+ if (pt.type === 'group') {
516
+ return this.patternTokenWouldMatch(pt.tokens?.[0], token);
517
+ }
518
+ return false;
519
+ }
520
+
521
+ /**
522
+ * Get the type of match for a token against a value.
523
+ * Used for confidence calculation.
524
+ */
525
+ private getMatchType(
526
+ token: LanguageToken,
527
+ value: string
528
+ ): 'exact' | 'normalized' | 'stem' | 'case-insensitive' | 'none' {
529
+ // Exact match (highest confidence)
530
+ if (token.value === value) return 'exact';
531
+
532
+ // Explicit keyword map normalized match (high confidence)
533
+ if (token.normalized === value) return 'normalized';
534
+
535
+ // Morphologically normalized stem match (medium-high confidence)
536
+ // Only accept if stem confidence is reasonable
537
+ if (token.stem === value && token.stemConfidence !== undefined && token.stemConfidence >= 0.7) {
538
+ return 'stem';
539
+ }
540
+
541
+ // Case-insensitive match for keywords (medium confidence)
542
+ if (token.kind === 'keyword' && token.value.toLowerCase() === value.toLowerCase()) {
543
+ return 'case-insensitive';
544
+ }
545
+
546
+ // Diacritic-insensitive match for keywords (medium confidence). Arabic
547
+ // harakat are OPTIONAL in the script — `بدّل` and `بَدِّل` are the same word,
548
+ // and a pattern literal carries whichever spelling the profile declared. The
549
+ // keyword map is queried diacritic-insensitively, so the token is correctly
550
+ // recognized; without this the surface still fails to match its own literal
551
+ // here, one layer later. Restricted to keywords, and `stripOptionalDiacritics`
552
+ // touches only Arabic-exclusive codepoints, so it is inert for every other
553
+ // script (Latin accents are meaning-bearing and must never be stripped).
554
+ if (token.kind === 'keyword') {
555
+ const strippedToken = stripOptionalDiacritics(token.value).toLowerCase();
556
+ if (strippedToken && strippedToken === stripOptionalDiacritics(value).toLowerCase()) {
557
+ return 'case-insensitive';
558
+ }
559
+ }
560
+
561
+ return 'none';
562
+ }
563
+
564
+ /**
565
+ * Track stem matches for confidence calculation.
566
+ * This is set during matching and read during confidence calculation.
567
+ */
568
+ private stemMatchCount: number = 0;
569
+
570
+ private totalKeywordMatches: number = 0;
571
+
572
+ /**
573
+ * Apply extraction rules to fill in static values and defaults for missing
574
+ * roles, and re-shape captured roles that declare a `transform` (the
575
+ * ExtractionRule field was documented but unwired — go-qu-url-dest is the
576
+ * first consumer: it re-types the marker-bound capture to the expression
577
+ * shape the en reference produces).
578
+ */
579
+ private applyExtractionRules(
580
+ pattern: LanguagePattern,
581
+ captured: Map<SemanticRole, SemanticValue>
582
+ ): void {
583
+ for (const [role, rule] of Object.entries(pattern.extraction)) {
584
+ const existing = captured.get(role as SemanticRole);
585
+ if (existing) {
586
+ if (rule.transform) {
587
+ const raw =
588
+ existing.type === 'literal'
589
+ ? String(existing.value)
590
+ : String((existing as { raw?: unknown }).raw ?? '');
591
+ captured.set(role as SemanticRole, rule.transform(raw));
592
+ }
593
+ } else if (rule.value !== undefined) {
594
+ // Static value extraction (e.g., action: { value: "toggle" })
595
+ captured.set(
596
+ role as SemanticRole,
597
+ rule.valueIsExpression
598
+ ? { type: 'expression', raw: rule.value }
599
+ : { type: 'literal', value: rule.value }
600
+ );
601
+ } else if (rule.default) {
602
+ // Copied, never aliased (the rule's default object is shared across
603
+ // parses), and tagged implicit: materialized from the default, not
604
+ // captured from source text — renderers suppress implicit values only.
605
+ // (`implicit` is semantic's value field; intent's value type has none.)
606
+ const implicitDefault: SemanticValue & { readonly implicit: true } = {
607
+ ...rule.default,
608
+ implicit: true,
609
+ };
610
+ captured.set(role as SemanticRole, implicitDefault);
611
+ }
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Check if a pattern token is optional.
617
+ */
618
+ private isOptional(patternToken: PatternToken): boolean {
619
+ return patternToken.type !== 'literal' && patternToken.optional === true;
620
+ }
621
+ }