@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
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import type { MultilingualDSL, CompileResult, ValidationResult } from './create-dsl';
|
|
34
|
+
import { UnreadInputError, TranslationReadBackError } from './create-dsl';
|
|
35
|
+
import { readingDifference } from './reading';
|
|
36
|
+
import type { SemanticNode } from '../core/types';
|
|
34
37
|
import type { NaturalLanguageRenderer, DomainRenderFn } from '../generation/renderer';
|
|
35
38
|
import type { CommandSchema } from '../schema/command-schema';
|
|
36
39
|
import type { SchemaLookup } from '../ir/types';
|
|
@@ -244,6 +247,9 @@ export class DomainRegistry {
|
|
|
244
247
|
return jsonResponse({ error: `Unknown operation: ${parsed.operation}` }, true);
|
|
245
248
|
}
|
|
246
249
|
} catch (error) {
|
|
250
|
+
// A refusal is not a tool failure: say what was refused and why, as translate_code does.
|
|
251
|
+
if (error instanceof UnreadInputError) return unreadInputResponse(error);
|
|
252
|
+
if (error instanceof TranslationReadBackError) return readBackResponse(error);
|
|
247
253
|
const message = error instanceof Error ? error.message : String(error);
|
|
248
254
|
return jsonResponse({ error: `${descriptor.name} tool error: ${message}` }, true);
|
|
249
255
|
}
|
|
@@ -412,6 +418,9 @@ export class DomainRegistry {
|
|
|
412
418
|
ok: result.ok,
|
|
413
419
|
code: result.code,
|
|
414
420
|
errors: result.errors,
|
|
421
|
+
...(result.errorCode === 'unread-input'
|
|
422
|
+
? { diagnostics: [UNREAD_INPUT(result.errors)] }
|
|
423
|
+
: {}),
|
|
415
424
|
language,
|
|
416
425
|
input,
|
|
417
426
|
});
|
|
@@ -431,6 +440,9 @@ export class DomainRegistry {
|
|
|
431
440
|
return jsonResponse({
|
|
432
441
|
valid: result.valid,
|
|
433
442
|
errors: result.errors,
|
|
443
|
+
...(result.errorCode === 'unread-input'
|
|
444
|
+
? { diagnostics: [UNREAD_INPUT(result.errors)] }
|
|
445
|
+
: {}),
|
|
434
446
|
language,
|
|
435
447
|
[descriptor.inputLabel]: input,
|
|
436
448
|
});
|
|
@@ -478,7 +490,28 @@ export class DomainRegistry {
|
|
|
478
490
|
roles[key] = value;
|
|
479
491
|
}
|
|
480
492
|
|
|
493
|
+
// The rendering must read back as the source, by the rule `translate` uses: the same action and
|
|
494
|
+
// role values, or the same compiled code. A rendering that does not is refused, with the text under
|
|
495
|
+
// `partial`, never `rendered`.
|
|
496
|
+
if (rendered != null) {
|
|
497
|
+
let back: SemanticNode | null = null;
|
|
498
|
+
let why = '';
|
|
499
|
+
try {
|
|
500
|
+
back = dsl.parse(rendered, to);
|
|
501
|
+
why = readingDifference(node, back);
|
|
502
|
+
if (why && compiled.ok) {
|
|
503
|
+
const again = dsl.compile(rendered, to);
|
|
504
|
+
if (again.ok && again.code === compiled.code) why = '';
|
|
505
|
+
}
|
|
506
|
+
} catch (error) {
|
|
507
|
+
why = error instanceof Error ? error.message : String(error);
|
|
508
|
+
}
|
|
509
|
+
if (why)
|
|
510
|
+
return readBackResponse(new TranslationReadBackError(rendered, from, to, node, back, why));
|
|
511
|
+
}
|
|
512
|
+
|
|
481
513
|
return jsonResponse({
|
|
514
|
+
ok: true,
|
|
482
515
|
input: { [descriptor.inputLabel]: input, language: from },
|
|
483
516
|
...(rendered != null && { rendered: { text: rendered, language: to } }),
|
|
484
517
|
semantic: { action: node.action, roles },
|
|
@@ -567,7 +600,7 @@ function generateToolDefinitions(desc: DomainDescriptor): MCPToolDefinition[] {
|
|
|
567
600
|
case 'translate':
|
|
568
601
|
tools.push({
|
|
569
602
|
name: `translate_${desc.name}`,
|
|
570
|
-
description: `Translate ${desc.description} input between natural languages (requires 'from' and 'to' codes). Supports: ${langList}.`,
|
|
603
|
+
description: `Translate ${desc.description} input between natural languages (requires 'from' and 'to' codes). Supports: ${langList}. A rendering that does not read back as the source is REFUSED: ok is false, the diagnostic is TRANSLATION_DOES_NOT_READ_BACK, and its text is under \`partial\`, never \`rendered\`: do not present it as the translation. Input left unread is refused as UNREAD_INPUT.`,
|
|
571
604
|
inputSchema: {
|
|
572
605
|
type: 'object',
|
|
573
606
|
properties: {
|
|
@@ -609,6 +642,54 @@ function getString(args: Record<string, unknown>, name: string, defaultValue = '
|
|
|
609
642
|
return typeof value === 'string' ? value : defaultValue;
|
|
610
643
|
}
|
|
611
644
|
|
|
645
|
+
function readingOf(node: SemanticNode): { action: string; roles: Record<string, unknown> } {
|
|
646
|
+
const roles: Record<string, unknown> = {};
|
|
647
|
+
for (const [key, value] of node.roles) roles[key] = value;
|
|
648
|
+
return { action: node.action, roles };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const UNREAD_INPUT = (errors?: readonly string[]) => ({
|
|
652
|
+
severity: 'error',
|
|
653
|
+
code: 'UNREAD_INPUT',
|
|
654
|
+
message: errors?.[0] ?? 'part of the input was not read',
|
|
655
|
+
suggestion:
|
|
656
|
+
'Part of the input belongs to no role of the command it reads as: remove it, or quote a value of several words.',
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
/** A parse refused because it read the command only by leaving input unread. */
|
|
660
|
+
function unreadInputResponse(error: UnreadInputError): MCPToolResponse {
|
|
661
|
+
return jsonResponse(
|
|
662
|
+
{
|
|
663
|
+
ok: false,
|
|
664
|
+
unread: error.unread,
|
|
665
|
+
reading: readingOf(error.reading),
|
|
666
|
+
diagnostics: [UNREAD_INPUT([error.message])],
|
|
667
|
+
},
|
|
668
|
+
true
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** A translation refused because it does not read back as the source; its text under `partial`. */
|
|
673
|
+
function readBackResponse(error: TranslationReadBackError): MCPToolResponse {
|
|
674
|
+
return jsonResponse(
|
|
675
|
+
{
|
|
676
|
+
ok: false,
|
|
677
|
+
partial: error.partial,
|
|
678
|
+
...(error.reading ? { reading: readingOf(error.reading) } : {}),
|
|
679
|
+
diagnostics: [
|
|
680
|
+
{
|
|
681
|
+
severity: 'error',
|
|
682
|
+
code: 'TRANSLATION_DOES_NOT_READ_BACK',
|
|
683
|
+
message: error.message,
|
|
684
|
+
suggestion:
|
|
685
|
+
'The rendering would not mean the source in the target language: do not present `partial` as the translation.',
|
|
686
|
+
},
|
|
687
|
+
],
|
|
688
|
+
},
|
|
689
|
+
true
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
612
693
|
function jsonResponse(data: unknown, isError?: boolean): MCPToolResponse {
|
|
613
694
|
return {
|
|
614
695
|
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
package/src/api/index.ts
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a read-back is compared (internal: not exported from the package). `translate` and the
|
|
3
|
+
* registry's translate tool both require a rendering to read back as its source; this is their rule.
|
|
4
|
+
*/
|
|
5
|
+
import type { SemanticNode } from '../core/types';
|
|
6
|
+
|
|
7
|
+
/** A node's role values as strings, by role: what a read-back is compared on. */
|
|
8
|
+
function roleValues(node: SemanticNode): Map<string, string> {
|
|
9
|
+
const out = new Map<string, string>();
|
|
10
|
+
for (const [role, value] of node.roles) {
|
|
11
|
+
const v = value as { value?: unknown; raw?: unknown };
|
|
12
|
+
out.set(role, String(v?.value ?? v?.raw ?? value));
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** How `back` differs from `source`; empty when it reads the same. */
|
|
18
|
+
export function readingDifference(source: SemanticNode, back: SemanticNode): string {
|
|
19
|
+
if (back.action !== source.action) return `reads as ${back.action}`;
|
|
20
|
+
const want = roleValues(source);
|
|
21
|
+
const got = roleValues(back);
|
|
22
|
+
const parts: string[] = [];
|
|
23
|
+
for (const [role, value] of want) {
|
|
24
|
+
if (!got.has(role)) parts.push(`${role} lost`);
|
|
25
|
+
else if (got.get(role) !== value) parts.push(`${role}: ${value} → ${got.get(role)}`);
|
|
26
|
+
}
|
|
27
|
+
for (const role of got.keys()) if (!want.has(role)) parts.push(`${role} added`);
|
|
28
|
+
return parts.join(', ');
|
|
29
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The translation-loss checks, with rules of a toy grammar: each check in its
|
|
3
|
+
* place in the order, and the error a refusing translation throws.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
findTranslationLoss,
|
|
8
|
+
LossyTranslationError,
|
|
9
|
+
unconsumedSpans,
|
|
10
|
+
type TranslationLossRules,
|
|
11
|
+
} from './translation-loss';
|
|
12
|
+
|
|
13
|
+
interface Node {
|
|
14
|
+
readonly action: string;
|
|
15
|
+
readonly diagnostics?: ReadonlyArray<{ readonly code?: string; readonly message: string }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const unread = (text: string): Node => ({
|
|
19
|
+
action: 'go',
|
|
20
|
+
diagnostics: [{ code: 'unconsumed-input', message: `input not read: "${text}"` }],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const rules: TranslationLossRules<Node> = {
|
|
24
|
+
unwritable: node => (node.action === 'legacy' ? ['legacy'] : []),
|
|
25
|
+
differences: [(written, back) => (written.action === back.action ? [] : [`-${written.action}`])],
|
|
26
|
+
missingValues: (input, output) => (input.includes('#a') && !output.includes('#a') ? ['#a'] : []),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('findTranslationLoss', () => {
|
|
30
|
+
const ok: Node = { action: 'go' };
|
|
31
|
+
|
|
32
|
+
it('finds nothing in a translation that reads back with its values', () => {
|
|
33
|
+
expect(findTranslationLoss('go #a', ok, 've #a', () => ok, rules)).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('checks in order: truncation, core-only, read-back, invariant', () => {
|
|
37
|
+
expect(findTranslationLoss('go #a x', unread('x'), '', () => ok, rules)).toEqual({
|
|
38
|
+
kind: 'truncation',
|
|
39
|
+
lost: ['x'],
|
|
40
|
+
});
|
|
41
|
+
expect(findTranslationLoss('x', { action: 'legacy' }, '', () => ok, rules)?.kind).toBe(
|
|
42
|
+
'core-only'
|
|
43
|
+
);
|
|
44
|
+
expect(
|
|
45
|
+
findTranslationLoss(
|
|
46
|
+
'go #a',
|
|
47
|
+
ok,
|
|
48
|
+
'?',
|
|
49
|
+
() => {
|
|
50
|
+
throw new Error('no pattern');
|
|
51
|
+
},
|
|
52
|
+
rules
|
|
53
|
+
)
|
|
54
|
+
).toEqual({ kind: 'read-back', lost: ['the output does not read: no pattern'] });
|
|
55
|
+
expect(findTranslationLoss('go #a', ok, 've #a y', () => unread('y'), rules)).toEqual({
|
|
56
|
+
kind: 'read-back',
|
|
57
|
+
lost: ['y'],
|
|
58
|
+
});
|
|
59
|
+
expect(findTranslationLoss('go #a', ok, 'stop #a', () => ({ action: 'stop' }), rules)).toEqual({
|
|
60
|
+
kind: 'read-back',
|
|
61
|
+
lost: ['-go'],
|
|
62
|
+
});
|
|
63
|
+
expect(findTranslationLoss('go #a', ok, 've', () => ok, rules)).toEqual({
|
|
64
|
+
kind: 'invariant',
|
|
65
|
+
lost: ['#a'],
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('compares the read-back with what was written', () => {
|
|
70
|
+
const written: TranslationLossRules<Node> = { ...rules, written: () => ({ action: 'stop' }) };
|
|
71
|
+
expect(
|
|
72
|
+
findTranslationLoss('go #a', ok, 'stop #a', () => ({ action: 'stop' }), written)
|
|
73
|
+
).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('unconsumedSpans and LossyTranslationError', () => {
|
|
78
|
+
it('reads the unread text out of the diagnostics', () => {
|
|
79
|
+
expect(unconsumedSpans(unread('banana'))).toEqual(['banana']);
|
|
80
|
+
expect(unconsumedSpans({ action: 'go' })).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('says what the translation loses', () => {
|
|
84
|
+
const error = new LossyTranslationError('ve', { kind: 'invariant', lost: ['#a'] }, 'en', 'es');
|
|
85
|
+
expect(error.name).toBe('LossyTranslationError');
|
|
86
|
+
expect(error.partial).toBe('ve');
|
|
87
|
+
expect(error.message).toBe('translation en → es would lose part of the script (invariant): #a');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-loud translation: what a translation checks before it returns (domains
|
|
3
|
+
* rethink Phase 4.5, `docs-internal/DOMAINS_RETHINK.md`). Moved from
|
|
4
|
+
* `@lokascript/semantic`'s `explicit/lossy.ts`, whose `translate` runs it with
|
|
5
|
+
* hyperscript's rules; `createMultilingualDSL`'s `translate` keeps its own
|
|
6
|
+
* read-back (`TranslationReadBackError`).
|
|
7
|
+
*
|
|
8
|
+
* A parse that matches a prefix of a script and leaves the rest unread used to
|
|
9
|
+
* come back from `translate()` as though it were the whole: `toggle .a .b`
|
|
10
|
+
* became `toggle .a`. A surface that cannot carry a script now says so: the
|
|
11
|
+
* translation throws a {@link LossyTranslationError}, which carries the output
|
|
12
|
+
* it would have returned and what it loses. Four checks, cheapest first:
|
|
13
|
+
*
|
|
14
|
+
* - `truncation` the parse of the input left input bound to no role (its
|
|
15
|
+
* `unconsumed-input` diagnostics);
|
|
16
|
+
* - `core-only` the input holds a form the grammar's writer has no spelling
|
|
17
|
+
* for (hyperscript: a form only `@hyperfixi/core` ran, which
|
|
18
|
+
* upstream reads with another meaning) — `rules.unwritable`;
|
|
19
|
+
* - `read-back` the output, read in the target language, does not read,
|
|
20
|
+
* leaves input unread, or differs from what was written
|
|
21
|
+
* (`rules.differences`, in order);
|
|
22
|
+
* - `invariant` a value the translation must write verbatim is missing
|
|
23
|
+
* from the output (`rules.missingValues`).
|
|
24
|
+
*
|
|
25
|
+
* None of them can say a translation is right. Each says one way it is wrong.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type TranslationLossKind = 'truncation' | 'core-only' | 'read-back' | 'invariant';
|
|
29
|
+
|
|
30
|
+
/** One way a translation loses part of its input. */
|
|
31
|
+
export interface TranslationLoss {
|
|
32
|
+
kind: TranslationLossKind;
|
|
33
|
+
/**
|
|
34
|
+
* What is lost: the input left unread (`truncation`); the forms nothing
|
|
35
|
+
* writes (`core-only`, as `swap <strategy>`); the output left unread or what
|
|
36
|
+
* it reads back without or with in excess (`read-back`, as `-cmd` / `+cmd`);
|
|
37
|
+
* or the verbatim values missing from the output (`invariant`).
|
|
38
|
+
*/
|
|
39
|
+
lost: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class LossyTranslationError extends Error {
|
|
43
|
+
override readonly name = 'LossyTranslationError';
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
/** The output `translate()` would have returned: NOT the whole script. */
|
|
47
|
+
readonly partial: string,
|
|
48
|
+
readonly loss: TranslationLoss,
|
|
49
|
+
readonly from: string,
|
|
50
|
+
readonly to: string
|
|
51
|
+
) {
|
|
52
|
+
super(
|
|
53
|
+
`translation ${from} → ${to} would lose part of the script (${loss.kind}): ` +
|
|
54
|
+
loss.lost.join(', ')
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What the checks read of a parse: its diagnostics. */
|
|
60
|
+
export interface LossCheckedNode {
|
|
61
|
+
readonly diagnostics?: ReadonlyArray<{ readonly code?: string; readonly message: string }>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The spans a parse left bound to no role (its top node carries them all). */
|
|
65
|
+
export function unconsumedSpans(node: LossCheckedNode): string[] {
|
|
66
|
+
const out: string[] = [];
|
|
67
|
+
for (const d of node.diagnostics ?? []) {
|
|
68
|
+
if (d.code !== 'unconsumed-input') continue;
|
|
69
|
+
out.push(/"(.*)"\s*$/s.exec(d.message)?.[1] ?? d.message);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A grammar's rules for `findTranslationLoss`, for its node type `N`. */
|
|
75
|
+
export interface TranslationLossRules<N extends LossCheckedNode> {
|
|
76
|
+
/** The forms in `node` the grammar's writer has no spelling for (`core-only`). */
|
|
77
|
+
unwritable?(node: N): string[];
|
|
78
|
+
/**
|
|
79
|
+
* `node` as it was written into the target language, to compare its
|
|
80
|
+
* read-back with (hyperscript: in upstream's spelling for English). Default:
|
|
81
|
+
* `node` itself.
|
|
82
|
+
*/
|
|
83
|
+
written?(node: N): N;
|
|
84
|
+
/**
|
|
85
|
+
* How the read-back differs from what was written, checked in order: the
|
|
86
|
+
* first that finds a difference is the loss (hyperscript: commands, then
|
|
87
|
+
* roles, then clauses kept as written).
|
|
88
|
+
*/
|
|
89
|
+
readonly differences: ReadonlyArray<(written: N, back: N) => string[]>;
|
|
90
|
+
/** The values `input` holds that `output` must write verbatim and does not (`invariant`). */
|
|
91
|
+
missingValues?(input: string, output: string): string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* How a translation of `input` (parsed as `node`) into `output` loses part of
|
|
96
|
+
* it, or undefined. `readBack` parses the output in the target language; it
|
|
97
|
+
* throws when the output does not read.
|
|
98
|
+
*/
|
|
99
|
+
export function findTranslationLoss<N extends LossCheckedNode>(
|
|
100
|
+
input: string,
|
|
101
|
+
node: N,
|
|
102
|
+
output: string,
|
|
103
|
+
readBack: (output: string) => N,
|
|
104
|
+
rules: TranslationLossRules<N>
|
|
105
|
+
): TranslationLoss | undefined {
|
|
106
|
+
const unread = unconsumedSpans(node);
|
|
107
|
+
if (unread.length) return { kind: 'truncation', lost: unread };
|
|
108
|
+
const unwritable = rules.unwritable?.(node) ?? [];
|
|
109
|
+
if (unwritable.length) return { kind: 'core-only', lost: unwritable };
|
|
110
|
+
|
|
111
|
+
let back: N;
|
|
112
|
+
try {
|
|
113
|
+
back = readBack(output);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return { kind: 'read-back', lost: [`the output does not read: ${(e as Error).message}`] };
|
|
116
|
+
}
|
|
117
|
+
const misread = unconsumedSpans(back);
|
|
118
|
+
if (misread.length) return { kind: 'read-back', lost: misread };
|
|
119
|
+
const written = rules.written ? rules.written(node) : node;
|
|
120
|
+
for (const difference of rules.differences) {
|
|
121
|
+
const lost = difference(written, back);
|
|
122
|
+
if (lost.length) return { kind: 'read-back', lost };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const values = rules.missingValues?.(input, output) ?? [];
|
|
126
|
+
if (values.length) return { kind: 'invariant', lost: values };
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The matcher's confidence model: an injectable score for a pattern's match
|
|
3
|
+
* (domains rethink Phase 4.3). The interface moved here with the skeleton
|
|
4
|
+
* (`match-skeleton.ts`), which calls `calculate` once per matched pattern;
|
|
5
|
+
* hyperscript's model is `@lokascript/semantic`'s `DefaultConfidenceModel`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LanguagePattern, SemanticRole, SemanticValue } from '../types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Context passed to confidence model methods.
|
|
12
|
+
*/
|
|
13
|
+
export interface ConfidenceContext {
|
|
14
|
+
/** The pattern being matched */
|
|
15
|
+
readonly pattern: LanguagePattern;
|
|
16
|
+
/** Captured semantic roles */
|
|
17
|
+
readonly captured: ReadonlyMap<SemanticRole, SemanticValue>;
|
|
18
|
+
/** Number of stem (morphological) keyword matches */
|
|
19
|
+
readonly stemMatchCount: number;
|
|
20
|
+
/** Total keyword matches attempted */
|
|
21
|
+
readonly totalKeywordMatches: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Breakdown of confidence scoring factors (for diagnostics/debugging).
|
|
26
|
+
*/
|
|
27
|
+
export interface ConfidenceBreakdown {
|
|
28
|
+
/** Role coverage score (0-1) */
|
|
29
|
+
readonly roleCoverage: number;
|
|
30
|
+
/** Stem match penalty applied (0 to -0.15) */
|
|
31
|
+
readonly stemPenalty: number;
|
|
32
|
+
/** Language-specific boost (e.g., VSO verb-first) */
|
|
33
|
+
readonly languageBoost: number;
|
|
34
|
+
/** Language-specific adjustment (e.g., preposition disambiguation) */
|
|
35
|
+
readonly languageAdjustment: number;
|
|
36
|
+
/** Final clamped confidence (0-1) */
|
|
37
|
+
readonly final: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Injectable confidence scoring model.
|
|
42
|
+
*
|
|
43
|
+
* Each method computes one factor of the confidence score.
|
|
44
|
+
* Override individual methods to customize scoring without rewriting the pipeline.
|
|
45
|
+
*/
|
|
46
|
+
export interface ConfidenceModel {
|
|
47
|
+
/**
|
|
48
|
+
* Calculate the full confidence score.
|
|
49
|
+
* Default implementation calls each factor method and combines them.
|
|
50
|
+
*/
|
|
51
|
+
calculate(ctx: ConfidenceContext): number;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Calculate confidence with a detailed breakdown.
|
|
55
|
+
* Useful for diagnostics and debugging.
|
|
56
|
+
*/
|
|
57
|
+
calculateWithBreakdown(ctx: ConfidenceContext): ConfidenceBreakdown;
|
|
58
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pattern matcher's skeleton and its value-reader seam (domains rethink
|
|
3
|
+
* Phase 4.3): the matcher semantic's hyperscript parser runs, with
|
|
4
|
+
* hyperscript's reading behind `ValueReader`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { MatchSkeleton } from './match-skeleton';
|
|
8
|
+
export type { MatchView, RoleSlot, ValueReader } from './value-reader';
|
|
9
|
+
export type { ConfidenceBreakdown, ConfidenceContext, ConfidenceModel } from './confidence-model';
|
|
10
|
+
export { StockValueReader, createStockReader, greedyStopMarkers, readGreedy } from './stock-reader';
|
|
11
|
+
export { readRoleValue, type ReadingProfile } from './stock-reading';
|
|
12
|
+
export { stockConfidenceModel } from './stock-confidence';
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The matcher's skeleton with a reader that knows no hyperscript: every role
|
|
3
|
+
* slot takes one token. What the skeleton does on its own — literals, group
|
|
4
|
+
* rollback, the speculative skip, spans, ranking — and that every value comes
|
|
5
|
+
* from the reader (domains rethink Phase 4.2-4.3).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import { MatchSkeleton } from './match-skeleton';
|
|
9
|
+
import type { MatchView, RoleSlot, ValueReader } from './value-reader';
|
|
10
|
+
import type { ConfidenceContext, ConfidenceModel } from './confidence-model';
|
|
11
|
+
import { TokenStreamImpl } from '../tokenization/token-utils';
|
|
12
|
+
import type {
|
|
13
|
+
LanguagePattern,
|
|
14
|
+
LanguageToken,
|
|
15
|
+
PatternMatchResult,
|
|
16
|
+
PatternToken,
|
|
17
|
+
SemanticRole,
|
|
18
|
+
SemanticValue,
|
|
19
|
+
TokenStream,
|
|
20
|
+
} from '../types';
|
|
21
|
+
|
|
22
|
+
class OneTokenReader implements ValueReader {
|
|
23
|
+
readonly began: string[] = [];
|
|
24
|
+
readonly owed: string[][] = [];
|
|
25
|
+
|
|
26
|
+
constructor(private readonly view: MatchView) {}
|
|
27
|
+
|
|
28
|
+
beginPattern(pattern: LanguagePattern): void {
|
|
29
|
+
this.began.push(pattern.id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
readRole(
|
|
33
|
+
tokens: TokenStream,
|
|
34
|
+
slot: RoleSlot,
|
|
35
|
+
captured: Map<SemanticRole, SemanticValue>
|
|
36
|
+
): boolean {
|
|
37
|
+
this.owed.push(
|
|
38
|
+
this.view.patternRest().flatMap(pt => (pt.type === 'literal' ? [pt.value] : []))
|
|
39
|
+
);
|
|
40
|
+
const token = tokens.peek();
|
|
41
|
+
if (!token) return slot.optional ?? false;
|
|
42
|
+
captured.set(slot.role, { type: 'literal', value: token.value });
|
|
43
|
+
tokens.advance();
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
maySkipSlot(patternToken: PatternToken, token: LanguageToken | null): boolean {
|
|
48
|
+
return patternToken.type === 'role' && !!patternToken.optional && token?.value === 'now';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
atClauseEnd(tokens: TokenStream): boolean {
|
|
52
|
+
return tokens.isAtEnd();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
resultFields(): Partial<PatternMatchResult> {
|
|
56
|
+
return { eventSource: { type: 'literal', value: 'from-reader' } };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function model(score: (ctx: ConfidenceContext) => number): ConfidenceModel {
|
|
61
|
+
return {
|
|
62
|
+
calculate: score,
|
|
63
|
+
calculateWithBreakdown: ctx => ({
|
|
64
|
+
roleCoverage: score(ctx),
|
|
65
|
+
stemPenalty: 0,
|
|
66
|
+
languageBoost: 0,
|
|
67
|
+
languageAdjustment: 0,
|
|
68
|
+
final: score(ctx),
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const flat = model(() => 1);
|
|
74
|
+
|
|
75
|
+
function matcher(confidence: ConfidenceModel = flat): {
|
|
76
|
+
skeleton: MatchSkeleton;
|
|
77
|
+
reader: () => OneTokenReader;
|
|
78
|
+
} {
|
|
79
|
+
let reader: OneTokenReader | undefined;
|
|
80
|
+
const skeleton = new MatchSkeleton(view => (reader = new OneTokenReader(view)), confidence);
|
|
81
|
+
return { skeleton, reader: () => reader! };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stream(text: string): TokenStream {
|
|
85
|
+
let offset = 0;
|
|
86
|
+
const tokens = text.split(' ').map(value => {
|
|
87
|
+
const token: LanguageToken = {
|
|
88
|
+
value,
|
|
89
|
+
kind: 'identifier',
|
|
90
|
+
position: { start: offset, end: offset + value.length },
|
|
91
|
+
};
|
|
92
|
+
offset += value.length + 1;
|
|
93
|
+
return token;
|
|
94
|
+
});
|
|
95
|
+
return new TokenStreamImpl(tokens, 'xx');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lit = (value: string): PatternToken => ({ type: 'literal', value });
|
|
99
|
+
const role = (name: string, optional = false): PatternToken => ({
|
|
100
|
+
type: 'role',
|
|
101
|
+
role: name as SemanticRole,
|
|
102
|
+
...(optional ? { optional } : {}),
|
|
103
|
+
});
|
|
104
|
+
const group = (...tokens: PatternToken[]): PatternToken => ({
|
|
105
|
+
type: 'group',
|
|
106
|
+
optional: true,
|
|
107
|
+
tokens,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
function pattern(id: string, tokens: PatternToken[], priority = 100): LanguagePattern {
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
language: 'xx',
|
|
114
|
+
command: 'go',
|
|
115
|
+
priority,
|
|
116
|
+
template: { format: id, tokens },
|
|
117
|
+
extraction: {},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const values = (result: PatternMatchResult | null) =>
|
|
122
|
+
Object.fromEntries(
|
|
123
|
+
[...(result?.captured ?? [])].map(([r, v]) => [r, (v as { value?: unknown }).value])
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
describe('MatchSkeleton', () => {
|
|
127
|
+
it('matches literals itself and reads every role slot through the reader', () => {
|
|
128
|
+
const { skeleton, reader } = matcher();
|
|
129
|
+
const result = skeleton.matchPattern(
|
|
130
|
+
stream('go home now'),
|
|
131
|
+
pattern('p', [lit('go'), role('destination'), lit('now')])
|
|
132
|
+
);
|
|
133
|
+
expect(values(result)).toEqual({ destination: 'home' });
|
|
134
|
+
expect(result?.consumedTokens).toBe(3);
|
|
135
|
+
expect(result?.roleStarts?.get('destination' as SemanticRole)).toBe(1);
|
|
136
|
+
expect(reader().began).toEqual(['p']);
|
|
137
|
+
// The view shows the reader what the pattern still owes after the slot.
|
|
138
|
+
expect(reader().owed).toEqual([['now']]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("adds the reader's result fields", () => {
|
|
142
|
+
const { skeleton } = matcher();
|
|
143
|
+
const result = skeleton.matchPattern(stream('go home'), pattern('p', [lit('go'), role('x')]));
|
|
144
|
+
expect(result?.eventSource).toEqual({ type: 'literal', value: 'from-reader' });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rolls a failed group back, restoring a value it overwrote', () => {
|
|
148
|
+
const { skeleton } = matcher();
|
|
149
|
+
// The group reads `b` into destination, then fails on its marker.
|
|
150
|
+
const result = skeleton.matchPattern(
|
|
151
|
+
stream('a b c'),
|
|
152
|
+
pattern('p', [role('destination'), group(role('destination'), lit('に'))])
|
|
153
|
+
);
|
|
154
|
+
expect(values(result)).toEqual({ destination: 'a' });
|
|
155
|
+
expect(result?.consumedTokens).toBe(1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('skips an optional slot when the reader allows it and the rest reaches a clause end', () => {
|
|
159
|
+
const { skeleton } = matcher();
|
|
160
|
+
const result = skeleton.matchPattern(
|
|
161
|
+
stream('go now'),
|
|
162
|
+
pattern('p', [lit('go'), role('patient', true), lit('now')])
|
|
163
|
+
);
|
|
164
|
+
expect(values(result)).toEqual({});
|
|
165
|
+
expect(result?.consumedTokens).toBe(2);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('ranks matches by priority, then confidence', () => {
|
|
169
|
+
const byId = model(ctx => (ctx.pattern.id === 'high' ? 0.9 : 0.5));
|
|
170
|
+
const { skeleton } = matcher(byId);
|
|
171
|
+
const tokens = [lit('go'), role('x')];
|
|
172
|
+
const best = skeleton.matchBest(stream('go home'), [
|
|
173
|
+
pattern('low', tokens),
|
|
174
|
+
pattern('high', tokens),
|
|
175
|
+
pattern('priority', tokens, 50),
|
|
176
|
+
]);
|
|
177
|
+
expect(best?.pattern.id).toBe('high');
|
|
178
|
+
});
|
|
179
|
+
});
|