@hyperfixi/testing-framework 2.7.2 → 2.8.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 (36) hide show
  1. package/dist/assertions.d.mts +26 -1
  2. package/dist/assertions.d.ts +26 -1
  3. package/dist/index.d.mts +5 -112
  4. package/dist/index.d.ts +5 -112
  5. package/dist/runner.d.mts +112 -0
  6. package/dist/runner.d.ts +112 -0
  7. package/dist/runner.js +1102 -0
  8. package/dist/runner.js.map +1 -0
  9. package/dist/runner.mjs +1097 -0
  10. package/dist/runner.mjs.map +1 -0
  11. package/dist/{assertions-CsGP61iW.d.mts → types-D-rCVkf3.d.mts} +1 -24
  12. package/dist/{assertions-CsGP61iW.d.ts → types-D-rCVkf3.d.ts} +1 -24
  13. package/package.json +11 -26
  14. package/src/multilingual/canonical-validity.test.ts +69 -0
  15. package/src/multilingual/canonical-validity.ts +132 -0
  16. package/src/multilingual/cli.ts +185 -1
  17. package/src/multilingual/fidelity.test.ts +192 -0
  18. package/src/multilingual/fidelity.ts +153 -0
  19. package/src/multilingual/foreign-canonical-validity.test.ts +0 -0
  20. package/src/multilingual/foreign-canonical-validity.ts +158 -0
  21. package/src/multilingual/orchestrator.ts +47 -1
  22. package/src/multilingual/reporters/console-reporter.ts +51 -0
  23. package/src/multilingual/reporters/regression-reporter.ts +23 -0
  24. package/src/multilingual/tools/diagnose-coverage.ts +118 -0
  25. package/src/multilingual/tools/triage-r1.ts +149 -0
  26. package/src/multilingual/types.ts +74 -0
  27. package/src/multilingual/validators/parse-validator.ts +9 -1
  28. package/src/runner.test.ts +7 -2
  29. package/src/vocab/batch3-roundtrip.test.ts +184 -0
  30. package/src/vocab/checks.test.ts +362 -0
  31. package/src/vocab/checks.ts +311 -0
  32. package/src/vocab/cli.ts +196 -0
  33. package/src/vocab/dump.ts +80 -0
  34. package/src/vocab/model.ts +110 -0
  35. package/src/vocab/report.ts +120 -0
  36. package/src/vocab/types.ts +100 -0
@@ -10,6 +10,8 @@ import * as path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { checkDbStamp, getDefaultDbPath } from '@hyperfixi/patterns-reference';
12
12
  import { TestOrchestrator } from './orchestrator';
13
+ import { diagnoseCoverage } from './tools/diagnose-coverage';
14
+ import { triageR1 } from './tools/triage-r1';
13
15
  import type { TestConfig, LanguageCode } from './types';
14
16
 
15
17
  /**
@@ -172,6 +174,17 @@ function parseArgs(): TestConfig {
172
174
  config.saveBaseline = true;
173
175
  break;
174
176
 
177
+ case '--diagnose-coverage':
178
+ // Read-only measurement pass; short-circuits main() before any gate.
179
+ config.diagnoseCoverage = true;
180
+ break;
181
+
182
+ case '--triage-r1':
183
+ // Read-only measurement pass; short-circuits main() before any gate.
184
+ // Combine with --languages to scope (en is force-loaded as reference).
185
+ config.triageR1 = true;
186
+ break;
187
+
175
188
  default:
176
189
  if (arg && arg.startsWith('-')) {
177
190
  console.error(`Unknown option: ${arg}`);
@@ -227,6 +240,15 @@ OPTIONS:
227
240
  --categories <cats> Filter by categories (comma-separated)
228
241
  --limit <n> Patterns per language in quick mode (default: 10)
229
242
  --save-baseline Save current results as new baseline
243
+ --triage-r1 Itemize R1 role-fidelity misses per language
244
+ (missing action.role:type entries vs the en
245
+ reference, clustered; scope with --languages)
246
+ --diagnose-coverage Report how often the semantic parser matched a
247
+ pattern that ignored part of its input, per
248
+ language, with sample dropped spans. Read-only:
249
+ never gates and never writes a baseline. Run it
250
+ before considering an input-coverage penalty in
251
+ the confidence model.
230
252
  -h, --help Show this help message
231
253
 
232
254
  EXAMPLES:
@@ -257,6 +279,39 @@ async function main(): Promise<void> {
257
279
  // --save-baseline needs the regression reporter wired so it can persist.
258
280
  if (config.saveBaseline) config.regression = true;
259
281
 
282
+ // --diagnose-coverage is a measurement pass, not a gate: it reads the corpus,
283
+ // parses each row, and reports the `unconsumed-input` firing rate. It compares
284
+ // nothing against the baseline and writes nothing, so it runs before (and
285
+ // instead of) the regression machinery. It DOES execute dist/, so a stale
286
+ // build would mis-measure — warn rather than refuse, since no baseline is at
287
+ // risk.
288
+ if (config.diagnoseCoverage) {
289
+ const staleDists = findStaleDists();
290
+ if (staleDists.length > 0) {
291
+ console.warn(
292
+ `⚠ Stale dist/ in: ${staleDists.join(', ')} — these numbers describe the built\n` +
293
+ ' output, not your checkout. Rebuild first: npm run test:multilingual:build-deps\n'
294
+ );
295
+ }
296
+ await diagnoseCoverage(config);
297
+ process.exit(0);
298
+ }
299
+
300
+ // --triage-r1 is the same kind of measurement pass for R1 role fidelity:
301
+ // itemizes each language's missing `action.role:type` entries vs the en
302
+ // reference, clustered by entry (with the mistype pairing). Read-only.
303
+ if (config.triageR1) {
304
+ const staleDists = findStaleDists();
305
+ if (staleDists.length > 0) {
306
+ console.warn(
307
+ `⚠ Stale dist/ in: ${staleDists.join(', ')} — these numbers describe the built\n` +
308
+ ' output, not your checkout. Rebuild first: npm run test:multilingual:build-deps\n'
309
+ );
310
+ }
311
+ await triageR1(config);
312
+ process.exit(0);
313
+ }
314
+
260
315
  // DB freshness guard: refuse to run a regression/baseline compare against a
261
316
  // patterns.db generated from *different* source than is currently checked out
262
317
  // (the cross-branch "phantom regression" footgun). The committed baseline only
@@ -387,6 +442,19 @@ async function main(): Promise<void> {
387
442
  r => r.avgPrecisionDelta < -AVG_PRECISION_DROP_TOLERANCE
388
443
  );
389
444
 
445
+ // R0-recall-multiset ratchet: the mirror of the precision ratchet. Every
446
+ // signal above is computed on a deduped SET, so a parse that drops a
447
+ // REPEATED command scores a perfect 1.0 — reference `[bind, bind]`
448
+ // collapses to `{bind}`, which `[bind]` satisfies in full. That is exactly
449
+ // how `bind-two-way` recorded fidelity 1.0 in all 24 languages while every
450
+ // one of them parsed only the first of its two binds. Counting duplicates
451
+ // makes the drop visible. Deltas are 0 unless the baseline carries
452
+ // avgMultisetRecall, so an un-regenerated baseline never retro-flags.
453
+ const AVG_MULTISET_RECALL_DROP_TOLERANCE = 0.02;
454
+ const multisetRecallDrops = allResults.filter(
455
+ r => r.avgMultisetRecallDelta < -AVG_MULTISET_RECALL_DROP_TOLERANCE
456
+ );
457
+
390
458
  // R1 — role-fidelity ratchet (§8): same semantics as the avgFidelity
391
459
  // ratchet, on the role-recall signal (action.role:valueType vs the en
392
460
  // reference). Deltas are 0 unless the baseline carries avgRoleFidelity,
@@ -398,6 +466,20 @@ async function main(): Promise<void> {
398
466
  r => r.avgRoleFidelityDelta < -AVG_ROLE_FIDELITY_DROP_TOLERANCE
399
467
  );
400
468
 
469
+ // R3 — role-VALUE ratchet: same semantics as the role-fidelity ratchet,
470
+ // on the invariant-value signal (`action.role=value` multiset over the
471
+ // code-shaped subset: selectors, sigil refs, time literals,
472
+ // colon-qualified event names, URLs — compared VERBATIM vs the en
473
+ // reference). A drop means a translation started losing/corrupting an
474
+ // invariant value (`draggable` captured for `draggable:start`, the #633
475
+ // class) — invisible to every action/type-based signal above. Deltas
476
+ // are 0 unless the baseline carries avgValueRecall, so an
477
+ // un-regenerated baseline never retro-flags.
478
+ const AVG_VALUE_RECALL_DROP_TOLERANCE = 0.02;
479
+ const valueRecallDrops = allResults.filter(
480
+ r => r.avgValueRecallDelta < -AVG_VALUE_RECALL_DROP_TOLERANCE
481
+ );
482
+
401
483
  // R2 — execution ratchet (§8): curated-subset patterns whose jsdom DOM
402
484
  // effects matched the en reference in the baseline but diverge now.
403
485
  // Tolerance 0: execution is binary and the harness is deterministic
@@ -408,6 +490,55 @@ async function main(): Promise<void> {
408
490
  r.newExecutionFailures.map(id => `${r.language}/${id}`)
409
491
  );
410
492
 
493
+ // R4 — canonical-validity ratchet: render every authored foreign
494
+ // translation to English and parse the result on the real
495
+ // hyperscript.org engine, diffing the invalid (pattern, language)
496
+ // pairs against the committed allowlist
497
+ // (baselines/foreign-canonical-validity.json). Both directions fail:
498
+ // a NEW invalid pair is a validity regression; a stale entry (an
499
+ // allowlisted pair that now renders valid) must be pruned in the same
500
+ // change that cleared it (tools/regen-foreign-baseline.ts). Tolerance
501
+ // 0 — the render+parse is deterministic against a fresh DB, and the
502
+ // DB/dist freshness guards above already refused stale inputs. Reuses
503
+ // checkForeignRenderValidity + the allowlist VERBATIM, so this and the
504
+ // vitest gate (foreign-canonical-validity.test.ts) cannot disagree.
505
+ // Full-mode only (quick mode keeps its speed contract) and skipped
506
+ // with a warning when the allowlist is absent, so a checkout without
507
+ // the baseline never retro-flags.
508
+ let validityNewInvalid: string[] = [];
509
+ let validityStale: string[] = [];
510
+ let validityChecked = false;
511
+ if (config.mode !== 'quick') {
512
+ const validityBaselinePath = path.resolve(
513
+ path.dirname(fileURLToPath(import.meta.url)),
514
+ '../../baselines/foreign-canonical-validity.json'
515
+ );
516
+ if (!fs.existsSync(validityBaselinePath)) {
517
+ console.warn(`⚠ R4 validity ratchet skipped: no allowlist at ${validityBaselinePath}.`);
518
+ } else {
519
+ const { checkForeignRenderValidity, groupFailuresByPattern } =
520
+ await import('./foreign-canonical-validity');
521
+ const allowlist = JSON.parse(fs.readFileSync(validityBaselinePath, 'utf8')) as {
522
+ allowedInvalid: Record<string, string[]>;
523
+ };
524
+ const pairKey = (id: string, lang: string) => `${id}/${lang}`;
525
+ const allowed = new Set(
526
+ Object.entries(allowlist.allowedInvalid).flatMap(([id, langs]) =>
527
+ langs.map(l => pairKey(id, l))
528
+ )
529
+ );
530
+ const validity = await checkForeignRenderValidity();
531
+ const current = new Set(
532
+ Object.entries(groupFailuresByPattern(validity.failures)).flatMap(([id, langs]) =>
533
+ langs.map(l => pairKey(id, l))
534
+ )
535
+ );
536
+ validityNewInvalid = [...current].filter(p => !allowed.has(p)).sort();
537
+ validityStale = [...allowed].filter(p => !current.has(p)).sort();
538
+ validityChecked = true;
539
+ }
540
+ }
541
+
411
542
  let failed = false;
412
543
 
413
544
  if (regressed.length > 0) {
@@ -485,6 +616,21 @@ async function main(): Promise<void> {
485
616
  failed = true;
486
617
  }
487
618
 
619
+ if (multisetRecallDrops.length > 0) {
620
+ console.error(
621
+ `\n✗ avgMultisetRecall dropped > ${AVG_MULTISET_RECALL_DROP_TOLERANCE} in ` +
622
+ `${multisetRecallDrops.length} language(s) — a parse started dropping a ` +
623
+ `REPEATED command (invisible to the Set-based fidelity/roleFidelity):`
624
+ );
625
+ for (const r of multisetRecallDrops) {
626
+ console.error(
627
+ ` ${r.language}: ΔavgMultisetRecall ${r.avgMultisetRecallDelta.toFixed(4)}`
628
+ );
629
+ }
630
+ console.error(` (if intentional, regenerate the baseline with --save-baseline)`);
631
+ failed = true;
632
+ }
633
+
488
634
  if (roleFidelityDrops.length > 0) {
489
635
  console.error(
490
636
  `\n✗ avgRoleFidelity dropped > ${AVG_ROLE_FIDELITY_DROP_TOLERANCE} in ` +
@@ -499,6 +645,19 @@ async function main(): Promise<void> {
499
645
  failed = true;
500
646
  }
501
647
 
648
+ if (valueRecallDrops.length > 0) {
649
+ console.error(
650
+ `\n✗ avgValueRecall dropped > ${AVG_VALUE_RECALL_DROP_TOLERANCE} in ` +
651
+ `${valueRecallDrops.length} language(s) — a parse started losing/corrupting ` +
652
+ `a language-invariant role VALUE (invisible to the action/type-based signals):`
653
+ );
654
+ for (const r of valueRecallDrops) {
655
+ console.error(` ${r.language}: ΔavgValueRecall ${r.avgValueRecallDelta.toFixed(4)}`);
656
+ }
657
+ console.error(` (if intentional, regenerate the baseline with --save-baseline)`);
658
+ failed = true;
659
+ }
660
+
502
661
  if (executionRegressions.length > 0) {
503
662
  console.error(
504
663
  `\n✗ Execution regression vs baseline (R2): ${executionRegressions.length} ` +
@@ -512,12 +671,37 @@ async function main(): Promise<void> {
512
671
  failed = true;
513
672
  }
514
673
 
674
+ if (validityNewInvalid.length > 0) {
675
+ console.error(
676
+ `\n✗ Canonical-validity regression (R4): ${validityNewInvalid.length} foreign ` +
677
+ `translation(s) now render English the hyperscript.org parser rejects:`
678
+ );
679
+ for (const p of validityNewInvalid) console.error(` ${p}`);
680
+ console.error(
681
+ ` (triage with tools/triage-foreign-residual.ts; if the invalidity is ` +
682
+ `expected, allowlist it via tools/regen-foreign-baseline.ts)`
683
+ );
684
+ failed = true;
685
+ }
686
+
687
+ if (validityStale.length > 0) {
688
+ console.error(
689
+ `\n✗ Stale validity allowlist (R4): ${validityStale.length} allowlisted ` +
690
+ `pair(s) now render VALID — prune with tools/regen-foreign-baseline.ts:`
691
+ );
692
+ for (const p of validityStale) console.error(` ${p}`);
693
+ failed = true;
694
+ }
695
+
515
696
  if (failed) {
516
697
  exitCode = 1;
517
698
  } else {
518
699
  console.log(
519
700
  `\n✓ No regression vs baseline ` +
520
- `(parse-rate ${REGRESSION_TOLERANCE_PTS}pts, fidelity + correctness + execution ratchets).`
701
+ `(parse-rate ${REGRESSION_TOLERANCE_PTS}pts, fidelity + correctness + ` +
702
+ `precision + multiset-recall + role + value + execution ratchets` +
703
+ (validityChecked ? ` + canonical validity (R4)` : '') +
704
+ `).`
521
705
  );
522
706
  exitCode = 0;
523
707
  }
@@ -2,7 +2,9 @@ import { describe, it, expect } from 'vitest';
2
2
  import {
3
3
  collectActions,
4
4
  collectActionsMultiset,
5
+ collectRoleValueSignature,
5
6
  computeFidelity,
7
+ computeMultisetRecall,
6
8
  computePrecision,
7
9
  spuriousActions,
8
10
  FIDELITY_THRESHOLD,
@@ -119,6 +121,196 @@ describe('computePrecision', () => {
119
121
  });
120
122
  });
121
123
 
124
+ describe('computeMultisetRecall', () => {
125
+ it('catches the dropped duplicate that Set-based recall is blind to', () => {
126
+ // The `bind-two-way` shape. EN `bind $n to #a bind $n to #b` is [bind, bind];
127
+ // a parse that truncates to the first command yields [bind]. Every Set-based
128
+ // signal reads this as perfect — which is why the row recorded fidelity 1.0
129
+ // across all 24 languages while every one of them dropped half its body.
130
+ const en = ['bind', 'bind'];
131
+ const truncated = ['bind'];
132
+
133
+ expect(computeFidelity(collectSet(en), collectSet(truncated))).toBe(1); // recall is fooled
134
+ expect(computePrecision(en, truncated)).toBe(1); // precision is fooled too
135
+ expect(computeMultisetRecall(en, truncated)).toBe(0.5); // this is not
136
+ });
137
+
138
+ it('scores a faithful (reordered) parse 1.0', () => {
139
+ expect(computeMultisetRecall(['bind', 'bind'], ['bind', 'bind'])).toBe(1);
140
+ expect(computeMultisetRecall(['add', 'on', 'remove'], ['on', 'remove', 'add'])).toBe(1);
141
+ });
142
+
143
+ it('is not fooled by an added duplicate (that is precision’s job)', () => {
144
+ // A candidate with a phantom extra still has full recall; precision catches it.
145
+ expect(computeMultisetRecall(['bind'], ['bind', 'bind'])).toBe(1);
146
+ expect(computePrecision(['bind'], ['bind', 'bind'])).toBe(0.5);
147
+ });
148
+
149
+ it('returns undefined when there is no reference to score against', () => {
150
+ expect(computeMultisetRecall([], ['bind'])).toBeUndefined();
151
+ });
152
+ });
153
+
154
+ /** The deduped Set signature `collectActions` produces, from a multiset. */
155
+ function collectSet(actions: readonly string[]): string[] {
156
+ return [...new Set(actions)].sort();
157
+ }
158
+
159
+ describe('collectRoleValueSignature', () => {
160
+ it('emits every invariant-shaped value class, from a roles Map', () => {
161
+ const node = {
162
+ kind: 'event-handler',
163
+ action: 'on',
164
+ roles: new Map<string, unknown>([
165
+ ['event', { type: 'expression', raw: 'draggable:start' }], // colon-qualified (the #633 class)
166
+ ]),
167
+ body: [
168
+ {
169
+ kind: 'command',
170
+ action: 'toggle',
171
+ roles: new Map<string, unknown>([
172
+ ['patient', { type: 'selector', value: '.active', selectorKind: 'class' }],
173
+ ]),
174
+ },
175
+ {
176
+ kind: 'command',
177
+ action: 'set',
178
+ roles: new Map<string, unknown>([
179
+ ['destination', { type: 'expression', raw: ':x' }], // sigil ref
180
+ ]),
181
+ },
182
+ {
183
+ kind: 'command',
184
+ action: 'wait',
185
+ roles: new Map<string, unknown>([
186
+ ['duration', { type: 'literal', value: '200ms', dataType: 'duration' }],
187
+ ]),
188
+ },
189
+ {
190
+ kind: 'command',
191
+ action: 'fetch',
192
+ roles: new Map<string, unknown>([['source', { type: 'literal', value: '/api/data' }]]),
193
+ },
194
+ ],
195
+ };
196
+ expect(collectRoleValueSignature(node)).toEqual([
197
+ 'fetch.source=/api/data',
198
+ 'on.event=draggable:start',
199
+ 'set.destination=:x',
200
+ 'toggle.patient=.active',
201
+ 'wait.duration=200ms',
202
+ ]);
203
+ });
204
+
205
+ it('excludes non-invariant values: references, prose, bare words, mixed raws, flags, property-paths', () => {
206
+ const node = {
207
+ kind: 'command',
208
+ action: 'put',
209
+ roles: new Map<string, unknown>([
210
+ ['destination', { type: 'reference', value: 'me' }], // fillSchemaDefaults noise
211
+ ['patient', { type: 'literal', value: 'Hello world', dataType: 'string' }], // legitimately translated
212
+ ['source', { type: 'expression', raw: 'startX' }], // bare identifier — v1 exclusion
213
+ ['modifier', { type: 'expression', raw: '次 .item' }], // native words + code mixed
214
+ ['condition', { type: 'expression', raw: '#modal exists' }], // selector-prefixed prose (if-exists class)
215
+ ['url', { type: 'literal', value: '/api/search?q=${my' }], // truncated template interpolation
216
+ ['flagRole', { type: 'flag', name: 'async', enabled: true }],
217
+ [
218
+ 'pathRole',
219
+ { type: 'property-path', object: { type: 'reference', value: 'me' }, property: 'value' },
220
+ ],
221
+ ]),
222
+ };
223
+ expect(collectRoleValueSignature(node)).toEqual([]);
224
+ });
225
+
226
+ it('is a multiset — a dropped duplicate value is visible via computeMultisetRecall', () => {
227
+ // The bind-two-way shape: two `bind`s to two different sigil refs; a
228
+ // truncating parse keeps only the first. Both entries share NO key with a
229
+ // Set — but if both bound the SAME ref, dedup would hide the drop:
230
+ const twoBinds = {
231
+ action: 'compound',
232
+ statements: [
233
+ {
234
+ action: 'bind',
235
+ roles: new Map<string, unknown>([['source', { type: 'expression', raw: '$name' }]]),
236
+ },
237
+ {
238
+ action: 'bind',
239
+ roles: new Map<string, unknown>([['source', { type: 'expression', raw: '$name' }]]),
240
+ },
241
+ ],
242
+ };
243
+ const oneBind = {
244
+ action: 'bind',
245
+ roles: new Map<string, unknown>([['source', { type: 'expression', raw: '$name' }]]),
246
+ };
247
+ const ref = collectRoleValueSignature(twoBinds);
248
+ const cand = collectRoleValueSignature(oneBind);
249
+ expect(ref).toEqual(['bind.source=$name', 'bind.source=$name']);
250
+ expect(cand).toEqual(['bind.source=$name']);
251
+ expect(computeMultisetRecall(ref, cand)).toBe(0.5);
252
+ });
253
+
254
+ it('recurses into behavior-shaped nodes (eventHandlers + initBlock)', () => {
255
+ // No other walker test exercises these two CHILD_FIELDS; ad-hoc walkers
256
+ // that omit them see behavior bodies as empty.
257
+ const behavior = {
258
+ kind: 'behavior',
259
+ action: 'behavior',
260
+ roles: new Map<string, unknown>([['name', { type: 'expression', raw: 'Draggable' }]]),
261
+ eventHandlers: [
262
+ {
263
+ kind: 'event-handler',
264
+ action: 'on',
265
+ roles: new Map<string, unknown>([
266
+ ['event', { type: 'literal', value: 'draggable:start' }],
267
+ ]),
268
+ body: [
269
+ {
270
+ kind: 'command',
271
+ action: 'add',
272
+ roles: new Map<string, unknown>([
273
+ ['patient', { type: 'selector', value: '.dragging' }],
274
+ ]),
275
+ },
276
+ ],
277
+ },
278
+ ],
279
+ initBlock: [
280
+ {
281
+ kind: 'command',
282
+ action: 'set',
283
+ roles: new Map<string, unknown>([['destination', { type: 'expression', raw: '*width' }]]),
284
+ },
285
+ ],
286
+ };
287
+ expect(collectRoleValueSignature(behavior)).toEqual([
288
+ 'add.patient=.dragging',
289
+ 'on.event=draggable:start',
290
+ 'set.destination=*width',
291
+ ]);
292
+ });
293
+
294
+ it('reads plain-object roles (synthetic/JSON-shaped nodes) too', () => {
295
+ const node = {
296
+ action: 'toggle',
297
+ roles: { patient: { type: 'selector', value: '#count' } },
298
+ };
299
+ expect(collectRoleValueSignature(node)).toEqual(['toggle.patient=#count']);
300
+ });
301
+
302
+ it('skips the structural compound wrapper and handles non-object input', () => {
303
+ const node = {
304
+ action: 'compound',
305
+ roles: new Map<string, unknown>([['patient', { type: 'selector', value: '.x' }]]),
306
+ statements: [{ action: 'toggle', roles: { patient: { type: 'selector', value: '.x' } } }],
307
+ };
308
+ expect(collectRoleValueSignature(node)).toEqual(['toggle.patient=.x']);
309
+ expect(collectRoleValueSignature(null)).toEqual([]);
310
+ expect(collectRoleValueSignature(undefined)).toEqual([]);
311
+ });
312
+ });
313
+
122
314
  describe('spuriousActions', () => {
123
315
  it('lists the hallucinated commands a render/parse introduced', () => {
124
316
  expect(spuriousActions(['add', 'on', 'remove'], ['add', 'on', 'remove', 'toggle'])).toEqual([
@@ -121,6 +121,39 @@ export function computeFidelity(
121
121
  return hits / reference.length;
122
122
  }
123
123
 
124
+ /**
125
+ * R0-recall on the **multiset** in [0, 1]: the fraction of the reference's
126
+ * actions — counting duplicates — also present in the candidate.
127
+ *
128
+ * {@link computeFidelity} scores the deduped Set signature, so a candidate that
129
+ * drops a REPEATED command scores 1.0: reference `[bind, bind]` collapses to
130
+ * `{bind}`, which `[bind]` satisfies in full. That is how `bind-two-way` sat at
131
+ * fidelity 1.0 across all 24 languages while every one of them parsed only the
132
+ * first of its two `bind`s. R1 (role signatures) is a Set too, and is equally
133
+ * blind. {@link computePrecision} catches the mirror case — a candidate that ADDS
134
+ * a duplicate — so before this signal existed the ratchet saw spurious commands
135
+ * but never dropped ones.
136
+ *
137
+ * Pass multisets (see {@link collectActionsMultiset}) on both sides.
138
+ * Returns `undefined` when the reference has no actions to compare against.
139
+ */
140
+ export function computeMultisetRecall(
141
+ reference: readonly string[],
142
+ candidate: readonly string[]
143
+ ): number | undefined {
144
+ if (reference.length === 0) return undefined;
145
+ const cand = actionCounts(candidate);
146
+ let matched = 0;
147
+ for (const a of reference) {
148
+ const remaining = cand.get(a) ?? 0;
149
+ if (remaining > 0) {
150
+ matched++;
151
+ cand.set(a, remaining - 1);
152
+ }
153
+ }
154
+ return matched / reference.length;
155
+ }
156
+
124
157
  /**
125
158
  * Structural **precision** in [0, 1]: the fraction of the *candidate's* actions
126
159
  * that are justified by the reference (multiset-aware). The complement of
@@ -222,3 +255,123 @@ function walkRoles(node: unknown, acc: Set<string>, depth: number): void {
222
255
  }
223
256
  }
224
257
  }
258
+
259
+ /**
260
+ * Role-value kinds never emitted by the R3 walker. `reference` values (`me`,
261
+ * `it`, …) are mostly `fillSchemaDefaults` injections present identically on
262
+ * both sides — noise. `flag` names and `property-path` properties are bare
263
+ * identifiers, excluded by v1 (see {@link collectRoleValueSignature}).
264
+ */
265
+ const VALUE_KIND_EXCLUSIONS = new Set(['reference', 'flag', 'property-path']);
266
+
267
+ /**
268
+ * A role value is compared cross-language only when its WHOLE surface form is
269
+ * code-shaped — language-invariant by construction, never legitimately
270
+ * translated. Conservative v1 whitelist; when a sweep firing turns out to be a
271
+ * legit translation difference, tighten here and document the exclusion.
272
+ */
273
+ const INVARIANT_VALUE_PATTERNS: readonly RegExp[] = [
274
+ /^[#.[<@*]/, // selectors: #id .class [attr] <tag/> @attr *style
275
+ /^[:$^][A-Za-z_]\w*$/, // sigil refs: :local $global ^element
276
+ /^\d+(\.\d+)?(ms|s|m|h)?$/, // numbers and time literals: 2 1.5 200ms
277
+ /^[A-Za-z_][\w-]*:[\w-]+$/, // colon-qualified event names: draggable:start
278
+ /^(\.{0,2}\/|https?:)/, // URLs / paths: /api/data ./x ../y https://…
279
+ ];
280
+
281
+ function isInvariantSurface(surface: string): boolean {
282
+ // Whole-surface rule, sweep-validated exclusions:
283
+ // - whitespace ⇒ mixed content. `if #modal exists` captures its condition as
284
+ // `#modal exists` — starts selector-shaped, but `exists` is prose that every
285
+ // language legitimately translates (16-language false firing without this).
286
+ // - `${` ⇒ template interpolation. `/api/search?q=${my value}` is an
287
+ // expression, and tokenizers split it at different points per language, so
288
+ // the captured surface isn't comparable verbatim.
289
+ if (/\s/.test(surface) || surface.includes('${')) return false;
290
+ return INVARIANT_VALUE_PATTERNS.some(re => re.test(surface));
291
+ }
292
+
293
+ /**
294
+ * The comparable surface form of a role value, or `undefined` when the value
295
+ * carries none: `.value` for `literal`/`selector` (string/number/boolean
296
+ * coerced), `.raw` for `expression`. Kinds in {@link VALUE_KIND_EXCLUSIONS}
297
+ * and values without a string `type` discriminator yield `undefined`.
298
+ */
299
+ function roleValueSurface(value: unknown): string | undefined {
300
+ if (value === null || typeof value !== 'object') return undefined;
301
+ const rec = value as { type?: unknown; value?: unknown; raw?: unknown };
302
+ if (typeof rec.type !== 'string' || VALUE_KIND_EXCLUSIONS.has(rec.type)) return undefined;
303
+ if (rec.type === 'expression') {
304
+ return typeof rec.raw === 'string' ? rec.raw : undefined;
305
+ }
306
+ const v = rec.value;
307
+ return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
308
+ ? String(v)
309
+ : undefined;
310
+ }
311
+
312
+ /**
313
+ * R3 — role-VALUE signature (invariant values only, multiset).
314
+ *
315
+ * R0/R1 compare actions and role *types*; values are never compared because
316
+ * they are legitimately translated. That leaves a live defect class with zero
317
+ * signal: right action counts, right role types, wrong role VALUE — the #633
318
+ * class, where ms captured `trigger` events named `draggable` instead of
319
+ * `draggable:start` (correct multiset, correct `trigger.event:literal`
320
+ * signature, silently wrong runtime behavior), and 18 other languages carried
321
+ * the same corruption with no side-effect at all.
322
+ *
323
+ * The subset of values compared here is language-invariant by construction —
324
+ * code, not prose (see {@link INVARIANT_VALUE_PATTERNS}). Emits a **multiset**
325
+ * of `` `action.role=value` `` entries (duplicates preserved, sorted); score
326
+ * with {@link computeMultisetRecall} so a dropped duplicate value is visible.
327
+ *
328
+ * Deliberately EXCLUDED in v1: bare-word identifiers (`startX` — usually
329
+ * invariant, but property/variable names occasionally get localized in seeds),
330
+ * string literals (message strings are legitimately translated), expression
331
+ * raws mixing native words + code (`次 .item`), and `reference` values
332
+ * (`me`/`it` — mostly `fillSchemaDefaults` injections on both sides).
333
+ *
334
+ * Blind spot: recall fires when a *translation* loses/corrupts an invariant
335
+ * value. If the **en reference itself** corrupts a value, every language flags
336
+ * at once — a 24-language R3 firestorm on one pattern means "suspect the en
337
+ * parse first" (unlike R0, where en corruption moves nothing).
338
+ *
339
+ * The roles container is a ReadonlyMap on live nodes (serializes to {} in
340
+ * results.json), so collect at validation time, never from the JSON.
341
+ */
342
+ export function collectRoleValueSignature(node: unknown): string[] {
343
+ const acc: string[] = [];
344
+ walkRoleValues(node, acc, 0);
345
+ return acc.sort();
346
+ }
347
+
348
+ function walkRoleValues(node: unknown, acc: string[], depth: number): void {
349
+ if (depth > 64 || node === null || typeof node !== 'object') return;
350
+
351
+ const rec = node as Record<string, unknown>;
352
+ const action = rec.action;
353
+ if (typeof action === 'string' && !STRUCTURAL_ACTIONS.has(action)) {
354
+ const roles = rec.roles;
355
+ const entries: Array<[unknown, unknown]> =
356
+ roles instanceof Map
357
+ ? [...roles.entries()]
358
+ : roles && typeof roles === 'object'
359
+ ? Object.entries(roles)
360
+ : [];
361
+ for (const [role, value] of entries) {
362
+ if (value === undefined || value === null) continue;
363
+ const surface = roleValueSurface(value);
364
+ if (surface === undefined || !isInvariantSurface(surface)) continue;
365
+ acc.push(`${action}.${String(role)}=${surface}`);
366
+ }
367
+ }
368
+
369
+ for (const field of CHILD_FIELDS) {
370
+ const child = rec[field];
371
+ if (Array.isArray(child)) {
372
+ for (const c of child) walkRoleValues(c, acc, depth + 1);
373
+ } else if (child && typeof child === 'object') {
374
+ walkRoleValues(child, acc, depth + 1);
375
+ }
376
+ }
377
+ }