@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +220 -8
  2. package/dist/api/create-dsl.d.ts +59 -1
  3. package/dist/api/create-dsl.d.ts.map +1 -1
  4. package/dist/api/domain-registry.d.ts.map +1 -1
  5. package/dist/api/index.d.ts +1 -0
  6. package/dist/api/index.d.ts.map +1 -1
  7. package/dist/api/index.js +1938 -724
  8. package/dist/api/index.js.map +1 -1
  9. package/dist/api/reading.d.ts +8 -0
  10. package/dist/api/reading.d.ts.map +1 -0
  11. package/dist/api/translation-loss.d.ts +84 -0
  12. package/dist/api/translation-loss.d.ts.map +1 -0
  13. package/dist/core/index.d.ts +1 -0
  14. package/dist/core/index.d.ts.map +1 -1
  15. package/dist/core/index.js +1047 -684
  16. package/dist/core/index.js.map +1 -1
  17. package/dist/core/matching/confidence-model.d.ts +54 -0
  18. package/dist/core/matching/confidence-model.d.ts.map +1 -0
  19. package/dist/core/matching/index.d.ts +12 -0
  20. package/dist/core/matching/index.d.ts.map +1 -0
  21. package/dist/core/matching/match-skeleton.d.ts +114 -0
  22. package/dist/core/matching/match-skeleton.d.ts.map +1 -0
  23. package/dist/core/matching/stock-confidence.d.ts +15 -0
  24. package/dist/core/matching/stock-confidence.d.ts.map +1 -0
  25. package/dist/core/matching/stock-reader.d.ts +56 -0
  26. package/dist/core/matching/stock-reader.d.ts.map +1 -0
  27. package/dist/core/matching/stock-reading.d.ts +44 -0
  28. package/dist/core/matching/stock-reading.d.ts.map +1 -0
  29. package/dist/core/matching/value-reader.d.ts +62 -0
  30. package/dist/core/matching/value-reader.d.ts.map +1 -0
  31. package/dist/core/pattern-matching/index.js +561 -660
  32. package/dist/core/pattern-matching/index.js.map +1 -1
  33. package/dist/core/pattern-matching/pattern-matcher.d.ts +0 -117
  34. package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
  35. package/dist/core/types.d.ts +48 -0
  36. package/dist/core/types.d.ts.map +1 -1
  37. package/dist/core/types.js.map +1 -1
  38. package/dist/generation/index.d.ts +2 -0
  39. package/dist/generation/index.d.ts.map +1 -1
  40. package/dist/generation/index.js +583 -1
  41. package/dist/generation/index.js.map +1 -1
  42. package/dist/generation/pattern-render.d.ts +72 -0
  43. package/dist/generation/pattern-render.d.ts.map +1 -0
  44. package/dist/generation/schema-patterns.d.ts +152 -0
  45. package/dist/generation/schema-patterns.d.ts.map +1 -0
  46. package/dist/index.cjs +2132 -890
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.js +2110 -887
  49. package/dist/index.js.map +1 -1
  50. package/dist/ir/index.js.map +1 -1
  51. package/dist/parsing/index.js +561 -660
  52. package/dist/parsing/index.js.map +1 -1
  53. package/dist/testing/gate/index.d.ts +8 -0
  54. package/dist/testing/gate/index.d.ts.map +1 -0
  55. package/dist/testing/gate/index.js +632 -0
  56. package/dist/testing/gate/index.js.map +1 -0
  57. package/dist/testing/gate/lint/format.d.ts +7 -0
  58. package/dist/testing/gate/lint/format.d.ts.map +1 -0
  59. package/dist/testing/gate/lint/index.d.ts +30 -0
  60. package/dist/testing/gate/lint/index.d.ts.map +1 -0
  61. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts +18 -0
  62. package/dist/testing/gate/lint/rules/extractor-coverage.d.ts.map +1 -0
  63. package/dist/testing/gate/lint/rules/index.d.ts +14 -0
  64. package/dist/testing/gate/lint/rules/index.d.ts.map +1 -0
  65. package/dist/testing/gate/lint/rules/keyword-classification.d.ts +19 -0
  66. package/dist/testing/gate/lint/rules/keyword-classification.d.ts.map +1 -0
  67. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts +13 -0
  68. package/dist/testing/gate/lint/rules/keyword-coverage.d.ts.map +1 -0
  69. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts +20 -0
  70. package/dist/testing/gate/lint/rules/marker-tokenization.d.ts.map +1 -0
  71. package/dist/testing/gate/lint/rules/position-ordering.d.ts +25 -0
  72. package/dist/testing/gate/lint/rules/position-ordering.d.ts.map +1 -0
  73. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts +24 -0
  74. package/dist/testing/gate/lint/rules/renderer-coherence.d.ts.map +1 -0
  75. package/dist/testing/gate/lint/rules/schema-rules.d.ts +10 -0
  76. package/dist/testing/gate/lint/rules/schema-rules.d.ts.map +1 -0
  77. package/dist/testing/gate/lint/types.d.ts +74 -0
  78. package/dist/testing/gate/lint/types.d.ts.map +1 -0
  79. package/dist/testing/gate/round-trip.d.ts +163 -0
  80. package/dist/testing/gate/round-trip.d.ts.map +1 -0
  81. package/package.json +7 -3
  82. package/src/__test__/fixtures/sql-dsl.ts +11 -2
  83. package/src/__test__/sql-integration.test.ts +2 -2
  84. package/src/api/create-dsl-engine.test.ts +86 -0
  85. package/src/api/create-dsl.test.ts +120 -0
  86. package/src/api/create-dsl.ts +314 -39
  87. package/src/api/domain-registry.test.ts +70 -5
  88. package/src/api/domain-registry.ts +82 -1
  89. package/src/api/index.ts +1 -0
  90. package/src/api/reading.ts +29 -0
  91. package/src/api/translation-loss.test.ts +89 -0
  92. package/src/api/translation-loss.ts +128 -0
  93. package/src/core/index.ts +1 -0
  94. package/src/core/matching/confidence-model.ts +58 -0
  95. package/src/core/matching/index.ts +12 -0
  96. package/src/core/matching/match-skeleton.test.ts +179 -0
  97. package/src/core/matching/match-skeleton.ts +621 -0
  98. package/src/core/matching/stock-confidence.ts +238 -0
  99. package/src/core/matching/stock-reader.test.ts +181 -0
  100. package/src/core/matching/stock-reader.ts +186 -0
  101. package/src/core/matching/stock-reading.ts +681 -0
  102. package/src/core/matching/value-reader.ts +85 -0
  103. package/src/core/pattern-matching/pattern-matcher.ts +11 -876
  104. package/src/core/types.ts +48 -0
  105. package/src/generation/index.ts +2 -0
  106. package/src/generation/pattern-render.ts +378 -0
  107. package/src/generation/schema-patterns.test.ts +99 -0
  108. package/src/generation/schema-patterns.ts +962 -0
  109. package/src/testing/gate/index.ts +8 -0
  110. package/src/testing/gate/lint/format.ts +23 -0
  111. package/src/testing/gate/lint/index.ts +73 -0
  112. package/src/testing/gate/lint/lint.test.ts +131 -0
  113. package/src/testing/gate/lint/rules/extractor-coverage.ts +63 -0
  114. package/src/testing/gate/lint/rules/index.ts +37 -0
  115. package/src/testing/gate/lint/rules/keyword-classification.ts +52 -0
  116. package/src/testing/gate/lint/rules/keyword-coverage.ts +38 -0
  117. package/src/testing/gate/lint/rules/marker-tokenization.ts +129 -0
  118. package/src/testing/gate/lint/rules/position-ordering.ts +87 -0
  119. package/src/testing/gate/lint/rules/renderer-coherence.ts +100 -0
  120. package/src/testing/gate/lint/rules/schema-rules.ts +35 -0
  121. package/src/testing/gate/lint/types.ts +83 -0
  122. package/src/testing/gate/round-trip.test.ts +118 -0
  123. 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
@@ -5,3 +5,4 @@
5
5
  export * from './create-dsl';
6
6
  export * from './domain-registry';
7
7
  export * from './dispatcher';
8
+ export * from './translation-loss';
@@ -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
@@ -5,3 +5,4 @@
5
5
  export * from './types';
6
6
  export * from './tokenization';
7
7
  export * from './pattern-matching';
8
+ export * from './matching';
@@ -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
+ });