@smthrs/evals 0.0.0-stage → 1.0.0-rc.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -2
  4. package/dist/cjs/Baseline.d.ts +120 -0
  5. package/dist/cjs/Baseline.d.ts.map +1 -0
  6. package/dist/cjs/Baseline.js +160 -0
  7. package/dist/cjs/Baseline.js.map +7 -0
  8. package/dist/cjs/CaseExecutor.d.ts +85 -0
  9. package/dist/cjs/CaseExecutor.d.ts.map +1 -0
  10. package/dist/cjs/CaseExecutor.js +59 -0
  11. package/dist/cjs/CaseExecutor.js.map +7 -0
  12. package/dist/cjs/EvalError.d.ts +64 -0
  13. package/dist/cjs/EvalError.d.ts.map +1 -0
  14. package/dist/cjs/EvalError.js +54 -0
  15. package/dist/cjs/EvalError.js.map +7 -0
  16. package/dist/cjs/Gate.d.ts +55 -0
  17. package/dist/cjs/Gate.d.ts.map +1 -0
  18. package/dist/cjs/Gate.js +88 -0
  19. package/dist/cjs/Gate.js.map +7 -0
  20. package/dist/cjs/Regression.d.ts +116 -0
  21. package/dist/cjs/Regression.d.ts.map +1 -0
  22. package/dist/cjs/Regression.js +192 -0
  23. package/dist/cjs/Regression.js.map +7 -0
  24. package/dist/cjs/Report.d.ts +114 -0
  25. package/dist/cjs/Report.d.ts.map +1 -0
  26. package/dist/cjs/Report.js +161 -0
  27. package/dist/cjs/Report.js.map +7 -0
  28. package/dist/cjs/Runner.d.ts +229 -0
  29. package/dist/cjs/Runner.d.ts.map +1 -0
  30. package/dist/cjs/Runner.js +453 -0
  31. package/dist/cjs/Runner.js.map +7 -0
  32. package/dist/cjs/Suite.d.ts +139 -0
  33. package/dist/cjs/Suite.d.ts.map +1 -0
  34. package/dist/cjs/Suite.js +221 -0
  35. package/dist/cjs/Suite.js.map +7 -0
  36. package/dist/cjs/Trials.d.ts +93 -0
  37. package/dist/cjs/Trials.d.ts.map +1 -0
  38. package/dist/cjs/Trials.js +82 -0
  39. package/dist/cjs/Trials.js.map +7 -0
  40. package/dist/cjs/index.d.ts +76 -0
  41. package/dist/cjs/index.d.ts.map +1 -0
  42. package/dist/cjs/index.js +51 -0
  43. package/dist/cjs/index.js.map +7 -0
  44. package/dist/cjs/internal/CanonicalJson.d.ts +44 -0
  45. package/dist/cjs/internal/CanonicalJson.d.ts.map +1 -0
  46. package/dist/cjs/internal/CanonicalJson.js +114 -0
  47. package/dist/cjs/internal/CanonicalJson.js.map +7 -0
  48. package/dist/cjs/internal/controlCharacters.d.ts +44 -0
  49. package/dist/cjs/internal/controlCharacters.d.ts.map +1 -0
  50. package/dist/cjs/internal/controlCharacters.js +42 -0
  51. package/dist/cjs/internal/controlCharacters.js.map +7 -0
  52. package/dist/cjs/internal/scorerLabel.d.ts +33 -0
  53. package/dist/cjs/internal/scorerLabel.d.ts.map +1 -0
  54. package/dist/cjs/internal/scorerLabel.js +28 -0
  55. package/dist/cjs/internal/scorerLabel.js.map +7 -0
  56. package/dist/cjs/internal/tupleKey.d.ts +20 -0
  57. package/dist/cjs/internal/tupleKey.d.ts.map +1 -0
  58. package/dist/cjs/internal/tupleKey.js +25 -0
  59. package/dist/cjs/internal/tupleKey.js.map +7 -0
  60. package/dist/cjs/package.json +1 -0
  61. package/dist/esm/Baseline.d.ts +120 -0
  62. package/dist/esm/Baseline.d.ts.map +1 -0
  63. package/dist/esm/Baseline.js +208 -0
  64. package/dist/esm/Baseline.js.map +1 -0
  65. package/dist/esm/CaseExecutor.d.ts +85 -0
  66. package/dist/esm/CaseExecutor.d.ts.map +1 -0
  67. package/dist/esm/CaseExecutor.js +60 -0
  68. package/dist/esm/CaseExecutor.js.map +1 -0
  69. package/dist/esm/EvalError.d.ts +64 -0
  70. package/dist/esm/EvalError.d.ts.map +1 -0
  71. package/dist/esm/EvalError.js +65 -0
  72. package/dist/esm/EvalError.js.map +1 -0
  73. package/dist/esm/Gate.d.ts +55 -0
  74. package/dist/esm/Gate.d.ts.map +1 -0
  75. package/dist/esm/Gate.js +90 -0
  76. package/dist/esm/Gate.js.map +1 -0
  77. package/dist/esm/Regression.d.ts +116 -0
  78. package/dist/esm/Regression.d.ts.map +1 -0
  79. package/dist/esm/Regression.js +200 -0
  80. package/dist/esm/Regression.js.map +1 -0
  81. package/dist/esm/Report.d.ts +114 -0
  82. package/dist/esm/Report.d.ts.map +1 -0
  83. package/dist/esm/Report.js +188 -0
  84. package/dist/esm/Report.js.map +1 -0
  85. package/dist/esm/Runner.d.ts +229 -0
  86. package/dist/esm/Runner.d.ts.map +1 -0
  87. package/dist/esm/Runner.js +455 -0
  88. package/dist/esm/Runner.js.map +1 -0
  89. package/dist/esm/Suite.d.ts +139 -0
  90. package/dist/esm/Suite.d.ts.map +1 -0
  91. package/dist/esm/Suite.js +255 -0
  92. package/dist/esm/Suite.js.map +1 -0
  93. package/dist/esm/Trials.d.ts +93 -0
  94. package/dist/esm/Trials.d.ts.map +1 -0
  95. package/dist/esm/Trials.js +117 -0
  96. package/dist/esm/Trials.js.map +1 -0
  97. package/dist/esm/index.d.ts +76 -0
  98. package/dist/esm/index.d.ts.map +1 -0
  99. package/dist/esm/index.js +76 -0
  100. package/dist/esm/index.js.map +1 -0
  101. package/dist/esm/internal/CanonicalJson.d.ts +44 -0
  102. package/dist/esm/internal/CanonicalJson.d.ts.map +1 -0
  103. package/dist/esm/internal/CanonicalJson.js +202 -0
  104. package/dist/esm/internal/CanonicalJson.js.map +1 -0
  105. package/dist/esm/internal/controlCharacters.d.ts +44 -0
  106. package/dist/esm/internal/controlCharacters.d.ts.map +1 -0
  107. package/dist/esm/internal/controlCharacters.js +65 -0
  108. package/dist/esm/internal/controlCharacters.js.map +1 -0
  109. package/dist/esm/internal/scorerLabel.d.ts +33 -0
  110. package/dist/esm/internal/scorerLabel.d.ts.map +1 -0
  111. package/dist/esm/internal/scorerLabel.js +33 -0
  112. package/dist/esm/internal/scorerLabel.js.map +1 -0
  113. package/dist/esm/internal/tupleKey.d.ts +20 -0
  114. package/dist/esm/internal/tupleKey.d.ts.map +1 -0
  115. package/dist/esm/internal/tupleKey.js +20 -0
  116. package/dist/esm/internal/tupleKey.js.map +1 -0
  117. package/docs/README.md +128 -0
  118. package/docs/api.md +626 -0
  119. package/docs/concepts/determinism.md +64 -0
  120. package/docs/concepts/fixed-suites.md +71 -0
  121. package/docs/concepts/step-keys.md +60 -0
  122. package/docs/guides/author-a-suite.md +115 -0
  123. package/docs/guides/gate-a-run-in-ci.md +129 -0
  124. package/docs/guides/maintain-a-baseline.md +91 -0
  125. package/docs/guides/run-a-suite.md +150 -0
  126. package/docs/installation.md +32 -0
  127. package/docs/quickstart.md +238 -0
  128. package/docs/troubleshooting.md +153 -0
  129. package/package.json +198 -3
  130. package/src/Baseline.ts +266 -0
  131. package/src/CaseExecutor.ts +104 -0
  132. package/src/EvalError.ts +74 -0
  133. package/src/Gate.ts +128 -0
  134. package/src/Regression.ts +321 -0
  135. package/src/Report.ts +268 -0
  136. package/src/Runner.ts +789 -0
  137. package/src/Suite.ts +358 -0
  138. package/src/Trials.ts +155 -0
  139. package/src/index.ts +76 -0
  140. package/src/internal/CanonicalJson.ts +234 -0
  141. package/src/internal/controlCharacters.ts +66 -0
  142. package/src/internal/scorerLabel.ts +35 -0
  143. package/src/internal/tupleKey.ts +20 -0
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Step-key-aware evaluation regression comparison.
3
+ *
4
+ * The step key is what separates the two findings this module reports. A score
5
+ * that moved at a *changed* step key is a regression: the target produced
6
+ * different work and it graded worse. A score that moved at an *unchanged* step
7
+ * key is nondeterminism: the same work graded differently twice. Both are
8
+ * results, and a gate reads both as red.
9
+ *
10
+ * @since 0.1.0
11
+ */
12
+
13
+ import * as Effect from "effect/Effect"
14
+ import type { Baseline, BaselineRecord } from "./Baseline.ts"
15
+ import { EvalError } from "./EvalError.ts"
16
+ import { CanonicalJson } from "./internal/CanonicalJson.ts"
17
+ import { flattenControlCharacters } from "./internal/controlCharacters.ts"
18
+ import { tupleKey } from "./internal/tupleKey.ts"
19
+ import type { Observation, RunResult } from "./Runner.ts"
20
+
21
+ /**
22
+ * Tolerances used for score comparisons.
23
+ *
24
+ * A move is reported only when it exceeds both tolerances, so either one alone
25
+ * is enough to silence it. Both default to `0`, which reports every move.
26
+ *
27
+ * @category models
28
+ * @since 0.1.0
29
+ */
30
+ export interface Tolerances {
31
+ readonly absolute?: number | undefined
32
+ readonly relative?: number | undefined
33
+ }
34
+
35
+ /**
36
+ * A score drop at a changed step key.
37
+ *
38
+ * @category models
39
+ * @since 0.1.0
40
+ */
41
+ export interface Regression {
42
+ readonly case: string
43
+ readonly scorer: string
44
+ readonly baseline: BaselineRecord
45
+ readonly actual: Extract<Observation, { readonly kind: "score" }>
46
+ readonly drop: number
47
+ }
48
+
49
+ /**
50
+ * A changed score at the same step key, indicating nondeterminism.
51
+ *
52
+ * @category models
53
+ * @since 0.1.0
54
+ */
55
+ export interface Nondeterminism {
56
+ readonly case: string
57
+ readonly scorer: string
58
+ readonly baseline: BaselineRecord
59
+ readonly actual: Extract<Observation, { readonly kind: "score" }>
60
+ readonly delta: number
61
+ }
62
+
63
+ /**
64
+ * An observation present on only one side of a comparison.
65
+ *
66
+ * `side` names the side it is missing from: `"run"` for a baseline record the
67
+ * run never reproduced, `"baseline"` for a score the run produced that no
68
+ * baseline record accounts for. `scorerName` comes from the observation that
69
+ * is present, when that artifact carried one.
70
+ *
71
+ * @category models
72
+ * @since 0.1.0
73
+ */
74
+ export interface MissingObservation {
75
+ readonly side: "baseline" | "run"
76
+ readonly case: string
77
+ readonly scorer: string
78
+ readonly scorerName?: string | undefined
79
+ readonly stepKey: string
80
+ }
81
+
82
+ /**
83
+ * Complete regression comparison.
84
+ *
85
+ * @category models
86
+ * @since 0.1.0
87
+ */
88
+ export interface Report {
89
+ readonly suite: string
90
+ readonly baseline: Baseline
91
+ readonly run: RunResult
92
+ readonly regressions: ReadonlyArray<Regression>
93
+ readonly nondeterminism: ReadonlyArray<Nondeterminism>
94
+ readonly missing: ReadonlyArray<MissingObservation>
95
+ readonly samples: ReadonlyArray<Extract<Observation, { readonly kind: "score" }>>
96
+ readonly inconclusive: ReadonlyArray<Extract<Observation, { readonly kind: "inconclusive" }>>
97
+ }
98
+
99
+ type Score = Extract<Observation, { readonly kind: "score" }>
100
+
101
+ // Injective: two distinct (case, scorer) pairs can never encode to one key,
102
+ // which a delimiter join could not promise once a name may contain the
103
+ // delimiter.
104
+ const key = (caseName: string, scorer: string): string => tupleKey(caseName, scorer)
105
+ const validTolerance = (value: number): boolean => Number.isFinite(value) && value >= 0
106
+
107
+ // Decimal scores are not exactly representable, so 0.8 - 0.7 is
108
+ // 0.10000000000000009. A move counts only when it clears a positive tolerance
109
+ // by more than a few ulps; a zero tolerance still reports every move.
110
+ const exceeds = (move: number, tolerance: number): boolean =>
111
+ tolerance === 0 ? move > 0 : move - tolerance > 8 * Number.EPSILON * Math.max(1, tolerance)
112
+
113
+ const groupBy = <A>(items: ReadonlyArray<A>, of: (item: A) => string): Map<string, Array<A>> => {
114
+ const groups = new Map<string, Array<A>>()
115
+ for (const item of items) {
116
+ const bucket = groups.get(of(item))
117
+ if (bucket === undefined) groups.set(of(item), [item])
118
+ else bucket.push(item)
119
+ }
120
+ return groups
121
+ }
122
+
123
+ const byScore = <A extends { readonly score: number }>(items: ReadonlyArray<A>): Array<A> =>
124
+ [...items].sort((left, right) => left.score - right.score)
125
+
126
+ const byStepThenScore = <A extends { readonly stepKey: string; readonly score: number }>(
127
+ items: ReadonlyArray<A>
128
+ ): Array<A> =>
129
+ [...items].sort((left, right) => CanonicalJson.compareText(left.stepKey, right.stepKey) || left.score - right.score)
130
+
131
+ /** One matched pair, or one record that nothing on the other side accounts for. */
132
+ type Pair =
133
+ | { readonly kind: "matched"; readonly expected: BaselineRecord; readonly observed: Score }
134
+ | { readonly kind: "missing-run"; readonly expected: BaselineRecord }
135
+ | { readonly kind: "missing-baseline"; readonly observed: Score }
136
+
137
+ /**
138
+ * Pairs one case's baseline records with one case's observations.
139
+ *
140
+ * Same-step-key pairs are matched first, lowest score to lowest score, so a
141
+ * repeated scorer does not report a move that never happened. Whatever is left
142
+ * over is paired in stable order, and an unpaired record on either side is a
143
+ * missing observation rather than a silent drop.
144
+ */
145
+ const pairsFor = (
146
+ expected: ReadonlyArray<BaselineRecord>,
147
+ observed: ReadonlyArray<Score>
148
+ ): ReadonlyArray<Pair> => {
149
+ const pairs: Array<Pair> = []
150
+ const expectedByStep = groupBy(expected, (record) => record.stepKey)
151
+ const observedByStep = groupBy(observed, (record) => record.stepKey)
152
+ const leftoverExpected: Array<BaselineRecord> = []
153
+ const leftoverObserved: Array<Score> = []
154
+ for (const stepKey of [...expectedByStep.keys()].sort(CanonicalJson.compareText)) {
155
+ const atStep = byScore(expectedByStep.get(stepKey)!)
156
+ const observedAtStep = byScore(observedByStep.get(stepKey) ?? [])
157
+ const count = Math.min(atStep.length, observedAtStep.length)
158
+ for (let index = 0; index < count; index++) {
159
+ pairs.push({ kind: "matched", expected: atStep[index]!, observed: observedAtStep[index]! })
160
+ }
161
+ leftoverExpected.push(...atStep.slice(count))
162
+ leftoverObserved.push(...observedAtStep.slice(count))
163
+ }
164
+ for (const [stepKey, atStep] of observedByStep) {
165
+ if (!expectedByStep.has(stepKey)) leftoverObserved.push(...atStep)
166
+ }
167
+ const remainingExpected = byStepThenScore(leftoverExpected)
168
+ const remainingObserved = byStepThenScore(leftoverObserved)
169
+ const matched = Math.min(remainingExpected.length, remainingObserved.length)
170
+ for (let index = 0; index < matched; index++) {
171
+ pairs.push({ kind: "matched", expected: remainingExpected[index]!, observed: remainingObserved[index]! })
172
+ }
173
+ for (const record of remainingExpected.slice(matched)) pairs.push({ kind: "missing-run", expected: record })
174
+ for (const record of remainingObserved.slice(matched)) pairs.push({ kind: "missing-baseline", observed: record })
175
+ return pairs
176
+ }
177
+
178
+ /**
179
+ * Compares a run to a baseline, preserving missing and inconclusive observations.
180
+ *
181
+ * Records and observations are grouped by `(case, scorer)` and then paired by
182
+ * step key, so a scorer that ran several times against one case is compared
183
+ * pairwise instead of by array position. A pair whose step key is unchanged and
184
+ * whose score moved is nondeterminism; a pair whose step key changed and whose
185
+ * score dropped is a regression; an unpaired record on either side is a missing
186
+ * observation. Inconclusive observations are carried through untouched, because
187
+ * they decide nothing and a gate has to see them.
188
+ *
189
+ * Fails with `invalid_tolerance` when a tolerance is not finite and
190
+ * non-negative, and with `invalid_baseline` when the artifact or any of its
191
+ * records belongs to a suite other than the one the run reports.
192
+ *
193
+ * @category constructors
194
+ * @since 0.1.0
195
+ */
196
+ export const compare = (
197
+ baseline: Baseline,
198
+ run: RunResult,
199
+ tolerances: Tolerances = {}
200
+ ): Effect.Effect<Report, EvalError> => {
201
+ const absolute = tolerances.absolute ?? 0
202
+ const relative = tolerances.relative ?? 0
203
+ if (!validTolerance(absolute)) {
204
+ return Effect.fail(
205
+ new EvalError({
206
+ code: "invalid_tolerance",
207
+ message: `Regression tolerance 'absolute' must be finite and non-negative, got ${String(absolute)}`,
208
+ path: "tolerances.absolute"
209
+ })
210
+ )
211
+ }
212
+ if (!validTolerance(relative)) {
213
+ return Effect.fail(
214
+ new EvalError({
215
+ code: "invalid_tolerance",
216
+ message: `Regression tolerance 'relative' must be finite and non-negative, got ${String(relative)}`,
217
+ path: "tolerances.relative"
218
+ })
219
+ )
220
+ }
221
+ const baselineSuite = baseline.suite
222
+ if (baselineSuite !== run.suite) {
223
+ return Effect.fail(
224
+ new EvalError({
225
+ code: "invalid_baseline",
226
+ message: flattenControlCharacters(
227
+ `Baseline belongs to suite '${baselineSuite}', but the run is suite '${run.suite}'`
228
+ ),
229
+ path: "baseline.suite"
230
+ })
231
+ )
232
+ }
233
+ const foreign = [...new Set(baseline.records.filter((record) => record.suite !== run.suite).map((r) => r.suite))]
234
+ if (foreign.length > 0) {
235
+ return Effect.fail(
236
+ new EvalError({
237
+ code: "invalid_baseline",
238
+ message: flattenControlCharacters(
239
+ `Baseline holds records for suite ${
240
+ foreign.map((suite) => `'${suite}'`).join(", ")
241
+ }, but the run is suite '${run.suite}'`
242
+ ),
243
+ path: "baseline.records"
244
+ })
245
+ )
246
+ }
247
+ const actual = run.observations.filter((observation): observation is Score => observation.kind === "score")
248
+ const inconclusive = run.observations.filter((
249
+ observation
250
+ ): observation is Extract<Observation, { readonly kind: "inconclusive" }> => observation.kind === "inconclusive")
251
+ const baselineByKey = groupBy(baseline.records, (record) => key(record.case, record.scorer))
252
+ const actualByKey = groupBy(actual, (observation) => key(observation.case, observation.scorer))
253
+ const regressions: Array<Regression> = []
254
+ const nondeterminism: Array<Nondeterminism> = []
255
+ const missing: Array<MissingObservation> = []
256
+ for (const groupedKey of new Set([...baselineByKey.keys(), ...actualByKey.keys()])) {
257
+ const pairs = pairsFor(baselineByKey.get(groupedKey) ?? [], actualByKey.get(groupedKey) ?? [])
258
+ for (const pair of pairs) {
259
+ if (pair.kind === "missing-baseline") {
260
+ missing.push({
261
+ side: "baseline",
262
+ case: pair.observed.case,
263
+ scorer: pair.observed.scorer,
264
+ ...(pair.observed.scorerName === undefined ? {} : { scorerName: pair.observed.scorerName }),
265
+ stepKey: pair.observed.stepKey
266
+ })
267
+ continue
268
+ }
269
+ if (pair.kind === "missing-run") {
270
+ missing.push({
271
+ side: "run",
272
+ case: pair.expected.case,
273
+ scorer: pair.expected.scorer,
274
+ ...(pair.expected.scorerName === undefined ? {} : { scorerName: pair.expected.scorerName }),
275
+ stepKey: pair.expected.stepKey
276
+ })
277
+ continue
278
+ }
279
+ const baselineRecord = pair.expected
280
+ const observation = pair.observed
281
+ const delta = observation.score - baselineRecord.score
282
+ // Both branches divide by the same guarded magnitude. A baseline score of
283
+ // exactly 0 used to be special-cased in the regression branch, which was
284
+ // dead: a score is validated into [0, 1], so nothing can drop below 0.
285
+ if (observation.stepKey === baselineRecord.stepKey) {
286
+ if (
287
+ exceeds(Math.abs(delta), absolute) &&
288
+ exceeds(Math.abs(delta) / Math.max(Math.abs(baselineRecord.score), 1e-12), relative)
289
+ ) {
290
+ nondeterminism.push({
291
+ case: observation.case,
292
+ scorer: observation.scorer,
293
+ baseline: baselineRecord,
294
+ actual: observation,
295
+ delta
296
+ })
297
+ }
298
+ } else if (
299
+ delta < 0 && exceeds(-delta, absolute) && exceeds(-delta / Math.max(baselineRecord.score, 1e-12), relative)
300
+ ) {
301
+ regressions.push({
302
+ case: observation.case,
303
+ scorer: observation.scorer,
304
+ baseline: baselineRecord,
305
+ actual: observation,
306
+ drop: -delta
307
+ })
308
+ }
309
+ }
310
+ }
311
+ return Effect.succeed({
312
+ suite: run.suite,
313
+ baseline,
314
+ run,
315
+ regressions,
316
+ nondeterminism,
317
+ missing,
318
+ samples: actual,
319
+ inconclusive
320
+ })
321
+ }
package/src/Report.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Canonical JSON and Markdown regression reports.
3
+ *
4
+ * The JSON report is the machine-readable artifact; the Markdown report is what
5
+ * an operator reads in a CI log, so it names every case the run could not
6
+ * decide rather than only counting them.
7
+ *
8
+ * @since 0.1.0
9
+ */
10
+
11
+ import type { Baseline } from "./Baseline.ts"
12
+ import { CanonicalJson } from "./internal/CanonicalJson.ts"
13
+ import { isControlCodePoint } from "./internal/controlCharacters.ts"
14
+ import { scorerLabel } from "./internal/scorerLabel.ts"
15
+ import type { MissingObservation, Nondeterminism, Regression, Report as RegressionReport } from "./Regression.ts"
16
+ import type { CaseResult, Observation, RunResult } from "./Runner.ts"
17
+
18
+ /**
19
+ * Current serialized report format version.
20
+ *
21
+ * It versions {@link Data}, the wire shape, independently of the nested
22
+ * `Baseline.version`: the comparison can change shape without the committed
23
+ * baseline artifact changing at all.
24
+ *
25
+ * @category serialization
26
+ * @since 0.1.0
27
+ */
28
+ export const version = 1 as const
29
+
30
+ /** An index into {@link Data.run}'s `observations` table. */
31
+ type ObservationRef = number
32
+
33
+ /**
34
+ * The serialized shape of a regression report.
35
+ *
36
+ * `run.observations` is the observation table: every observation the report
37
+ * mentions appears in it exactly once, and everything else refers to one by
38
+ * index. `run.cases[i].observations` is a list of indexes, and a regression or
39
+ * nondeterminism entry's `actual` is a single index. An in-memory
40
+ * `Regression.Report` carries the same observation object in up to four
41
+ * places, so encoding it directly repeated every reason and every `meta` that
42
+ * many times.
43
+ *
44
+ * `samples` and `inconclusive` are absent by construction: both are filters of
45
+ * `run.observations` by `kind`, so a reader recomputes them rather than
46
+ * downloading a third copy.
47
+ *
48
+ * @category serialization
49
+ * @since 0.1.0
50
+ */
51
+ export interface Data {
52
+ readonly reportVersion: typeof version
53
+ readonly suite: string
54
+ readonly baseline: Baseline
55
+ readonly run:
56
+ & Omit<RunResult, "cases">
57
+ & {
58
+ readonly cases: ReadonlyArray<
59
+ & Omit<CaseResult, "observations">
60
+ & { readonly observations: ReadonlyArray<ObservationRef> }
61
+ >
62
+ }
63
+ readonly regressions: ReadonlyArray<Omit<Regression, "actual"> & { readonly actual: ObservationRef }>
64
+ readonly nondeterminism: ReadonlyArray<Omit<Nondeterminism, "actual"> & { readonly actual: ObservationRef }>
65
+ readonly missing: ReadonlyArray<MissingObservation>
66
+ }
67
+
68
+ /**
69
+ * Projects a comparison into its serialized form.
70
+ *
71
+ * The observation table starts as `run.observations` and grows to hold any
72
+ * observation reached only through a case or a comparison entry, so a
73
+ * hand-built report whose case lists are not flattened still serializes every
74
+ * reference. Observations are pooled by identity, not by value: two distinct
75
+ * objects that happen to be equal stay two rows, because collapsing them would
76
+ * claim one grading where there were two.
77
+ *
78
+ * @category serialization
79
+ * @since 0.1.0
80
+ */
81
+ export const data = (report: RegressionReport): Data => {
82
+ const positions = new Map<Observation, ObservationRef>()
83
+ const observations: Array<Observation> = []
84
+ const refer = (observation: Observation): ObservationRef => {
85
+ const known = positions.get(observation)
86
+ if (known !== undefined) return known
87
+ const position = observations.length
88
+ observations.push(observation)
89
+ positions.set(observation, position)
90
+ return position
91
+ }
92
+ for (const observation of report.run.observations) refer(observation)
93
+ const cases = report.run.cases.map((result) => ({ ...result, observations: result.observations.map(refer) }))
94
+ return {
95
+ reportVersion: version,
96
+ suite: report.suite,
97
+ baseline: report.baseline,
98
+ run: { ...report.run, cases, observations },
99
+ regressions: report.regressions.map((entry) => ({ ...entry, actual: refer(entry.actual) })),
100
+ nondeterminism: report.nondeterminism.map((entry) => ({ ...entry, actual: refer(entry.actual) })),
101
+ missing: report.missing
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Serializes a regression report as stable, sorted-key JSON.
107
+ *
108
+ * The wire shape is {@link Data}, stamped with {@link version}: each
109
+ * observation is encoded once and referred to by index everywhere else. The
110
+ * report embeds each case's raw `execution.output`, which comes from an
111
+ * arbitrary target flow, so the encoding is total rather than trusting: keys
112
+ * are sorted by code unit, embedded strings are capped, total traversal is
113
+ * bounded, and anything JSON cannot express becomes a named marker
114
+ * (`[circular]`, `[NaN]`, `[function]`, `[budget exceeded]`) instead of a
115
+ * `RangeError` or a silent `null`. Two identical runs therefore produce
116
+ * byte-identical JSON.
117
+ *
118
+ * Nothing redacts the embedded output. A suite whose cases carry secrets must
119
+ * not print this report where the log is readable.
120
+ *
121
+ * @category serialization
122
+ * @since 0.1.0
123
+ */
124
+ export const json = (report: RegressionReport): string =>
125
+ CanonicalJson.stringify(data(report), { maxStringLength: CanonicalJson.maxStringLength })
126
+
127
+ /** Maximum escaped UTF-16 code units per value, before a truncation ellipsis. */
128
+ const maxCellLength = 240
129
+
130
+ const cell = (value: unknown): string => {
131
+ let text = ""
132
+ if (value !== undefined && value !== null) {
133
+ if (typeof value === "string") text = value
134
+ else {
135
+ try {
136
+ text = String(value)
137
+ } catch {
138
+ text = "[unreadable]"
139
+ }
140
+ }
141
+ }
142
+ let escaped = ""
143
+ for (const character of text) {
144
+ const code = character.codePointAt(0)!
145
+ // Escape each input character once, including backslashes before pipes.
146
+ // Ampersands prevent entities; dots, colons, and @ prevent GFM autolinks.
147
+ const token = isControlCodePoint(code)
148
+ ? " "
149
+ : /[\\|`*_[\]<>!#~&.:@]/u.test(character)
150
+ ? `\\${character}`
151
+ : character
152
+ if (escaped.length + token.length > maxCellLength) return `${escaped}…`
153
+ escaped += token
154
+ }
155
+ return escaped
156
+ }
157
+
158
+ const scorerCell = (scorer: string, scorerName: string | undefined): string => cell(scorerLabel({ scorer, scorerName }))
159
+
160
+ const section = (
161
+ heading: string,
162
+ header: ReadonlyArray<string>,
163
+ alignment: ReadonlyArray<string>,
164
+ rows: ReadonlyArray<ReadonlyArray<string>>
165
+ ): ReadonlyArray<string> =>
166
+ rows.length === 0 ? [] : [
167
+ `## ${heading}`,
168
+ "",
169
+ `| ${header.join(" | ")} |`,
170
+ `| ${alignment.join(" | ")} |`,
171
+ ...rows.map((row) => `| ${row.join(" | ")} |`),
172
+ ""
173
+ ]
174
+
175
+ /**
176
+ * Renders a concise stable Markdown regression report.
177
+ *
178
+ * Every count in the summary that is not zero has a section naming its rows:
179
+ * the regressions and the nondeterminism a gate reads as red, and the case
180
+ * failures, missing observations, and inconclusive observations that leave a
181
+ * gate undecided. A report that only counted them named nothing an operator
182
+ * could act on, which is exactly the run that needs debugging.
183
+ *
184
+ * Every cell and the suite heading value replace C0 controls and DEL with
185
+ * spaces and escape backslashes, pipes, inline GFM syntax, raw HTML, entities,
186
+ * and URL/email autolinks. Values are capped at 240 escaped UTF-16 code units,
187
+ * plus an ellipsis when truncated, without splitting escapes or code points.
188
+ *
189
+ * @category rendering
190
+ * @since 0.1.0
191
+ */
192
+ export const markdown = (report: RegressionReport): string => {
193
+ const lines = [
194
+ `# Evaluation report: ${cell(report.suite)}`,
195
+ "",
196
+ `- Regressions: ${report.regressions.length}`,
197
+ `- Nondeterminism: ${report.nondeterminism.length}`,
198
+ `- Missing observations: ${report.missing.length}`,
199
+ `- Inconclusive observations: ${report.inconclusive.length}`,
200
+ `- Failed cases: ${report.run.cases.filter((result) => result.error !== undefined).length}`,
201
+ ...(report.run.trials === undefined ? [] : [
202
+ `- pass@1: ${report.run.trials.passAt1.toFixed(6)}`,
203
+ `- pass@${report.run.k ?? 1}: ${report.run.trials.passAtK.toFixed(6)}`,
204
+ `- pass^${report.run.k ?? 1}: ${report.run.trials.passHatK.toFixed(6)}`,
205
+ `- stderr: ${
206
+ (Object.values(report.run.trials.perCase).reduce((sum, item) => sum + item.stderr, 0) / report.run.trials.cases)
207
+ .toFixed(6)
208
+ }`
209
+ ]),
210
+ "",
211
+ ...section(
212
+ "Regressions",
213
+ ["Case", "Scorer", "Baseline", "Actual", "Drop"],
214
+ ["---", "---", "---:", "---:", "---:"],
215
+ report.regressions.map((item) => [
216
+ cell(item.case),
217
+ scorerCell(item.scorer, item.actual.scorerName),
218
+ item.baseline.score.toFixed(6),
219
+ item.actual.score.toFixed(6),
220
+ item.drop.toFixed(6)
221
+ ])
222
+ ),
223
+ ...section(
224
+ "Nondeterminism",
225
+ ["Case", "Scorer", "Step key", "Baseline", "Actual", "Delta"],
226
+ ["---", "---", "---", "---:", "---:", "---:"],
227
+ report.nondeterminism.map((item) => [
228
+ cell(item.case),
229
+ scorerCell(item.scorer, item.actual.scorerName),
230
+ cell(item.actual.stepKey),
231
+ item.baseline.score.toFixed(6),
232
+ item.actual.score.toFixed(6),
233
+ item.delta.toFixed(6)
234
+ ])
235
+ ),
236
+ ...section(
237
+ "Case failures",
238
+ ["Case", "Code", "Message"],
239
+ ["---", "---", "---"],
240
+ report.run.cases.flatMap((result) =>
241
+ result.error === undefined ? [] : [[cell(result.case), cell(result.error.code), cell(result.error.message)]]
242
+ )
243
+ ),
244
+ ...section(
245
+ "Missing observations",
246
+ ["Side", "Case", "Scorer", "Step key"],
247
+ ["---", "---", "---", "---"],
248
+ report.missing.map((item) => [
249
+ cell(item.side),
250
+ cell(item.case),
251
+ scorerCell(item.scorer, item.scorerName),
252
+ cell(item.stepKey)
253
+ ])
254
+ ),
255
+ ...section(
256
+ "Inconclusive",
257
+ ["Case", "Scorer", "Step key", "Reason"],
258
+ ["---", "---", "---", "---"],
259
+ report.inconclusive.map((item) => [
260
+ cell(item.case),
261
+ scorerCell(item.scorer, item.scorerName),
262
+ cell(item.stepKey),
263
+ cell(item.reason)
264
+ ])
265
+ )
266
+ ]
267
+ return `${lines.join("\n").trim()}\n`
268
+ }