@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,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stock confidence model: how framework's old matcher scored a domain
|
|
3
|
+
* pattern's match (`core/pattern-matching/`), moved here as a `ConfidenceModel`
|
|
4
|
+
* for the matcher's skeleton (domains rethink Phase 4.7); the old matcher calls
|
|
5
|
+
* it too until Phase 6.
|
|
6
|
+
*
|
|
7
|
+
* Role coverage (a required slot 1, an optional one 0.8, an absent optional one
|
|
8
|
+
* with a default 60% of that), a stem-match penalty (up to 15%, floor 0.5), and
|
|
9
|
+
* two Arabic adjustments: a VSO verb-first boost and the preposition each role
|
|
10
|
+
* takes most naturally.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { LanguagePattern, SemanticRole, SemanticValue } from '../types';
|
|
14
|
+
import type { ConfidenceBreakdown, ConfidenceContext, ConfidenceModel } from './confidence-model';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Calculate confidence score for a match (0-1).
|
|
18
|
+
*
|
|
19
|
+
* Confidence is reduced for:
|
|
20
|
+
* - Stem matches (morphological normalization has inherent uncertainty)
|
|
21
|
+
* - Missing optional roles (but less penalty if role has a default value)
|
|
22
|
+
*
|
|
23
|
+
* Confidence is increased for:
|
|
24
|
+
* - VSO languages (Arabic) when pattern starts with a verb
|
|
25
|
+
*/
|
|
26
|
+
function scoreParts(ctx: ConfidenceContext): ConfidenceBreakdown {
|
|
27
|
+
const { pattern, captured } = ctx;
|
|
28
|
+
let score = 0;
|
|
29
|
+
let maxScore = 0;
|
|
30
|
+
|
|
31
|
+
// Helper to check if a role has a default value in extraction rules
|
|
32
|
+
const hasDefault = (role: SemanticRole): boolean => {
|
|
33
|
+
return pattern.extraction?.[role]?.default !== undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Score based on captured roles
|
|
37
|
+
for (const token of pattern.template.tokens) {
|
|
38
|
+
if (token.type === 'role') {
|
|
39
|
+
maxScore += 1;
|
|
40
|
+
if (captured.has(token.role)) {
|
|
41
|
+
score += 1;
|
|
42
|
+
}
|
|
43
|
+
} else if (token.type === 'group') {
|
|
44
|
+
// Group tokens are optional - weight depends on whether they have defaults
|
|
45
|
+
for (const subToken of token.tokens) {
|
|
46
|
+
if (subToken.type === 'role') {
|
|
47
|
+
const roleHasDefault = hasDefault(subToken.role);
|
|
48
|
+
const weight = 0.8; // Optional roles: 80% weight
|
|
49
|
+
maxScore += weight;
|
|
50
|
+
|
|
51
|
+
if (captured.has(subToken.role)) {
|
|
52
|
+
// Role was explicitly provided by user
|
|
53
|
+
score += weight;
|
|
54
|
+
} else if (roleHasDefault) {
|
|
55
|
+
// Role has a default - give 60% partial credit since command is semantically complete
|
|
56
|
+
// This prevents penalizing common patterns like "toggle .active" (default: me)
|
|
57
|
+
score += weight * 0.6;
|
|
58
|
+
}
|
|
59
|
+
// If no default and not captured, score += 0 (true penalty for missing info)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const roleCoverage = maxScore > 0 ? score / maxScore : 1;
|
|
66
|
+
let baseConfidence = roleCoverage;
|
|
67
|
+
let stemPenalty = 0;
|
|
68
|
+
|
|
69
|
+
// Apply penalty for stem matches
|
|
70
|
+
// Each stem match reduces confidence slightly (e.g., 5% per stem match)
|
|
71
|
+
// This ensures exact matches are preferred over morphological matches
|
|
72
|
+
if (ctx.stemMatchCount > 0 && ctx.totalKeywordMatches > 0) {
|
|
73
|
+
stemPenalty = (ctx.stemMatchCount / ctx.totalKeywordMatches) * 0.15;
|
|
74
|
+
baseConfidence = Math.max(0.5, baseConfidence - stemPenalty);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Apply VSO confidence boost for Arabic verb-first patterns
|
|
78
|
+
const vsoBoost = calculateVSOConfidenceBoost(pattern);
|
|
79
|
+
baseConfidence = Math.min(1.0, baseConfidence + vsoBoost);
|
|
80
|
+
|
|
81
|
+
// Apply preposition disambiguation adjustment for Arabic
|
|
82
|
+
const prepositionAdjustment = arabicPrepositionDisambiguation(pattern, captured);
|
|
83
|
+
baseConfidence = Math.max(0.0, Math.min(1.0, baseConfidence + prepositionAdjustment));
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
roleCoverage,
|
|
87
|
+
stemPenalty,
|
|
88
|
+
languageBoost: vsoBoost,
|
|
89
|
+
languageAdjustment: prepositionAdjustment,
|
|
90
|
+
final: baseConfidence,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Calculate confidence boost for VSO (Verb-Subject-Object) language patterns.
|
|
96
|
+
* Arabic naturally uses VSO word order, so patterns that start with a verb
|
|
97
|
+
* should receive a confidence boost.
|
|
98
|
+
*
|
|
99
|
+
* Returns +0.15 confidence boost if:
|
|
100
|
+
* - Language is Arabic ('ar')
|
|
101
|
+
* - Pattern's first token is a verb keyword
|
|
102
|
+
*
|
|
103
|
+
* @param pattern The language pattern being matched
|
|
104
|
+
* @returns Confidence boost (0 or 0.15)
|
|
105
|
+
*/
|
|
106
|
+
function calculateVSOConfidenceBoost(pattern: LanguagePattern): number {
|
|
107
|
+
// Only apply to Arabic
|
|
108
|
+
if (pattern.language !== 'ar') {
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if first token in pattern is a literal (keyword)
|
|
113
|
+
const firstToken = pattern.template.tokens[0];
|
|
114
|
+
if (!firstToken || firstToken.type !== 'literal') {
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// List of Arabic verb keywords (command verbs)
|
|
119
|
+
const ARABIC_VERBS = new Set([
|
|
120
|
+
'بدل',
|
|
121
|
+
'غير',
|
|
122
|
+
'أضف',
|
|
123
|
+
'أزل',
|
|
124
|
+
'ضع',
|
|
125
|
+
'اجعل',
|
|
126
|
+
'عين',
|
|
127
|
+
'زد',
|
|
128
|
+
'انقص',
|
|
129
|
+
'سجل',
|
|
130
|
+
'أظهر',
|
|
131
|
+
'أخف',
|
|
132
|
+
'شغل',
|
|
133
|
+
'أرسل',
|
|
134
|
+
'ركز',
|
|
135
|
+
'شوش',
|
|
136
|
+
'توقف',
|
|
137
|
+
'انسخ',
|
|
138
|
+
'احذف',
|
|
139
|
+
'اصنع',
|
|
140
|
+
'انتظر',
|
|
141
|
+
'انتقال',
|
|
142
|
+
'أو',
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
// Check if first token value is a verb
|
|
146
|
+
if (ARABIC_VERBS.has(firstToken.value)) {
|
|
147
|
+
return 0.15;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check alternatives
|
|
151
|
+
if (firstToken.alternatives) {
|
|
152
|
+
for (const alt of firstToken.alternatives) {
|
|
153
|
+
if (ARABIC_VERBS.has(alt)) {
|
|
154
|
+
return 0.15;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Arabic preposition disambiguation for confidence adjustment.
|
|
164
|
+
*
|
|
165
|
+
* Different Arabic prepositions are more or less natural for different semantic roles:
|
|
166
|
+
* - على (on/upon) is preferred for patient/target roles (element selectors)
|
|
167
|
+
* - إلى (to) is preferred for destination roles
|
|
168
|
+
* - من (from) is preferred for source roles
|
|
169
|
+
* - في (in) is preferred for location roles
|
|
170
|
+
*
|
|
171
|
+
* This method analyzes the prepositions used with captured semantic roles and
|
|
172
|
+
* adjusts confidence based on idiomaticity:
|
|
173
|
+
* - +0.10 for highly idiomatic preposition choices
|
|
174
|
+
* - -0.10 for less natural preposition choices
|
|
175
|
+
*
|
|
176
|
+
* @param pattern The language pattern being matched
|
|
177
|
+
* @param captured The captured semantic values
|
|
178
|
+
* @returns Confidence adjustment (-0.10 to +0.10)
|
|
179
|
+
*/
|
|
180
|
+
function arabicPrepositionDisambiguation(
|
|
181
|
+
pattern: LanguagePattern,
|
|
182
|
+
captured: ReadonlyMap<SemanticRole, SemanticValue>
|
|
183
|
+
): number {
|
|
184
|
+
// Only apply to Arabic
|
|
185
|
+
if (pattern.language !== 'ar') {
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let adjustment = 0;
|
|
190
|
+
|
|
191
|
+
// Preferred prepositions for each semantic role
|
|
192
|
+
// Only including roles that commonly use prepositions in Arabic
|
|
193
|
+
const PREFERRED_PREPOSITIONS: Partial<Record<SemanticRole, string[]>> = {
|
|
194
|
+
patient: ['على'], // element selectors prefer على (on/upon)
|
|
195
|
+
destination: ['إلى', 'الى'], // destination prefers إلى (to)
|
|
196
|
+
source: ['من'], // source prefers من (from)
|
|
197
|
+
agent: ['من'], // agent/by prefers من (from/by)
|
|
198
|
+
manner: ['ب'], // manner prefers ب (with/by)
|
|
199
|
+
style: ['ب'], // style prefers ب (with)
|
|
200
|
+
goal: ['إلى', 'الى'], // target state prefers إلى (to)
|
|
201
|
+
method: ['ب'], // method prefers ب (with/by)
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Check each captured role for preposition metadata
|
|
205
|
+
for (const [role, value] of captured.entries()) {
|
|
206
|
+
// Skip if no preferred prepositions defined for this role
|
|
207
|
+
const preferred = PREFERRED_PREPOSITIONS[role];
|
|
208
|
+
if (!preferred || preferred.length === 0) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check if the value has preposition metadata (from Arabic tokenizer)
|
|
213
|
+
// This metadata is attached when a preposition particle token is consumed
|
|
214
|
+
const metadata =
|
|
215
|
+
'metadata' in value ? (value as { metadata: Record<string, unknown> }).metadata : undefined;
|
|
216
|
+
if (metadata && typeof metadata.prepositionValue === 'string') {
|
|
217
|
+
const usedPreposition = metadata.prepositionValue;
|
|
218
|
+
|
|
219
|
+
// Check if the used preposition is in the preferred list
|
|
220
|
+
if (preferred.includes(usedPreposition)) {
|
|
221
|
+
// Idiomatic choice - boost confidence
|
|
222
|
+
adjustment += 0.1;
|
|
223
|
+
} else {
|
|
224
|
+
// Less natural choice - reduce confidence
|
|
225
|
+
adjustment -= 0.1;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Cap total adjustment at ±0.10 (even if multiple roles analyzed)
|
|
231
|
+
return Math.max(-0.1, Math.min(0.1, adjustment));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The stock confidence model (stateless: one instance serves every matcher). */
|
|
235
|
+
export const stockConfidenceModel: ConfidenceModel = {
|
|
236
|
+
calculate: ctx => scoreParts(ctx).final,
|
|
237
|
+
calculateWithBreakdown: ctx => scoreParts(ctx),
|
|
238
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stock value reader on the matcher's skeleton (domains rethink Phase 4.7):
|
|
3
|
+
* each kind of value it reads, a greedy slot's stop markers, the guard against a
|
|
4
|
+
* marker read as a value, and the skip of an optional slot.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { MatchSkeleton } from './match-skeleton';
|
|
8
|
+
import { createStockReader, greedyStopMarkers, readGreedy } from './stock-reader';
|
|
9
|
+
import { stockConfidenceModel } from './stock-confidence';
|
|
10
|
+
import { TokenStreamImpl } from '../tokenization/token-utils';
|
|
11
|
+
import type { LanguagePattern, LanguageToken, PatternToken, TokenKind } from '../types';
|
|
12
|
+
|
|
13
|
+
function stream(...tokens: Array<[string, TokenKind] | [string, TokenKind, string]>) {
|
|
14
|
+
let offset = 0;
|
|
15
|
+
const out: LanguageToken[] = tokens.map(([value, kind, normalized]) => {
|
|
16
|
+
const token: LanguageToken = {
|
|
17
|
+
value,
|
|
18
|
+
kind,
|
|
19
|
+
position: { start: offset, end: offset + value.length },
|
|
20
|
+
...(normalized ? { normalized } : {}),
|
|
21
|
+
};
|
|
22
|
+
offset += value.length + 1;
|
|
23
|
+
return token;
|
|
24
|
+
});
|
|
25
|
+
return new TokenStreamImpl(out, 'en');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const lit = (value: string, ...alternatives: string[]): PatternToken =>
|
|
29
|
+
alternatives.length ? { type: 'literal', value, alternatives } : { type: 'literal', value };
|
|
30
|
+
const slot = (
|
|
31
|
+
role: string,
|
|
32
|
+
extra: { optional?: boolean; greedy?: boolean; expectedTypes?: string[] } = {}
|
|
33
|
+
): PatternToken => ({ type: 'role', role, ...extra }) as PatternToken;
|
|
34
|
+
const group = (...tokens: PatternToken[]): PatternToken => ({
|
|
35
|
+
type: 'group',
|
|
36
|
+
optional: true,
|
|
37
|
+
tokens,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function pattern(tokens: PatternToken[], language = 'en'): LanguagePattern {
|
|
41
|
+
return {
|
|
42
|
+
id: 'p',
|
|
43
|
+
language,
|
|
44
|
+
command: 'go',
|
|
45
|
+
priority: 100,
|
|
46
|
+
template: { format: 'p', tokens },
|
|
47
|
+
extraction: {},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const matcher = () => new MatchSkeleton(createStockReader(), stockConfidenceModel);
|
|
52
|
+
const read = (p: LanguagePattern, ...tokens: Parameters<typeof stream>) => {
|
|
53
|
+
const result = matcher().matchPattern(stream(...tokens), p);
|
|
54
|
+
return result && Object.fromEntries(result.captured);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe('one value per slot', () => {
|
|
58
|
+
const go = pattern([lit('go'), slot('x')]);
|
|
59
|
+
it.each([
|
|
60
|
+
['#box', 'selector', { type: 'selector', value: '#box' }],
|
|
61
|
+
['"hello there"', 'literal', { type: 'literal', value: 'hello there', dataType: 'string' }],
|
|
62
|
+
['42', 'literal', { type: 'literal', value: 42, dataType: 'number' }],
|
|
63
|
+
['true', 'literal', { type: 'literal', value: true, dataType: 'boolean' }],
|
|
64
|
+
['300ms', 'literal', { type: 'literal', value: '300ms', dataType: 'duration' }],
|
|
65
|
+
['/api/users', 'url', { type: 'literal', value: '/api/users', dataType: 'string' }],
|
|
66
|
+
['users', 'identifier', { type: 'expression', raw: 'users' }],
|
|
67
|
+
] as const)('reads %s', (value, kind, want) => {
|
|
68
|
+
expect(read(go, ['go', 'keyword'], [value, kind])).toEqual({ x: want });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('skips an English article before a selector', () => {
|
|
72
|
+
expect(read(go, ['go', 'keyword'], ['the', 'identifier'], ['#box', 'selector'])).toEqual({
|
|
73
|
+
x: { type: 'selector', value: '#box' },
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('refuses a value of a type the slot does not take, unless the slot is optional', () => {
|
|
78
|
+
const selectorOnly = pattern([lit('go'), slot('x', { expectedTypes: ['selector'] })]);
|
|
79
|
+
expect(read(selectorOnly, ['go', 'keyword'], ['users', 'identifier'])).toBeNull();
|
|
80
|
+
const optional = pattern([
|
|
81
|
+
lit('go'),
|
|
82
|
+
slot('x', { expectedTypes: ['selector'], optional: true }),
|
|
83
|
+
]);
|
|
84
|
+
expect(read(optional, ['go', 'keyword'], ['users', 'identifier'])).toEqual({});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('a greedy slot', () => {
|
|
89
|
+
it('reads to the next marker the pattern owes', () => {
|
|
90
|
+
const select = pattern([
|
|
91
|
+
lit('select'),
|
|
92
|
+
slot('columns', { greedy: true }),
|
|
93
|
+
lit('from'),
|
|
94
|
+
slot('source'),
|
|
95
|
+
]);
|
|
96
|
+
expect(
|
|
97
|
+
read(
|
|
98
|
+
select,
|
|
99
|
+
['select', 'keyword'],
|
|
100
|
+
['name', 'identifier'],
|
|
101
|
+
[',', 'punctuation'],
|
|
102
|
+
['email', 'identifier'],
|
|
103
|
+
['from', 'keyword'],
|
|
104
|
+
['users', 'identifier']
|
|
105
|
+
)
|
|
106
|
+
).toEqual({
|
|
107
|
+
columns: { type: 'expression', raw: 'name , email' },
|
|
108
|
+
source: { type: 'expression', raw: 'users' },
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('reads to the end when nothing is owed, and is refused when it reads nothing', () => {
|
|
113
|
+
const where = pattern([lit('where'), slot('condition', { greedy: true })]);
|
|
114
|
+
expect(
|
|
115
|
+
read(where, ['where', 'keyword'], ['age', 'identifier'], ['>', 'operator'], ['18', 'literal'])
|
|
116
|
+
).toEqual({ condition: { type: 'expression', raw: 'age > 18' } });
|
|
117
|
+
expect(read(where, ['where', 'keyword'])).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('stops past an optional group at the required literal after it, by value or normalized form', () => {
|
|
121
|
+
const rest: PatternToken[] = [group(lit('where'), slot('c')), lit('選択', 'select')];
|
|
122
|
+
expect([...greedyStopMarkers(rest)]).toEqual(['where', '選択', 'select']);
|
|
123
|
+
const tokens = stream(['users', 'identifier'], ['SELECT', 'keyword', 'select']);
|
|
124
|
+
expect(readGreedy(tokens, greedyStopMarkers(rest))).toEqual({
|
|
125
|
+
type: 'expression',
|
|
126
|
+
raw: 'users',
|
|
127
|
+
});
|
|
128
|
+
expect(tokens.position()).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('the marker guard and the optional skip', () => {
|
|
133
|
+
it('a required slot does not read the marker the pattern wants next', () => {
|
|
134
|
+
// SOV `{patient} を 実行` given `を 実行`: the patient is missing, not `を`.
|
|
135
|
+
const sov = pattern([slot('patient'), lit('を'), lit('実行')], 'ja');
|
|
136
|
+
expect(read(sov, ['を', 'particle'], ['実行', 'keyword'])).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('an optional slot is skipped for the literal the pattern wants next, when the rest then reads to the end', () => {
|
|
140
|
+
const p = pattern([lit('go'), group(slot('x', { optional: true })), lit('now')]);
|
|
141
|
+
expect(read(p, ['go', 'keyword'], ['now', 'keyword'])).toEqual({});
|
|
142
|
+
expect(read(p, ['go', 'keyword'], ['#a', 'selector'], ['now', 'keyword'])).toEqual({
|
|
143
|
+
x: { type: 'selector', value: '#a' },
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('a required slot takes back the optional slot before it', () => {
|
|
149
|
+
it('when the optional slot took its value (behaviorspec `when clicks`)', () => {
|
|
150
|
+
const when = pattern([lit('when'), group(slot('actor', { optional: true })), slot('action')]);
|
|
151
|
+
expect(read(when, ['when', 'keyword'], ['clicks', 'identifier'])).toEqual({
|
|
152
|
+
action: { type: 'expression', raw: 'clicks' },
|
|
153
|
+
});
|
|
154
|
+
expect(
|
|
155
|
+
read(when, ['when', 'keyword'], ['user', 'identifier'], ['clicks', 'identifier'])
|
|
156
|
+
).toEqual({
|
|
157
|
+
actor: { type: 'expression', raw: 'user' },
|
|
158
|
+
action: { type: 'expression', raw: 'clicks' },
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('when it stands at its own marker (flow `orders として 取得変数`)', () => {
|
|
163
|
+
const capture = pattern(
|
|
164
|
+
[
|
|
165
|
+
group(slot('patient', { optional: true })),
|
|
166
|
+
slot('destination'),
|
|
167
|
+
lit('として'),
|
|
168
|
+
lit('取得変数'),
|
|
169
|
+
],
|
|
170
|
+
'ja'
|
|
171
|
+
);
|
|
172
|
+
expect(
|
|
173
|
+
read(capture, ['orders', 'identifier'], ['として', 'particle'], ['取得変数', 'keyword'])
|
|
174
|
+
).toEqual({ destination: { type: 'expression', raw: 'orders' } });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('not when something was read between them', () => {
|
|
178
|
+
const p = pattern([group(slot('a', { optional: true })), lit('to'), slot('b')]);
|
|
179
|
+
expect(read(p, ['x', 'identifier'], ['to', 'keyword'])).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stock value reader: what a domain grammar's matcher reads at each role
|
|
3
|
+
* slot, on the matcher's skeleton (domains rethink Phase 4.7,
|
|
4
|
+
* `docs-internal/DOMAINS_RETHINK.md`). Hyperscript's reader is semantic's
|
|
5
|
+
* `HyperscriptValueReader`; a domain takes this one.
|
|
6
|
+
*
|
|
7
|
+
* - A **greedy** slot (`RoleSpec.greedy`: a SQL condition, a column list) reads
|
|
8
|
+
* every token up to the next marker the pattern still owes, or the end, as
|
|
9
|
+
* one expression (`readGreedy`).
|
|
10
|
+
* - Any other slot reads one value (`readRoleValue`, the old matcher's reading):
|
|
11
|
+
* a selector, a quoted string, a number, a boolean, a duration, a URL, an
|
|
12
|
+
* identifier, or one of the multi-token forms it knows (`user.name`,
|
|
13
|
+
* `#dialog.showModal()`), checked against the slot's expected types.
|
|
14
|
+
* - A **required** slot reads nothing where the stream is at the marker the
|
|
15
|
+
* pattern wants next (the old matcher's guard against an SOV marker taken as
|
|
16
|
+
* a value). Where a required slot finds nothing to read, and the optional,
|
|
17
|
+
* marker-less slot just before it read a value with nothing read since, that
|
|
18
|
+
* value is given back and the required slot reads again (the old matcher's
|
|
19
|
+
* backtrack: behaviorspec `when clicks`, where the optional actor took
|
|
20
|
+
* `clicks`; flow `orders として 取得変数`, where the optional patient took
|
|
21
|
+
* `orders`).
|
|
22
|
+
* - An **optional** slot may be skipped for a token the pattern wants next, the
|
|
23
|
+
* skip kept only when the rest of the pattern then reads to the end.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
StreamMark,
|
|
28
|
+
LanguagePattern,
|
|
29
|
+
LanguageToken,
|
|
30
|
+
PatternToken,
|
|
31
|
+
SemanticRole,
|
|
32
|
+
SemanticValue,
|
|
33
|
+
TokenStream,
|
|
34
|
+
} from '../types';
|
|
35
|
+
import type { MatchView, RoleSlot, ValueReader } from './value-reader';
|
|
36
|
+
import { readRoleValue, type ReadingProfile } from './stock-reading';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The markers a greedy value stops at, from the pattern tokens still owed after
|
|
40
|
+
* it: the next literal (with its alternatives), and each optional group's
|
|
41
|
+
* leading literal up to and including the first required token.
|
|
42
|
+
*/
|
|
43
|
+
export function greedyStopMarkers(rest: readonly PatternToken[]): Set<string> {
|
|
44
|
+
const markers = new Set<string>();
|
|
45
|
+
const add = (pt: PatternToken & { type: 'literal' }): void => {
|
|
46
|
+
markers.add(pt.value.toLowerCase());
|
|
47
|
+
for (const alt of pt.alternatives ?? []) markers.add(alt.toLowerCase());
|
|
48
|
+
};
|
|
49
|
+
for (const pt of rest) {
|
|
50
|
+
if (pt.type === 'literal') {
|
|
51
|
+
add(pt);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
if (pt.type === 'group') {
|
|
55
|
+
const lead = pt.tokens.find(t => t.type === 'literal');
|
|
56
|
+
if (lead?.type === 'literal') add(lead);
|
|
57
|
+
// For optional groups, continue scanning past them so the greedy capture
|
|
58
|
+
// also sees subsequent required literals (the SOV verb after an optional
|
|
59
|
+
// WHERE group).
|
|
60
|
+
if (!pt.optional) break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return markers;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether `token` is one of the stop markers, by its value or its normalized form. */
|
|
67
|
+
function isStopMarker(token: LanguageToken, stopMarkers: ReadonlySet<string>): boolean {
|
|
68
|
+
if (stopMarkers.size === 0) return false;
|
|
69
|
+
if (stopMarkers.has(token.value.toLowerCase())) return true;
|
|
70
|
+
return !!token.normalized && stopMarkers.has(token.normalized.toLowerCase());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read every token up to a stop marker (or the end) as one expression value,
|
|
75
|
+
* advancing past them; null (consuming nothing) when the first token stops it.
|
|
76
|
+
*/
|
|
77
|
+
export function readGreedy(
|
|
78
|
+
tokens: TokenStream,
|
|
79
|
+
stopMarkers: ReadonlySet<string>
|
|
80
|
+
): SemanticValue | null {
|
|
81
|
+
const values: string[] = [];
|
|
82
|
+
while (!tokens.isAtEnd()) {
|
|
83
|
+
const next = tokens.peek();
|
|
84
|
+
if (!next || isStopMarker(next, stopMarkers)) break;
|
|
85
|
+
values.push(next.value);
|
|
86
|
+
tokens.advance();
|
|
87
|
+
}
|
|
88
|
+
return values.length > 0 ? { type: 'expression', raw: values.join(' ') } : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The stock reader, for a matcher whose profiles it may consult (possessive words). */
|
|
92
|
+
export class StockValueReader implements ValueReader {
|
|
93
|
+
private profile: ReadingProfile | undefined;
|
|
94
|
+
/** The last optional, marker-less slot that read a value: what a failing required slot may take back. */
|
|
95
|
+
private lastOptional: { role: SemanticRole; start: StreamMark; end: number } | undefined;
|
|
96
|
+
|
|
97
|
+
constructor(
|
|
98
|
+
private readonly view: MatchView,
|
|
99
|
+
private readonly profiles: Readonly<Record<string, ReadingProfile>> = {}
|
|
100
|
+
) {}
|
|
101
|
+
|
|
102
|
+
beginPattern(pattern: LanguagePattern): void {
|
|
103
|
+
this.profile = this.profiles[pattern.language] ?? { code: pattern.language };
|
|
104
|
+
this.lastOptional = undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
readRole(
|
|
108
|
+
tokens: TokenStream,
|
|
109
|
+
slot: RoleSlot,
|
|
110
|
+
captured: Map<SemanticRole, SemanticValue>,
|
|
111
|
+
next: PatternToken | undefined
|
|
112
|
+
): boolean {
|
|
113
|
+
const start = tokens.mark();
|
|
114
|
+
if (this.readOnce(tokens, slot, captured, next, true)) {
|
|
115
|
+
if (!slot.optional) {
|
|
116
|
+
this.lastOptional = undefined;
|
|
117
|
+
} else if (
|
|
118
|
+
captured.has(slot.role) &&
|
|
119
|
+
tokens.position() > start.position &&
|
|
120
|
+
!this.view.slotAfterLiteral() &&
|
|
121
|
+
next?.type !== 'literal'
|
|
122
|
+
) {
|
|
123
|
+
this.lastOptional = { role: slot.role, start, end: tokens.position() };
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
const before = this.lastOptional;
|
|
128
|
+
if (slot.optional || !before || before.end !== tokens.position()) return false;
|
|
129
|
+
// Give the optional slot's value back and read this one from there.
|
|
130
|
+
this.lastOptional = undefined;
|
|
131
|
+
tokens.reset(before.start);
|
|
132
|
+
captured.delete(before.role);
|
|
133
|
+
return this.readOnce(tokens, slot, captured, next, false);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One read of `slot`; `guard` refuses a required slot the marker the pattern wants next. */
|
|
137
|
+
private readOnce(
|
|
138
|
+
tokens: TokenStream,
|
|
139
|
+
slot: RoleSlot,
|
|
140
|
+
captured: Map<SemanticRole, SemanticValue>,
|
|
141
|
+
next: PatternToken | undefined,
|
|
142
|
+
guard: boolean
|
|
143
|
+
): boolean {
|
|
144
|
+
// A required slot never takes the marker the pattern wants next as its
|
|
145
|
+
// value (`v0x を 実行`: `を` is the marker, not the patient).
|
|
146
|
+
if (guard && !slot.optional && next?.type === 'literal') {
|
|
147
|
+
const token = tokens.peek();
|
|
148
|
+
if (token && this.view.wouldMatch(next, token)) return false;
|
|
149
|
+
}
|
|
150
|
+
if (slot.greedy) {
|
|
151
|
+
const value = readGreedy(tokens, greedyStopMarkers(this.view.patternRest()));
|
|
152
|
+
if (value) {
|
|
153
|
+
captured.set(slot.role, value);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
return slot.optional ?? false;
|
|
157
|
+
}
|
|
158
|
+
return readRoleValue(tokens, slot, captured, this.profile);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
maySkipSlot(
|
|
162
|
+
patternToken: PatternToken,
|
|
163
|
+
token: LanguageToken | null,
|
|
164
|
+
next: PatternToken | undefined
|
|
165
|
+
): boolean {
|
|
166
|
+
const slot =
|
|
167
|
+
patternToken.type === 'group' && patternToken.optional
|
|
168
|
+
? patternToken.tokens.every(t => t.type === 'role')
|
|
169
|
+
? patternToken.tokens[0]
|
|
170
|
+
: undefined
|
|
171
|
+
: patternToken;
|
|
172
|
+
if (!slot || slot.type !== 'role' || !slot.optional) return false;
|
|
173
|
+
return !!token && this.view.wouldMatch(next, token);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
atClauseEnd(tokens: TokenStream): boolean {
|
|
177
|
+
return tokens.isAtEnd();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A reader factory for `MatchSkeleton`, reading with the given profiles. */
|
|
182
|
+
export function createStockReader(
|
|
183
|
+
profiles: Readonly<Record<string, ReadingProfile>> = {}
|
|
184
|
+
): (view: MatchView) => ValueReader {
|
|
185
|
+
return view => new StockValueReader(view, profiles);
|
|
186
|
+
}
|