@hyperfixi/testing-framework 2.5.0 → 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.
@@ -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 (parseRateDelta < -5 || newFailures.length > 0) {
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
  };