@hyperfixi/testing-framework 2.5.1 → 2.6.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-CthVynH6.d.mts → assertions-CsGP61iW.d.mts} +1 -1
- package/dist/{assertions-CthVynH6.d.ts → assertions-CsGP61iW.d.ts} +1 -1
- package/dist/assertions.d.mts +1 -1
- package/dist/assertions.d.ts +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/package.json +6 -5
- package/src/multilingual/bundle-builder.ts +34 -16
- package/src/multilingual/cli.ts +357 -37
- package/src/multilingual/fidelity.test.ts +136 -0
- package/src/multilingual/fidelity.ts +224 -0
- package/src/multilingual/html-pattern.test.ts +39 -0
- package/src/multilingual/html-pattern.ts +24 -0
- package/src/multilingual/orchestrator.ts +177 -7
- package/src/multilingual/pattern-loader.ts +11 -2
- package/src/multilingual/reporters/console-reporter.ts +101 -0
- package/src/multilingual/reporters/regression-reporter.test.ts +406 -0
- package/src/multilingual/reporters/regression-reporter.ts +136 -1
- package/src/multilingual/types.ts +146 -0
- package/src/multilingual/validators/execution-validator.test.ts +412 -0
- package/src/multilingual/validators/execution-validator.ts +510 -0
- package/src/multilingual/validators/parse-validator.ts +22 -0
|
@@ -157,7 +157,92 @@ export class ConsoleReporter implements Reporter {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
this.reportDegeneratePasses(results);
|
|
161
|
+
this.reportExecutionFailures(results);
|
|
162
|
+
|
|
163
|
+
this.log('');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* R2 — surface curated-subset patterns whose jsdom execution diverged from
|
|
168
|
+
* the en reference's DOM effects. These can score 1.0 on parse fidelity yet
|
|
169
|
+
* behave differently at runtime, so they're reported separately.
|
|
170
|
+
*/
|
|
171
|
+
private reportExecutionFailures(results: TestResults): void {
|
|
172
|
+
// pattern id -> languages where execution diverged
|
|
173
|
+
const byPattern = new Map<string, string[]>();
|
|
174
|
+
const scored = results.languageResults.filter(l => l.avgExecutionFidelity !== undefined);
|
|
175
|
+
for (const lang of scored) {
|
|
176
|
+
for (const id of lang.executionFailures ?? []) {
|
|
177
|
+
let langs = byPattern.get(id);
|
|
178
|
+
if (!langs) {
|
|
179
|
+
langs = [];
|
|
180
|
+
byPattern.set(id, langs);
|
|
181
|
+
}
|
|
182
|
+
langs.push(lang.language);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (scored.length === 0) return;
|
|
186
|
+
|
|
187
|
+
const avg = scored.reduce((sum, l) => sum + (l.avgExecutionFidelity ?? 0), 0) / scored.length;
|
|
188
|
+
this.log('');
|
|
189
|
+
this.log(
|
|
190
|
+
this.bright(`Execution (R2): avgExecutionFidelity ${avg.toFixed(4)} over curated subset`)
|
|
191
|
+
);
|
|
192
|
+
if (byPattern.size === 0) return;
|
|
193
|
+
|
|
194
|
+
const totalInstances = [...byPattern.values()].reduce((n, langs) => n + langs.length, 0);
|
|
195
|
+
this.log(
|
|
196
|
+
this.yellow(
|
|
197
|
+
`⚠ Execution divergence: ${totalInstances} instance(s) across ${byPattern.size} pattern(s)`
|
|
198
|
+
)
|
|
199
|
+
);
|
|
200
|
+
this.log(
|
|
201
|
+
this.dim(' (DOM effects differ from the en reference in jsdom — not parse failures)')
|
|
202
|
+
);
|
|
203
|
+
for (const [id, langs] of [...byPattern.entries()].sort((a, b) => b[1].length - a[1].length)) {
|
|
204
|
+
this.log(
|
|
205
|
+
` ${this.dim('-')} ${id} ${this.dim(`(${langs.length}: ${langs.sort().join(',')})`)}`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Surface *degenerate* passes — patterns that parse non-null but drop most of
|
|
212
|
+
* the English reference's command structure (structural fidelity below the
|
|
213
|
+
* threshold). These pass the parse-rate gate yet aren't faithful translations,
|
|
214
|
+
* so they're reported separately rather than silently counted as green.
|
|
215
|
+
*/
|
|
216
|
+
private reportDegeneratePasses(results: TestResults): void {
|
|
217
|
+
// pattern id -> languages where it's a degenerate pass
|
|
218
|
+
const byPattern = new Map<string, string[]>();
|
|
219
|
+
for (const lang of results.languageResults) {
|
|
220
|
+
for (const id of lang.degeneratePasses ?? []) {
|
|
221
|
+
let langs = byPattern.get(id);
|
|
222
|
+
if (!langs) {
|
|
223
|
+
langs = [];
|
|
224
|
+
byPattern.set(id, langs);
|
|
225
|
+
}
|
|
226
|
+
langs.push(lang.language);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (byPattern.size === 0) return;
|
|
230
|
+
|
|
231
|
+
const totalInstances = [...byPattern.values()].reduce((n, langs) => n + langs.length, 0);
|
|
160
232
|
this.log('');
|
|
233
|
+
this.log(
|
|
234
|
+
this.yellow(
|
|
235
|
+
`⚠ Degenerate passes: ${totalInstances} instance(s) across ${byPattern.size} pattern(s)`
|
|
236
|
+
)
|
|
237
|
+
);
|
|
238
|
+
this.log(
|
|
239
|
+
this.dim(' (parse non-null but lost >50% of the English command structure — not failures)')
|
|
240
|
+
);
|
|
241
|
+
for (const [id, langs] of [...byPattern.entries()].sort((a, b) => b[1].length - a[1].length)) {
|
|
242
|
+
this.log(
|
|
243
|
+
` ${this.dim('-')} ${id} ${this.dim(`(${langs.length}: ${langs.sort().join(',')})`)}`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
161
246
|
}
|
|
162
247
|
|
|
163
248
|
/**
|
|
@@ -211,6 +296,22 @@ export class ConsoleReporter implements Reporter {
|
|
|
211
296
|
this.log(` ${this.green('New Successes:')} ${result.newSuccesses.length}`);
|
|
212
297
|
}
|
|
213
298
|
|
|
299
|
+
// Fidelity regressions (faithful pass → degenerate pass)
|
|
300
|
+
if (result.newDegeneratePasses.length > 0) {
|
|
301
|
+
this.log(
|
|
302
|
+
` ${this.yellow('Fidelity Regressions:')} ${result.newDegeneratePasses.length}` +
|
|
303
|
+
` ${this.dim(`(${result.newDegeneratePasses.join(', ')})`)}`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// R2 — execution regressions (faithful execution → divergent DOM effects)
|
|
308
|
+
if (result.newExecutionFailures.length > 0) {
|
|
309
|
+
this.log(
|
|
310
|
+
` ${this.red('Execution Regressions:')} ${result.newExecutionFailures.length}` +
|
|
311
|
+
` ${this.dim(`(${result.newExecutionFailures.join(', ')})`)}`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
214
315
|
this.log('');
|
|
215
316
|
}
|
|
216
317
|
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { writeFileSync, readFileSync, rmSync, mkdtempSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { RegressionReporter } from './regression-reporter';
|
|
6
|
+
import type { TestResults, LanguageResults, ParseResult, Baseline } from '../types';
|
|
7
|
+
|
|
8
|
+
/** Minimal ParseResult for a successful pattern. */
|
|
9
|
+
function pass(id: string): ParseResult {
|
|
10
|
+
return {
|
|
11
|
+
pattern: {
|
|
12
|
+
codeExampleId: id,
|
|
13
|
+
language: 'ja',
|
|
14
|
+
hyperscript: '',
|
|
15
|
+
wordOrder: 'SOV',
|
|
16
|
+
confidence: 1,
|
|
17
|
+
verifiedParses: 0,
|
|
18
|
+
roleAlignmentScore: 0,
|
|
19
|
+
},
|
|
20
|
+
success: true,
|
|
21
|
+
duration: 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function lang(
|
|
26
|
+
parseResults: ParseResult[],
|
|
27
|
+
degeneratePasses: string[],
|
|
28
|
+
lossyPasses: string[] = [],
|
|
29
|
+
avgFidelity = 1
|
|
30
|
+
): LanguageResults {
|
|
31
|
+
const parseSuccess = parseResults.filter(r => r.success).length;
|
|
32
|
+
return {
|
|
33
|
+
language: 'ja',
|
|
34
|
+
bundle: 'browser-priority',
|
|
35
|
+
parseResults,
|
|
36
|
+
parseSuccess,
|
|
37
|
+
parseFailure: parseResults.length - parseSuccess,
|
|
38
|
+
parseRate: parseSuccess / parseResults.length,
|
|
39
|
+
avgConfidence: 1,
|
|
40
|
+
avgFidelity,
|
|
41
|
+
degeneratePasses,
|
|
42
|
+
lossyPasses,
|
|
43
|
+
duration: 0,
|
|
44
|
+
status: 'pass',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function results(language: LanguageResults): TestResults {
|
|
49
|
+
return {
|
|
50
|
+
timestamp: '',
|
|
51
|
+
commit: 'test',
|
|
52
|
+
config: {} as TestResults['config'],
|
|
53
|
+
languageResults: [language],
|
|
54
|
+
bundles: {},
|
|
55
|
+
summary: {
|
|
56
|
+
totalPatterns: language.parseResults.length,
|
|
57
|
+
totalSuccess: language.parseSuccess,
|
|
58
|
+
totalFailure: language.parseFailure,
|
|
59
|
+
totalDuration: 0,
|
|
60
|
+
overallStatus: 'pass',
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('RegressionReporter fidelity ratchet', () => {
|
|
66
|
+
let dir: string;
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function reporterWith(baseline: Baseline): RegressionReporter {
|
|
72
|
+
dir = mkdtempSync(join(tmpdir(), 'fidelity-'));
|
|
73
|
+
const path = join(dir, 'baseline.json');
|
|
74
|
+
writeFileSync(path, JSON.stringify(baseline));
|
|
75
|
+
return new RegressionReporter(path);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const baseline: Baseline = {
|
|
79
|
+
timestamp: '',
|
|
80
|
+
commit: 'base',
|
|
81
|
+
languages: {
|
|
82
|
+
ja: {
|
|
83
|
+
parseSuccess: 3,
|
|
84
|
+
parseFailure: 1,
|
|
85
|
+
parseRate: 0.75,
|
|
86
|
+
avgConfidence: 1,
|
|
87
|
+
avgFidelity: 0.9,
|
|
88
|
+
// `was-degenerate` already degenerate; `was-faithful` is a clean pass;
|
|
89
|
+
// `was-fail` failed in baseline.
|
|
90
|
+
degeneratePasses: ['was-degenerate'],
|
|
91
|
+
lossyPasses: ['was-lossy'],
|
|
92
|
+
bundleSize: undefined,
|
|
93
|
+
patterns: {
|
|
94
|
+
'was-faithful': { success: true, confidence: 1 },
|
|
95
|
+
'was-degenerate': { success: true, confidence: 1 },
|
|
96
|
+
'was-lossy': { success: true, confidence: 1 },
|
|
97
|
+
stable: { success: true, confidence: 1 },
|
|
98
|
+
'was-fail': { success: false, confidence: undefined },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
bundles: {},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
it('flags a faithful baseline pass that became degenerate', () => {
|
|
106
|
+
const reporter = reporterWith(baseline);
|
|
107
|
+
// `was-faithful` is now degenerate; `was-fail` now parses degenerately (improvement).
|
|
108
|
+
reporter.reportComplete(
|
|
109
|
+
results(
|
|
110
|
+
lang(
|
|
111
|
+
[pass('was-faithful'), pass('was-degenerate'), pass('stable'), pass('was-fail')],
|
|
112
|
+
['was-faithful', 'was-degenerate', 'was-fail']
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
const r = reporter.getRegressionResults().find(x => x.language === 'ja')!;
|
|
117
|
+
// Only the faithful→degenerate transition is a regression.
|
|
118
|
+
expect(r.newDegeneratePasses).toEqual(['was-faithful']);
|
|
119
|
+
// `was-degenerate` (already degenerate) and `was-fail` (was failing) are excluded.
|
|
120
|
+
expect(r.newDegeneratePasses).not.toContain('was-degenerate');
|
|
121
|
+
expect(r.newDegeneratePasses).not.toContain('was-fail');
|
|
122
|
+
expect(r.status).toBe('regressed');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('does not flag anything when fidelity is unchanged', () => {
|
|
126
|
+
const reporter = reporterWith(baseline);
|
|
127
|
+
reporter.reportComplete(
|
|
128
|
+
results(
|
|
129
|
+
lang([pass('was-faithful'), pass('was-degenerate'), pass('stable')], ['was-degenerate'])
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
const r = reporter.getRegressionResults().find(x => x.language === 'ja')!;
|
|
133
|
+
expect(r.newDegeneratePasses).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('never retro-flags when the baseline has no fidelity data', () => {
|
|
137
|
+
const noFidelity: Baseline = structuredClone(baseline);
|
|
138
|
+
delete noFidelity.languages.ja!.degeneratePasses;
|
|
139
|
+
const reporter = reporterWith(noFidelity);
|
|
140
|
+
reporter.reportComplete(
|
|
141
|
+
results(lang([pass('was-faithful'), pass('stable')], ['was-faithful', 'stable']))
|
|
142
|
+
);
|
|
143
|
+
const r = reporter.getRegressionResults().find(x => x.language === 'ja')!;
|
|
144
|
+
expect(r.newDegeneratePasses).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('RegressionReporter correctness (lossy) ratchet — R0', () => {
|
|
149
|
+
let dir: string;
|
|
150
|
+
afterEach(() => {
|
|
151
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
function reporterWith(baseline: Baseline): RegressionReporter {
|
|
155
|
+
dir = mkdtempSync(join(tmpdir(), 'lossy-'));
|
|
156
|
+
const path = join(dir, 'baseline.json');
|
|
157
|
+
writeFileSync(path, JSON.stringify(baseline));
|
|
158
|
+
return new RegressionReporter(path);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const baseline: Baseline = {
|
|
162
|
+
timestamp: '',
|
|
163
|
+
commit: 'base',
|
|
164
|
+
languages: {
|
|
165
|
+
ja: {
|
|
166
|
+
parseSuccess: 4,
|
|
167
|
+
parseFailure: 0,
|
|
168
|
+
parseRate: 1,
|
|
169
|
+
avgConfidence: 1,
|
|
170
|
+
avgFidelity: 0.9,
|
|
171
|
+
degeneratePasses: ['was-degenerate'],
|
|
172
|
+
lossyPasses: ['was-lossy'],
|
|
173
|
+
bundleSize: undefined,
|
|
174
|
+
patterns: {
|
|
175
|
+
'was-faithful': { success: true, confidence: 1 },
|
|
176
|
+
'was-lossy': { success: true, confidence: 1 },
|
|
177
|
+
'was-degenerate': { success: true, confidence: 1 },
|
|
178
|
+
stable: { success: true, confidence: 1 },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
bundles: {},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
it('flags a faithful baseline pass that became lossy (silent command-drop)', () => {
|
|
186
|
+
const reporter = reporterWith(baseline);
|
|
187
|
+
// `was-faithful` is now lossy; `was-lossy` stays lossy (not re-flagged).
|
|
188
|
+
reporter.reportComplete(
|
|
189
|
+
results(
|
|
190
|
+
lang(
|
|
191
|
+
[pass('was-faithful'), pass('was-lossy'), pass('stable')],
|
|
192
|
+
[],
|
|
193
|
+
['was-faithful', 'was-lossy'],
|
|
194
|
+
0.85
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
);
|
|
198
|
+
const r = reporter.getRegressionResults().find(x => x.language === 'ja')!;
|
|
199
|
+
expect(r.newLossyPasses).toEqual(['was-faithful']);
|
|
200
|
+
expect(r.newLossyPasses).not.toContain('was-lossy'); // already lossy in baseline
|
|
201
|
+
expect(r.avgFidelityDelta).toBeCloseTo(-0.05, 5);
|
|
202
|
+
expect(r.status).toBe('regressed');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('does not flag when lossy set is unchanged', () => {
|
|
206
|
+
const reporter = reporterWith(baseline);
|
|
207
|
+
reporter.reportComplete(
|
|
208
|
+
results(
|
|
209
|
+
lang([pass('was-faithful'), pass('was-lossy'), pass('stable')], [], ['was-lossy'], 0.9)
|
|
210
|
+
)
|
|
211
|
+
);
|
|
212
|
+
const r = reporter.getRegressionResults().find(x => x.language === 'ja')!;
|
|
213
|
+
expect(r.newLossyPasses).toEqual([]);
|
|
214
|
+
expect(r.avgFidelityDelta).toBeCloseTo(0, 5);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('treats faithful→lossy as improvement-neutral for an already-lossy or failing pattern', () => {
|
|
218
|
+
// A pattern lossy in baseline that becomes faithful is not a regression (and not
|
|
219
|
+
// listed); only faithful→lossy is flagged.
|
|
220
|
+
const reporter = reporterWith(baseline);
|
|
221
|
+
reporter.reportComplete(
|
|
222
|
+
results(lang([pass('was-faithful'), pass('was-lossy'), pass('stable')], [], [], 0.95))
|
|
223
|
+
);
|
|
224
|
+
const r = reporter.getRegressionResults().find(x => x.language === 'ja')!;
|
|
225
|
+
expect(r.newLossyPasses).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('never retro-flags when the baseline has no lossy data', () => {
|
|
229
|
+
const noLossy: Baseline = structuredClone(baseline);
|
|
230
|
+
delete noLossy.languages.ja!.lossyPasses;
|
|
231
|
+
const reporter = reporterWith(noLossy);
|
|
232
|
+
reporter.reportComplete(
|
|
233
|
+
results(lang([pass('was-faithful'), pass('stable')], [], ['was-faithful', 'stable'], 0.5))
|
|
234
|
+
);
|
|
235
|
+
const r = reporter.getRegressionResults().find(x => x.language === 'ja')!;
|
|
236
|
+
expect(r.newLossyPasses).toEqual([]);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('RegressionReporter execution ratchet — R2 (session 5)', () => {
|
|
241
|
+
// LOCK: the R2 execution ratchet flags ONLY pass→fail transitions on the
|
|
242
|
+
// curated execution subset, with the same never-retro-flag guard as the
|
|
243
|
+
// degenerate/lossy ratchets (an un-regenerated baseline carries no
|
|
244
|
+
// `executionFailures`, so nothing is flagged until one is saved). Trapped
|
|
245
|
+
// runtime errors are deliberately NOT part of the match signal — only the
|
|
246
|
+
// deterministic DOM-effect comparison is (see execution-validator.ts).
|
|
247
|
+
let dir: string;
|
|
248
|
+
afterEach(() => {
|
|
249
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
function reporterWith(baseline: Baseline): RegressionReporter {
|
|
253
|
+
dir = mkdtempSync(join(tmpdir(), 'execution-'));
|
|
254
|
+
const path = join(dir, 'baseline.json');
|
|
255
|
+
writeFileSync(path, JSON.stringify(baseline));
|
|
256
|
+
return new RegressionReporter(path);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function langWithExecution(
|
|
260
|
+
executionFailures: string[],
|
|
261
|
+
avgExecutionFidelity: number
|
|
262
|
+
): LanguageResults {
|
|
263
|
+
const l = lang([pass('stable')], []);
|
|
264
|
+
l.avgExecutionFidelity = avgExecutionFidelity;
|
|
265
|
+
l.executionFailures = executionFailures;
|
|
266
|
+
return l;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const baseline: Baseline = {
|
|
270
|
+
timestamp: '',
|
|
271
|
+
commit: 'base',
|
|
272
|
+
languages: {
|
|
273
|
+
ja: {
|
|
274
|
+
parseSuccess: 1,
|
|
275
|
+
parseFailure: 0,
|
|
276
|
+
parseRate: 1,
|
|
277
|
+
avgConfidence: 1,
|
|
278
|
+
avgFidelity: 1,
|
|
279
|
+
avgExecutionFidelity: 0.8,
|
|
280
|
+
executionFailures: ['was-failing'],
|
|
281
|
+
degeneratePasses: [],
|
|
282
|
+
lossyPasses: [],
|
|
283
|
+
bundleSize: undefined,
|
|
284
|
+
patterns: { stable: { success: true, confidence: 1 } },
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
bundles: {},
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
it('flags a faithfully-executing baseline pattern that now diverges', () => {
|
|
291
|
+
const reporter = reporterWith(baseline);
|
|
292
|
+
reporter.reportComplete(results(langWithExecution(['was-failing', 'was-passing'], 0.7)));
|
|
293
|
+
const r = reporter.getRegressionResults().find(x => x.language === 'ja')!;
|
|
294
|
+
expect(r.newExecutionFailures).toEqual(['was-passing']);
|
|
295
|
+
// Already failing in baseline = burn-down list, not a regression.
|
|
296
|
+
expect(r.newExecutionFailures).not.toContain('was-failing');
|
|
297
|
+
expect(r.avgExecutionFidelityDelta).toBeCloseTo(-0.1, 5);
|
|
298
|
+
expect(r.status).toBe('regressed');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('does not flag when the failure set is unchanged', () => {
|
|
302
|
+
const reporter = reporterWith(baseline);
|
|
303
|
+
reporter.reportComplete(results(langWithExecution(['was-failing'], 0.8)));
|
|
304
|
+
const r = reporter.getRegressionResults().find(x => x.language === 'ja')!;
|
|
305
|
+
expect(r.newExecutionFailures).toEqual([]);
|
|
306
|
+
expect(r.avgExecutionFidelityDelta).toBeCloseTo(0, 5);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('never retro-flags when the baseline has no execution data', () => {
|
|
310
|
+
const noExecution: Baseline = structuredClone(baseline);
|
|
311
|
+
delete noExecution.languages.ja!.executionFailures;
|
|
312
|
+
delete noExecution.languages.ja!.avgExecutionFidelity;
|
|
313
|
+
const reporter = reporterWith(noExecution);
|
|
314
|
+
reporter.reportComplete(results(langWithExecution(['anything', 'everything'], 0.1)));
|
|
315
|
+
const r = reporter.getRegressionResults().find(x => x.language === 'ja')!;
|
|
316
|
+
expect(r.newExecutionFailures).toEqual([]);
|
|
317
|
+
expect(r.avgExecutionFidelityDelta).toBe(0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('persists execution data when saving a baseline', () => {
|
|
321
|
+
const reporter = reporterWith(baseline);
|
|
322
|
+
const res = results(langWithExecution(['was-failing'], 0.8));
|
|
323
|
+
reporter.reportComplete(res);
|
|
324
|
+
reporter.saveAsBaseline(res);
|
|
325
|
+
const saved = JSON.parse(readFileSync(join(dir, 'baseline.json'), 'utf-8')) as Baseline;
|
|
326
|
+
expect(saved.languages.ja!.avgExecutionFidelity).toBeCloseTo(0.8, 5);
|
|
327
|
+
expect(saved.languages.ja!.executionFailures).toEqual(['was-failing']);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('RegressionReporter precision ratchet — R0-precision (trust floor)', () => {
|
|
332
|
+
// LOCK: avgPrecision is the phantom-command signal recall (avgFidelity) cannot
|
|
333
|
+
// see — the fraction of each parse's actions justified by the en reference. The
|
|
334
|
+
// reporter computes avgPrecisionDelta with the SAME both-sides guard as R1/R2 (an
|
|
335
|
+
// un-regenerated baseline carries no avgPrecision, so the delta is 0 and nothing
|
|
336
|
+
// retro-flags). The CLI turns a delta < -0.02 into a CI failure.
|
|
337
|
+
let dir: string;
|
|
338
|
+
afterEach(() => {
|
|
339
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
function reporterWith(baseline: Baseline): RegressionReporter {
|
|
343
|
+
dir = mkdtempSync(join(tmpdir(), 'precision-'));
|
|
344
|
+
const path = join(dir, 'baseline.json');
|
|
345
|
+
writeFileSync(path, JSON.stringify(baseline));
|
|
346
|
+
return new RegressionReporter(path);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function langWithPrecision(avgPrecision: number): LanguageResults {
|
|
350
|
+
const l = lang([pass('stable')], []);
|
|
351
|
+
l.avgPrecision = avgPrecision;
|
|
352
|
+
return l;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const baseline: Baseline = {
|
|
356
|
+
timestamp: '',
|
|
357
|
+
commit: 'base',
|
|
358
|
+
languages: {
|
|
359
|
+
ja: {
|
|
360
|
+
parseSuccess: 1,
|
|
361
|
+
parseFailure: 0,
|
|
362
|
+
parseRate: 1,
|
|
363
|
+
avgConfidence: 1,
|
|
364
|
+
avgFidelity: 1,
|
|
365
|
+
avgPrecision: 1,
|
|
366
|
+
degeneratePasses: [],
|
|
367
|
+
lossyPasses: [],
|
|
368
|
+
bundleSize: undefined,
|
|
369
|
+
patterns: { stable: { success: true, confidence: 1 } },
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
bundles: {},
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
it('reports a negative avgPrecisionDelta when precision drops (phantom commands injected)', () => {
|
|
376
|
+
const reporter = reporterWith(baseline);
|
|
377
|
+
reporter.reportComplete(results(langWithPrecision(0.9)));
|
|
378
|
+
const r = reporter.getRegressionResults().find(x => x.language === 'ja')!;
|
|
379
|
+
expect(r.avgPrecisionDelta).toBeCloseTo(-0.1, 5);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('reports ~0 delta when precision is unchanged', () => {
|
|
383
|
+
const reporter = reporterWith(baseline);
|
|
384
|
+
reporter.reportComplete(results(langWithPrecision(1)));
|
|
385
|
+
const r = reporter.getRegressionResults().find(x => x.language === 'ja')!;
|
|
386
|
+
expect(r.avgPrecisionDelta).toBeCloseTo(0, 5);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('never retro-flags when the baseline has no precision data', () => {
|
|
390
|
+
const noPrecision: Baseline = structuredClone(baseline);
|
|
391
|
+
delete noPrecision.languages.ja!.avgPrecision;
|
|
392
|
+
const reporter = reporterWith(noPrecision);
|
|
393
|
+
reporter.reportComplete(results(langWithPrecision(0.1)));
|
|
394
|
+
const r = reporter.getRegressionResults().find(x => x.language === 'ja')!;
|
|
395
|
+
expect(r.avgPrecisionDelta).toBe(0);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('persists avgPrecision when saving a baseline', () => {
|
|
399
|
+
const reporter = reporterWith(baseline);
|
|
400
|
+
const res = results(langWithPrecision(0.95));
|
|
401
|
+
reporter.reportComplete(res);
|
|
402
|
+
reporter.saveAsBaseline(res);
|
|
403
|
+
const saved = JSON.parse(readFileSync(join(dir, 'baseline.json'), 'utf-8')) as Baseline;
|
|
404
|
+
expect(saved.languages.ja!.avgPrecision).toBeCloseTo(0.95, 5);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
@@ -111,15 +111,47 @@ export class RegressionReporter implements Reporter {
|
|
|
111
111
|
|
|
112
112
|
const parseRateDelta = (langResult.parseRate - baselineLang.parseRate) * 100;
|
|
113
113
|
const avgConfidenceDelta = langResult.avgConfidence - baselineLang.avgConfidence;
|
|
114
|
+
const avgFidelityDelta = (langResult.avgFidelity ?? 0) - (baselineLang.avgFidelity ?? 0);
|
|
115
|
+
// R0-precision — both-sides guard: an un-regenerated baseline (no
|
|
116
|
+
// avgPrecision yet) must never retro-flag.
|
|
117
|
+
const avgPrecisionDelta =
|
|
118
|
+
langResult.avgPrecision !== undefined && baselineLang.avgPrecision !== undefined
|
|
119
|
+
? langResult.avgPrecision - baselineLang.avgPrecision
|
|
120
|
+
: 0;
|
|
121
|
+
// R1 — only meaningful when BOTH sides carry role data; an un-regenerated
|
|
122
|
+
// baseline (no avgRoleFidelity yet) must never retro-flag.
|
|
123
|
+
const avgRoleFidelityDelta =
|
|
124
|
+
langResult.avgRoleFidelity !== undefined && baselineLang.avgRoleFidelity !== undefined
|
|
125
|
+
? langResult.avgRoleFidelity - baselineLang.avgRoleFidelity
|
|
126
|
+
: 0;
|
|
127
|
+
// R2 — same both-sides guard: an un-regenerated baseline (no execution
|
|
128
|
+
// data yet) must never retro-flag.
|
|
129
|
+
const avgExecutionFidelityDelta =
|
|
130
|
+
langResult.avgExecutionFidelity !== undefined &&
|
|
131
|
+
baselineLang.avgExecutionFidelity !== undefined
|
|
132
|
+
? langResult.avgExecutionFidelity - baselineLang.avgExecutionFidelity
|
|
133
|
+
: 0;
|
|
114
134
|
const bundleSizeDelta = this.getBundleSizeDelta(langResult, baselineLang);
|
|
115
135
|
|
|
116
136
|
// Identify new failures and successes
|
|
117
137
|
const newFailures = this.findNewFailures(langResult, baselineLang);
|
|
118
138
|
const newSuccesses = this.findNewSuccesses(langResult, baselineLang);
|
|
139
|
+
// Fidelity ratchet: faithful pass in baseline → degenerate pass now.
|
|
140
|
+
const newDegeneratePasses = this.findNewDegeneratePasses(langResult, baselineLang);
|
|
141
|
+
// Correctness ratchet (R0): faithful pass in baseline → lossy pass now.
|
|
142
|
+
const newLossyPasses = this.findNewLossyPasses(langResult, baselineLang);
|
|
143
|
+
// Execution ratchet (R2): faithful execution in baseline → divergent now.
|
|
144
|
+
const newExecutionFailures = this.findNewExecutionFailures(langResult, baselineLang);
|
|
119
145
|
|
|
120
146
|
// Determine status
|
|
121
147
|
let status: 'improved' | 'regressed' | 'unchanged' = 'unchanged';
|
|
122
|
-
if (
|
|
148
|
+
if (
|
|
149
|
+
parseRateDelta < -5 ||
|
|
150
|
+
newFailures.length > 0 ||
|
|
151
|
+
newDegeneratePasses.length > 0 ||
|
|
152
|
+
newLossyPasses.length > 0 ||
|
|
153
|
+
newExecutionFailures.length > 0
|
|
154
|
+
) {
|
|
123
155
|
status = 'regressed';
|
|
124
156
|
} else if (parseRateDelta > 5 || newSuccesses.length > 0) {
|
|
125
157
|
status = 'improved';
|
|
@@ -129,9 +161,16 @@ export class RegressionReporter implements Reporter {
|
|
|
129
161
|
language: langResult.language,
|
|
130
162
|
parseRateDelta,
|
|
131
163
|
avgConfidenceDelta,
|
|
164
|
+
avgFidelityDelta,
|
|
165
|
+
avgPrecisionDelta,
|
|
166
|
+
avgRoleFidelityDelta,
|
|
167
|
+
avgExecutionFidelityDelta,
|
|
132
168
|
bundleSizeDelta: bundleSizeDelta !== undefined ? bundleSizeDelta : undefined,
|
|
133
169
|
newFailures,
|
|
134
170
|
newSuccesses,
|
|
171
|
+
newDegeneratePasses,
|
|
172
|
+
newLossyPasses,
|
|
173
|
+
newExecutionFailures,
|
|
135
174
|
status,
|
|
136
175
|
});
|
|
137
176
|
}
|
|
@@ -200,6 +239,87 @@ export class RegressionReporter implements Reporter {
|
|
|
200
239
|
return newSuccesses.slice(0, 10); // Limit to 10 for reporting
|
|
201
240
|
}
|
|
202
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Find patterns that *regressed in fidelity*: a faithful (non-degenerate) pass
|
|
244
|
+
* in the baseline that is now a degenerate pass (still parses, but lost most of
|
|
245
|
+
* the English command structure). This is the fidelity ratchet's signal.
|
|
246
|
+
*
|
|
247
|
+
* Returns [] when the baseline carries no fidelity data yet (`degeneratePasses`
|
|
248
|
+
* undefined) so adopting the signal never retro-flags the whole corpus. A
|
|
249
|
+
* pattern that went FAIL → degenerate-pass is an improvement (it now parses),
|
|
250
|
+
* not a regression, and is excluded because it wasn't a baseline pass.
|
|
251
|
+
*/
|
|
252
|
+
private findNewDegeneratePasses(
|
|
253
|
+
current: LanguageResults,
|
|
254
|
+
baseline: {
|
|
255
|
+
patterns: Record<string, { success: boolean; confidence: number | undefined }> | undefined;
|
|
256
|
+
degeneratePasses?: string[] | undefined;
|
|
257
|
+
}
|
|
258
|
+
): string[] {
|
|
259
|
+
if (!baseline.degeneratePasses || !baseline.patterns) return [];
|
|
260
|
+
const baselineDegenerate = new Set(baseline.degeneratePasses);
|
|
261
|
+
const currentDegenerate = new Set(current.degeneratePasses ?? []);
|
|
262
|
+
|
|
263
|
+
const regressed: string[] = [];
|
|
264
|
+
for (const id of currentDegenerate) {
|
|
265
|
+
const wasPass = baseline.patterns[id]?.success === true;
|
|
266
|
+
// Faithful baseline pass (passed, not already degenerate) that is now degenerate.
|
|
267
|
+
if (wasPass && !baselineDegenerate.has(id)) regressed.push(id);
|
|
268
|
+
}
|
|
269
|
+
return regressed.sort();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Correctness ratchet (R0): patterns that were a *faithful* pass (fidelity 1.0) in
|
|
274
|
+
* the baseline but are now a *lossy* pass (0.5 ≤ fid < 1.0 — still parses, clears the
|
|
275
|
+
* degenerate floor, but silently drops ≥1 command). This is the band the degenerate
|
|
276
|
+
* ratchet misses (it only catches drops below 0.5). "Faithful in baseline" = a pass
|
|
277
|
+
* that was neither degenerate nor lossy.
|
|
278
|
+
*
|
|
279
|
+
* Returns [] when the baseline carries no lossy data yet (`lossyPasses` undefined) so
|
|
280
|
+
* adopting the signal never retro-flags the whole corpus — same guard as the
|
|
281
|
+
* degenerate ratchet. A pattern already lossy in the baseline is not re-flagged.
|
|
282
|
+
*/
|
|
283
|
+
private findNewLossyPasses(
|
|
284
|
+
current: LanguageResults,
|
|
285
|
+
baseline: {
|
|
286
|
+
patterns: Record<string, { success: boolean; confidence: number | undefined }> | undefined;
|
|
287
|
+
degeneratePasses?: string[] | undefined;
|
|
288
|
+
lossyPasses?: string[] | undefined;
|
|
289
|
+
}
|
|
290
|
+
): string[] {
|
|
291
|
+
if (!baseline.lossyPasses || !baseline.patterns) return [];
|
|
292
|
+
const baselineDegenerate = new Set(baseline.degeneratePasses ?? []);
|
|
293
|
+
const baselineLossy = new Set(baseline.lossyPasses);
|
|
294
|
+
const currentLossy = new Set(current.lossyPasses ?? []);
|
|
295
|
+
|
|
296
|
+
const regressed: string[] = [];
|
|
297
|
+
for (const id of currentLossy) {
|
|
298
|
+
const wasPass = baseline.patterns[id]?.success === true;
|
|
299
|
+
// Faithful baseline pass = passed AND not degenerate AND not already lossy.
|
|
300
|
+
if (wasPass && !baselineDegenerate.has(id) && !baselineLossy.has(id)) regressed.push(id);
|
|
301
|
+
}
|
|
302
|
+
return regressed.sort();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Execution ratchet (R2): curated-subset patterns that executed faithfully in
|
|
307
|
+
* the baseline (scored, and not in its `executionFailures`) but diverge from
|
|
308
|
+
* the en reference now. Returns [] when the baseline carries no execution
|
|
309
|
+
* data yet (`executionFailures` undefined) so adopting the signal never
|
|
310
|
+
* retro-flags — same guard as the degenerate/lossy ratchets. A pattern
|
|
311
|
+
* already failing in the baseline is not re-flagged (that's the burn-down
|
|
312
|
+
* list, not a regression).
|
|
313
|
+
*/
|
|
314
|
+
private findNewExecutionFailures(
|
|
315
|
+
current: LanguageResults,
|
|
316
|
+
baseline: { executionFailures?: string[] | undefined }
|
|
317
|
+
): string[] {
|
|
318
|
+
if (!baseline.executionFailures) return [];
|
|
319
|
+
const baselineFailures = new Set(baseline.executionFailures);
|
|
320
|
+
return (current.executionFailures ?? []).filter(id => !baselineFailures.has(id)).sort();
|
|
321
|
+
}
|
|
322
|
+
|
|
203
323
|
/**
|
|
204
324
|
* Save current results as new baseline
|
|
205
325
|
*/
|
|
@@ -238,6 +358,21 @@ export class RegressionReporter implements Reporter {
|
|
|
238
358
|
parseFailure: langResult.parseFailure,
|
|
239
359
|
parseRate: langResult.parseRate,
|
|
240
360
|
avgConfidence: langResult.avgConfidence,
|
|
361
|
+
// Structural fidelity vs the English reference parse — tracks degenerate
|
|
362
|
+
// (non-null but shallow) passes that the parse rate alone can't surface.
|
|
363
|
+
avgFidelity: langResult.avgFidelity ?? undefined,
|
|
364
|
+
// R0-precision — fraction of each parse's actions justified by the en
|
|
365
|
+
// reference (phantom-command signal recall can't see). Recorded + ratcheted.
|
|
366
|
+
avgPrecision: langResult.avgPrecision ?? undefined,
|
|
367
|
+
// R1 — role fidelity (role name + value type vs the en reference).
|
|
368
|
+
// Recorded + ratcheted; burn-down is NOT part of the parsing-track goal.
|
|
369
|
+
avgRoleFidelity: langResult.avgRoleFidelity ?? undefined,
|
|
370
|
+
// R2 — execution fidelity over the curated subset (DOM effects vs the
|
|
371
|
+
// en reference in jsdom). Recorded + ratcheted; burn-down deferred.
|
|
372
|
+
avgExecutionFidelity: langResult.avgExecutionFidelity ?? undefined,
|
|
373
|
+
executionFailures: langResult.executionFailures ?? undefined,
|
|
374
|
+
degeneratePasses: langResult.degeneratePasses ?? undefined,
|
|
375
|
+
lossyPasses: langResult.lossyPasses ?? undefined,
|
|
241
376
|
bundleSize: langResult.bundleSize ?? undefined,
|
|
242
377
|
patterns,
|
|
243
378
|
};
|