@lokascript/framework 4.5.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.
- package/CHANGELOG.md +220 -8
- package/dist/api/create-dsl.d.ts +59 -1
- package/dist/api/create-dsl.d.ts.map +1 -1
- package/dist/api/domain-registry.d.ts.map +1 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +1938 -724
- package/dist/api/index.js.map +1 -1
- package/dist/api/reading.d.ts +8 -0
- package/dist/api/reading.d.ts.map +1 -0
- package/dist/api/translation-loss.d.ts +84 -0
- package/dist/api/translation-loss.d.ts.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1047 -684
- package/dist/core/index.js.map +1 -1
- package/dist/core/matching/confidence-model.d.ts +54 -0
- package/dist/core/matching/confidence-model.d.ts.map +1 -0
- package/dist/core/matching/index.d.ts +12 -0
- package/dist/core/matching/index.d.ts.map +1 -0
- package/dist/core/matching/match-skeleton.d.ts +114 -0
- package/dist/core/matching/match-skeleton.d.ts.map +1 -0
- package/dist/core/matching/stock-confidence.d.ts +15 -0
- package/dist/core/matching/stock-confidence.d.ts.map +1 -0
- package/dist/core/matching/stock-reader.d.ts +56 -0
- package/dist/core/matching/stock-reader.d.ts.map +1 -0
- package/dist/core/matching/stock-reading.d.ts +44 -0
- package/dist/core/matching/stock-reading.d.ts.map +1 -0
- package/dist/core/matching/value-reader.d.ts +62 -0
- package/dist/core/matching/value-reader.d.ts.map +1 -0
- package/dist/core/pattern-matching/index.js +561 -660
- package/dist/core/pattern-matching/index.js.map +1 -1
- package/dist/core/pattern-matching/pattern-matcher.d.ts +0 -117
- package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
- package/dist/core/types.d.ts +48 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/generation/index.d.ts +2 -0
- package/dist/generation/index.d.ts.map +1 -1
- package/dist/generation/index.js +583 -1
- package/dist/generation/index.js.map +1 -1
- package/dist/generation/pattern-render.d.ts +72 -0
- package/dist/generation/pattern-render.d.ts.map +1 -0
- package/dist/generation/schema-patterns.d.ts +152 -0
- package/dist/generation/schema-patterns.d.ts.map +1 -0
- package/dist/index.cjs +2132 -890
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +2110 -887
- package/dist/index.js.map +1 -1
- package/dist/ir/index.js.map +1 -1
- package/dist/parsing/index.js +561 -660
- package/dist/parsing/index.js.map +1 -1
- package/dist/testing/gate/index.d.ts +8 -0
- package/dist/testing/gate/index.d.ts.map +1 -0
- package/dist/testing/gate/index.js +632 -0
- package/dist/testing/gate/index.js.map +1 -0
- package/dist/testing/gate/lint/format.d.ts +7 -0
- package/dist/testing/gate/lint/format.d.ts.map +1 -0
- package/dist/testing/gate/lint/index.d.ts +30 -0
- package/dist/testing/gate/lint/index.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/extractor-coverage.d.ts +18 -0
- package/dist/testing/gate/lint/rules/extractor-coverage.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/index.d.ts +14 -0
- package/dist/testing/gate/lint/rules/index.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/keyword-classification.d.ts +19 -0
- package/dist/testing/gate/lint/rules/keyword-classification.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/keyword-coverage.d.ts +13 -0
- package/dist/testing/gate/lint/rules/keyword-coverage.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/marker-tokenization.d.ts +20 -0
- package/dist/testing/gate/lint/rules/marker-tokenization.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/position-ordering.d.ts +25 -0
- package/dist/testing/gate/lint/rules/position-ordering.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/renderer-coherence.d.ts +24 -0
- package/dist/testing/gate/lint/rules/renderer-coherence.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/schema-rules.d.ts +10 -0
- package/dist/testing/gate/lint/rules/schema-rules.d.ts.map +1 -0
- package/dist/testing/gate/lint/types.d.ts +74 -0
- package/dist/testing/gate/lint/types.d.ts.map +1 -0
- package/dist/testing/gate/round-trip.d.ts +163 -0
- package/dist/testing/gate/round-trip.d.ts.map +1 -0
- package/package.json +7 -3
- package/src/__test__/fixtures/sql-dsl.ts +11 -2
- package/src/__test__/sql-integration.test.ts +2 -2
- package/src/api/create-dsl-engine.test.ts +86 -0
- package/src/api/create-dsl.test.ts +120 -0
- package/src/api/create-dsl.ts +314 -39
- package/src/api/domain-registry.test.ts +70 -5
- package/src/api/domain-registry.ts +82 -1
- package/src/api/index.ts +1 -0
- package/src/api/reading.ts +29 -0
- package/src/api/translation-loss.test.ts +89 -0
- package/src/api/translation-loss.ts +128 -0
- package/src/core/index.ts +1 -0
- package/src/core/matching/confidence-model.ts +58 -0
- package/src/core/matching/index.ts +12 -0
- package/src/core/matching/match-skeleton.test.ts +179 -0
- package/src/core/matching/match-skeleton.ts +621 -0
- package/src/core/matching/stock-confidence.ts +238 -0
- package/src/core/matching/stock-reader.test.ts +181 -0
- package/src/core/matching/stock-reader.ts +186 -0
- package/src/core/matching/stock-reading.ts +681 -0
- package/src/core/matching/value-reader.ts +85 -0
- package/src/core/pattern-matching/pattern-matcher.ts +11 -876
- package/src/core/types.ts +48 -0
- package/src/generation/index.ts +2 -0
- package/src/generation/pattern-render.ts +378 -0
- package/src/generation/schema-patterns.test.ts +99 -0
- package/src/generation/schema-patterns.ts +962 -0
- package/src/testing/gate/index.ts +8 -0
- package/src/testing/gate/lint/format.ts +23 -0
- package/src/testing/gate/lint/index.ts +73 -0
- package/src/testing/gate/lint/lint.test.ts +131 -0
- package/src/testing/gate/lint/rules/extractor-coverage.ts +63 -0
- package/src/testing/gate/lint/rules/index.ts +37 -0
- package/src/testing/gate/lint/rules/keyword-classification.ts +52 -0
- package/src/testing/gate/lint/rules/keyword-coverage.ts +38 -0
- package/src/testing/gate/lint/rules/marker-tokenization.ts +129 -0
- package/src/testing/gate/lint/rules/position-ordering.ts +87 -0
- package/src/testing/gate/lint/rules/renderer-coherence.ts +100 -0
- package/src/testing/gate/lint/rules/schema-rules.ts +35 -0
- package/src/testing/gate/lint/types.ts +83 -0
- package/src/testing/gate/round-trip.test.ts +118 -0
- 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
|
+
}
|