@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.
- package/dist/assertions.d.mts +26 -1
- package/dist/assertions.d.ts +26 -1
- package/dist/index.d.mts +5 -112
- package/dist/index.d.ts +5 -112
- package/dist/runner.d.mts +112 -0
- package/dist/runner.d.ts +112 -0
- package/dist/runner.js +1102 -0
- package/dist/runner.js.map +1 -0
- package/dist/runner.mjs +1097 -0
- package/dist/runner.mjs.map +1 -0
- package/dist/{assertions-CsGP61iW.d.mts → types-D-rCVkf3.d.mts} +1 -24
- package/dist/{assertions-CsGP61iW.d.ts → types-D-rCVkf3.d.ts} +1 -24
- package/package.json +11 -26
- package/src/multilingual/canonical-validity.test.ts +69 -0
- package/src/multilingual/canonical-validity.ts +132 -0
- package/src/multilingual/cli.ts +185 -1
- package/src/multilingual/fidelity.test.ts +192 -0
- package/src/multilingual/fidelity.ts +153 -0
- package/src/multilingual/foreign-canonical-validity.test.ts +0 -0
- package/src/multilingual/foreign-canonical-validity.ts +158 -0
- package/src/multilingual/orchestrator.ts +47 -1
- package/src/multilingual/reporters/console-reporter.ts +51 -0
- package/src/multilingual/reporters/regression-reporter.ts +23 -0
- package/src/multilingual/tools/diagnose-coverage.ts +118 -0
- package/src/multilingual/tools/triage-r1.ts +149 -0
- package/src/multilingual/types.ts +74 -0
- package/src/multilingual/validators/parse-validator.ts +9 -1
- package/src/runner.test.ts +7 -2
- package/src/vocab/batch3-roundtrip.test.ts +184 -0
- package/src/vocab/checks.test.ts +362 -0
- package/src/vocab/checks.ts +311 -0
- package/src/vocab/cli.ts +196 -0
- package/src/vocab/dump.ts +80 -0
- package/src/vocab/model.ts +110 -0
- package/src/vocab/report.ts +120 -0
- package/src/vocab/types.ts +100 -0
package/src/multilingual/cli.ts
CHANGED
|
@@ -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 +
|
|
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
|
+
}
|
|
Binary file
|