@smthrs/evals 0.0.0-stage → 1.0.0-rc.4
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/CHANGELOG.md +142 -0
- package/LICENSE +21 -0
- package/README.md +62 -2
- package/dist/cjs/Baseline.d.ts +118 -0
- package/dist/cjs/Baseline.d.ts.map +1 -0
- package/dist/cjs/Baseline.js +158 -0
- package/dist/cjs/Baseline.js.map +7 -0
- package/dist/cjs/CaseExecutor.d.ts +85 -0
- package/dist/cjs/CaseExecutor.d.ts.map +1 -0
- package/dist/cjs/CaseExecutor.js +59 -0
- package/dist/cjs/CaseExecutor.js.map +7 -0
- package/dist/cjs/EvalError.d.ts +64 -0
- package/dist/cjs/EvalError.d.ts.map +1 -0
- package/dist/cjs/EvalError.js +54 -0
- package/dist/cjs/EvalError.js.map +7 -0
- package/dist/cjs/Gate.d.ts +55 -0
- package/dist/cjs/Gate.d.ts.map +1 -0
- package/dist/cjs/Gate.js +88 -0
- package/dist/cjs/Gate.js.map +7 -0
- package/dist/cjs/Regression.d.ts +116 -0
- package/dist/cjs/Regression.d.ts.map +1 -0
- package/dist/cjs/Regression.js +191 -0
- package/dist/cjs/Regression.js.map +7 -0
- package/dist/cjs/Report.d.ts +114 -0
- package/dist/cjs/Report.d.ts.map +1 -0
- package/dist/cjs/Report.js +161 -0
- package/dist/cjs/Report.js.map +7 -0
- package/dist/cjs/Runner.d.ts +229 -0
- package/dist/cjs/Runner.d.ts.map +1 -0
- package/dist/cjs/Runner.js +430 -0
- package/dist/cjs/Runner.js.map +7 -0
- package/dist/cjs/Suite.d.ts +139 -0
- package/dist/cjs/Suite.d.ts.map +1 -0
- package/dist/cjs/Suite.js +221 -0
- package/dist/cjs/Suite.js.map +7 -0
- package/dist/cjs/Trials.d.ts +93 -0
- package/dist/cjs/Trials.d.ts.map +1 -0
- package/dist/cjs/Trials.js +82 -0
- package/dist/cjs/Trials.js.map +7 -0
- package/dist/cjs/index.d.ts +76 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +51 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/CanonicalJson.d.ts +44 -0
- package/dist/cjs/internal/CanonicalJson.d.ts.map +1 -0
- package/dist/cjs/internal/CanonicalJson.js +114 -0
- package/dist/cjs/internal/CanonicalJson.js.map +7 -0
- package/dist/cjs/internal/controlCharacters.d.ts +44 -0
- package/dist/cjs/internal/controlCharacters.d.ts.map +1 -0
- package/dist/cjs/internal/controlCharacters.js +42 -0
- package/dist/cjs/internal/controlCharacters.js.map +7 -0
- package/dist/cjs/internal/scorerLabel.d.ts +33 -0
- package/dist/cjs/internal/scorerLabel.d.ts.map +1 -0
- package/dist/cjs/internal/scorerLabel.js +28 -0
- package/dist/cjs/internal/scorerLabel.js.map +7 -0
- package/dist/cjs/internal/tupleKey.d.ts +20 -0
- package/dist/cjs/internal/tupleKey.d.ts.map +1 -0
- package/dist/cjs/internal/tupleKey.js +25 -0
- package/dist/cjs/internal/tupleKey.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/Baseline.d.ts +118 -0
- package/dist/esm/Baseline.d.ts.map +1 -0
- package/dist/esm/Baseline.js +203 -0
- package/dist/esm/Baseline.js.map +1 -0
- package/dist/esm/CaseExecutor.d.ts +85 -0
- package/dist/esm/CaseExecutor.d.ts.map +1 -0
- package/dist/esm/CaseExecutor.js +60 -0
- package/dist/esm/CaseExecutor.js.map +1 -0
- package/dist/esm/EvalError.d.ts +64 -0
- package/dist/esm/EvalError.d.ts.map +1 -0
- package/dist/esm/EvalError.js +65 -0
- package/dist/esm/EvalError.js.map +1 -0
- package/dist/esm/Gate.d.ts +55 -0
- package/dist/esm/Gate.d.ts.map +1 -0
- package/dist/esm/Gate.js +90 -0
- package/dist/esm/Gate.js.map +1 -0
- package/dist/esm/Regression.d.ts +116 -0
- package/dist/esm/Regression.d.ts.map +1 -0
- package/dist/esm/Regression.js +195 -0
- package/dist/esm/Regression.js.map +1 -0
- package/dist/esm/Report.d.ts +114 -0
- package/dist/esm/Report.d.ts.map +1 -0
- package/dist/esm/Report.js +188 -0
- package/dist/esm/Report.js.map +1 -0
- package/dist/esm/Runner.d.ts +229 -0
- package/dist/esm/Runner.d.ts.map +1 -0
- package/dist/esm/Runner.js +430 -0
- package/dist/esm/Runner.js.map +1 -0
- package/dist/esm/Suite.d.ts +139 -0
- package/dist/esm/Suite.d.ts.map +1 -0
- package/dist/esm/Suite.js +255 -0
- package/dist/esm/Suite.js.map +1 -0
- package/dist/esm/Trials.d.ts +93 -0
- package/dist/esm/Trials.d.ts.map +1 -0
- package/dist/esm/Trials.js +116 -0
- package/dist/esm/Trials.js.map +1 -0
- package/dist/esm/index.d.ts +76 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +76 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/CanonicalJson.d.ts +44 -0
- package/dist/esm/internal/CanonicalJson.d.ts.map +1 -0
- package/dist/esm/internal/CanonicalJson.js +202 -0
- package/dist/esm/internal/CanonicalJson.js.map +1 -0
- package/dist/esm/internal/controlCharacters.d.ts +44 -0
- package/dist/esm/internal/controlCharacters.d.ts.map +1 -0
- package/dist/esm/internal/controlCharacters.js +65 -0
- package/dist/esm/internal/controlCharacters.js.map +1 -0
- package/dist/esm/internal/scorerLabel.d.ts +33 -0
- package/dist/esm/internal/scorerLabel.d.ts.map +1 -0
- package/dist/esm/internal/scorerLabel.js +33 -0
- package/dist/esm/internal/scorerLabel.js.map +1 -0
- package/dist/esm/internal/tupleKey.d.ts +20 -0
- package/dist/esm/internal/tupleKey.d.ts.map +1 -0
- package/dist/esm/internal/tupleKey.js +20 -0
- package/dist/esm/internal/tupleKey.js.map +1 -0
- package/docs/README.md +130 -0
- package/docs/api.md +626 -0
- package/docs/concepts/determinism.md +64 -0
- package/docs/concepts/fixed-suites.md +71 -0
- package/docs/concepts/step-keys.md +60 -0
- package/docs/guides/author-a-suite.md +115 -0
- package/docs/guides/gate-a-run-in-ci.md +129 -0
- package/docs/guides/maintain-a-baseline.md +91 -0
- package/docs/guides/run-a-suite.md +150 -0
- package/docs/installation.md +46 -0
- package/docs/quickstart.md +238 -0
- package/docs/troubleshooting.md +148 -0
- package/package.json +198 -3
- package/src/Baseline.ts +259 -0
- package/src/CaseExecutor.ts +104 -0
- package/src/EvalError.ts +74 -0
- package/src/Gate.ts +128 -0
- package/src/Regression.ts +314 -0
- package/src/Report.ts +268 -0
- package/src/Runner.ts +759 -0
- package/src/Suite.ts +358 -0
- package/src/Trials.ts +154 -0
- package/src/index.ts +76 -0
- package/src/internal/CanonicalJson.ts +234 -0
- package/src/internal/controlCharacters.ts +66 -0
- package/src/internal/scorerLabel.ts +35 -0
- package/src/internal/tupleKey.ts +20 -0
|
@@ -0,0 +1,314 @@
|
|
|
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
|
+
const groupBy = <A>(items: ReadonlyArray<A>, of: (item: A) => string): Map<string, Array<A>> => {
|
|
108
|
+
const groups = new Map<string, Array<A>>()
|
|
109
|
+
for (const item of items) {
|
|
110
|
+
const bucket = groups.get(of(item))
|
|
111
|
+
if (bucket === undefined) groups.set(of(item), [item])
|
|
112
|
+
else bucket.push(item)
|
|
113
|
+
}
|
|
114
|
+
return groups
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const byScore = <A extends { readonly score: number }>(items: ReadonlyArray<A>): Array<A> =>
|
|
118
|
+
[...items].sort((left, right) => left.score - right.score)
|
|
119
|
+
|
|
120
|
+
const byStepThenScore = <A extends { readonly stepKey: string; readonly score: number }>(
|
|
121
|
+
items: ReadonlyArray<A>
|
|
122
|
+
): Array<A> =>
|
|
123
|
+
[...items].sort((left, right) => CanonicalJson.compareText(left.stepKey, right.stepKey) || left.score - right.score)
|
|
124
|
+
|
|
125
|
+
/** One matched pair, or one record that nothing on the other side accounts for. */
|
|
126
|
+
type Pair =
|
|
127
|
+
| { readonly kind: "matched"; readonly expected: BaselineRecord; readonly observed: Score }
|
|
128
|
+
| { readonly kind: "missing-run"; readonly expected: BaselineRecord }
|
|
129
|
+
| { readonly kind: "missing-baseline"; readonly observed: Score }
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Pairs one case's baseline records with one case's observations.
|
|
133
|
+
*
|
|
134
|
+
* Same-step-key pairs are matched first, lowest score to lowest score, so a
|
|
135
|
+
* repeated scorer does not report a move that never happened. Whatever is left
|
|
136
|
+
* over is paired in stable order, and an unpaired record on either side is a
|
|
137
|
+
* missing observation rather than a silent drop.
|
|
138
|
+
*/
|
|
139
|
+
const pairsFor = (
|
|
140
|
+
expected: ReadonlyArray<BaselineRecord>,
|
|
141
|
+
observed: ReadonlyArray<Score>
|
|
142
|
+
): ReadonlyArray<Pair> => {
|
|
143
|
+
const pairs: Array<Pair> = []
|
|
144
|
+
const expectedByStep = groupBy(expected, (record) => record.stepKey)
|
|
145
|
+
const observedByStep = groupBy(observed, (record) => record.stepKey)
|
|
146
|
+
const leftoverExpected: Array<BaselineRecord> = []
|
|
147
|
+
const leftoverObserved: Array<Score> = []
|
|
148
|
+
for (const stepKey of [...expectedByStep.keys()].sort(CanonicalJson.compareText)) {
|
|
149
|
+
const atStep = byScore(expectedByStep.get(stepKey)!)
|
|
150
|
+
const observedAtStep = byScore(observedByStep.get(stepKey) ?? [])
|
|
151
|
+
const count = Math.min(atStep.length, observedAtStep.length)
|
|
152
|
+
for (let index = 0; index < count; index++) {
|
|
153
|
+
pairs.push({ kind: "matched", expected: atStep[index]!, observed: observedAtStep[index]! })
|
|
154
|
+
}
|
|
155
|
+
leftoverExpected.push(...atStep.slice(count))
|
|
156
|
+
leftoverObserved.push(...observedAtStep.slice(count))
|
|
157
|
+
}
|
|
158
|
+
for (const [stepKey, atStep] of observedByStep) {
|
|
159
|
+
if (!expectedByStep.has(stepKey)) leftoverObserved.push(...atStep)
|
|
160
|
+
}
|
|
161
|
+
const remainingExpected = byStepThenScore(leftoverExpected)
|
|
162
|
+
const remainingObserved = byStepThenScore(leftoverObserved)
|
|
163
|
+
const matched = Math.min(remainingExpected.length, remainingObserved.length)
|
|
164
|
+
for (let index = 0; index < matched; index++) {
|
|
165
|
+
pairs.push({ kind: "matched", expected: remainingExpected[index]!, observed: remainingObserved[index]! })
|
|
166
|
+
}
|
|
167
|
+
for (const record of remainingExpected.slice(matched)) pairs.push({ kind: "missing-run", expected: record })
|
|
168
|
+
for (const record of remainingObserved.slice(matched)) pairs.push({ kind: "missing-baseline", observed: record })
|
|
169
|
+
return pairs
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Compares a run to a baseline, preserving missing and inconclusive observations.
|
|
174
|
+
*
|
|
175
|
+
* Records and observations are grouped by `(case, scorer)` and then paired by
|
|
176
|
+
* step key, so a scorer that ran several times against one case is compared
|
|
177
|
+
* pairwise instead of by array position. A pair whose step key is unchanged and
|
|
178
|
+
* whose score moved is nondeterminism; a pair whose step key changed and whose
|
|
179
|
+
* score dropped is a regression; an unpaired record on either side is a missing
|
|
180
|
+
* observation. Inconclusive observations are carried through untouched, because
|
|
181
|
+
* they decide nothing and a gate has to see them.
|
|
182
|
+
*
|
|
183
|
+
* Fails with `invalid_tolerance` when a tolerance is not finite and
|
|
184
|
+
* non-negative, and with `invalid_baseline` when the artifact or any of its
|
|
185
|
+
* records belongs to a suite other than the one the run reports.
|
|
186
|
+
*
|
|
187
|
+
* @category constructors
|
|
188
|
+
* @since 0.1.0
|
|
189
|
+
*/
|
|
190
|
+
export const compare = (
|
|
191
|
+
baseline: Baseline,
|
|
192
|
+
run: RunResult,
|
|
193
|
+
tolerances: Tolerances = {}
|
|
194
|
+
): Effect.Effect<Report, EvalError> => {
|
|
195
|
+
const absolute = tolerances.absolute ?? 0
|
|
196
|
+
const relative = tolerances.relative ?? 0
|
|
197
|
+
if (!validTolerance(absolute)) {
|
|
198
|
+
return Effect.fail(
|
|
199
|
+
new EvalError({
|
|
200
|
+
code: "invalid_tolerance",
|
|
201
|
+
message: `Regression tolerance 'absolute' must be finite and non-negative, got ${String(absolute)}`,
|
|
202
|
+
path: "tolerances.absolute"
|
|
203
|
+
})
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
if (!validTolerance(relative)) {
|
|
207
|
+
return Effect.fail(
|
|
208
|
+
new EvalError({
|
|
209
|
+
code: "invalid_tolerance",
|
|
210
|
+
message: `Regression tolerance 'relative' must be finite and non-negative, got ${String(relative)}`,
|
|
211
|
+
path: "tolerances.relative"
|
|
212
|
+
})
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
const baselineSuite = baseline.suite
|
|
216
|
+
if (baselineSuite !== run.suite) {
|
|
217
|
+
return Effect.fail(
|
|
218
|
+
new EvalError({
|
|
219
|
+
code: "invalid_baseline",
|
|
220
|
+
message: flattenControlCharacters(
|
|
221
|
+
`Baseline belongs to suite '${baselineSuite}', but the run is suite '${run.suite}'`
|
|
222
|
+
),
|
|
223
|
+
path: "baseline.suite"
|
|
224
|
+
})
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
const foreign = [...new Set(baseline.records.filter((record) => record.suite !== run.suite).map((r) => r.suite))]
|
|
228
|
+
if (foreign.length > 0) {
|
|
229
|
+
return Effect.fail(
|
|
230
|
+
new EvalError({
|
|
231
|
+
code: "invalid_baseline",
|
|
232
|
+
message: flattenControlCharacters(
|
|
233
|
+
`Baseline holds records for suite ${
|
|
234
|
+
foreign.map((suite) => `'${suite}'`).join(", ")
|
|
235
|
+
}, but the run is suite '${run.suite}'`
|
|
236
|
+
),
|
|
237
|
+
path: "baseline.records"
|
|
238
|
+
})
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
const actual = run.observations.filter((observation): observation is Score => observation.kind === "score")
|
|
242
|
+
const inconclusive = run.observations.filter((
|
|
243
|
+
observation
|
|
244
|
+
): observation is Extract<Observation, { readonly kind: "inconclusive" }> => observation.kind === "inconclusive")
|
|
245
|
+
const baselineByKey = groupBy(baseline.records, (record) => key(record.case, record.scorer))
|
|
246
|
+
const actualByKey = groupBy(actual, (observation) => key(observation.case, observation.scorer))
|
|
247
|
+
const regressions: Array<Regression> = []
|
|
248
|
+
const nondeterminism: Array<Nondeterminism> = []
|
|
249
|
+
const missing: Array<MissingObservation> = []
|
|
250
|
+
for (const groupedKey of new Set([...baselineByKey.keys(), ...actualByKey.keys()])) {
|
|
251
|
+
const pairs = pairsFor(baselineByKey.get(groupedKey) ?? [], actualByKey.get(groupedKey) ?? [])
|
|
252
|
+
for (const pair of pairs) {
|
|
253
|
+
if (pair.kind === "missing-baseline") {
|
|
254
|
+
missing.push({
|
|
255
|
+
side: "baseline",
|
|
256
|
+
case: pair.observed.case,
|
|
257
|
+
scorer: pair.observed.scorer,
|
|
258
|
+
...(pair.observed.scorerName === undefined ? {} : { scorerName: pair.observed.scorerName }),
|
|
259
|
+
stepKey: pair.observed.stepKey
|
|
260
|
+
})
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
if (pair.kind === "missing-run") {
|
|
264
|
+
missing.push({
|
|
265
|
+
side: "run",
|
|
266
|
+
case: pair.expected.case,
|
|
267
|
+
scorer: pair.expected.scorer,
|
|
268
|
+
...(pair.expected.scorerName === undefined ? {} : { scorerName: pair.expected.scorerName }),
|
|
269
|
+
stepKey: pair.expected.stepKey
|
|
270
|
+
})
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
const baselineRecord = pair.expected
|
|
274
|
+
const observation = pair.observed
|
|
275
|
+
const delta = observation.score - baselineRecord.score
|
|
276
|
+
// Both branches divide by the same guarded magnitude. A baseline score of
|
|
277
|
+
// exactly 0 used to be special-cased in the regression branch, which was
|
|
278
|
+
// dead: a score is validated into [0, 1], so nothing can drop below 0.
|
|
279
|
+
if (observation.stepKey === baselineRecord.stepKey) {
|
|
280
|
+
if (
|
|
281
|
+
Math.abs(delta) > absolute && Math.abs(delta) / Math.max(Math.abs(baselineRecord.score), 1e-12) > relative
|
|
282
|
+
) {
|
|
283
|
+
nondeterminism.push({
|
|
284
|
+
case: observation.case,
|
|
285
|
+
scorer: observation.scorer,
|
|
286
|
+
baseline: baselineRecord,
|
|
287
|
+
actual: observation,
|
|
288
|
+
delta
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
} else if (
|
|
292
|
+
delta < 0 && -delta > absolute && -delta / Math.max(baselineRecord.score, 1e-12) > relative
|
|
293
|
+
) {
|
|
294
|
+
regressions.push({
|
|
295
|
+
case: observation.case,
|
|
296
|
+
scorer: observation.scorer,
|
|
297
|
+
baseline: baselineRecord,
|
|
298
|
+
actual: observation,
|
|
299
|
+
drop: -delta
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return Effect.succeed({
|
|
305
|
+
suite: run.suite,
|
|
306
|
+
baseline,
|
|
307
|
+
run,
|
|
308
|
+
regressions,
|
|
309
|
+
nondeterminism,
|
|
310
|
+
missing,
|
|
311
|
+
samples: actual,
|
|
312
|
+
inconclusive
|
|
313
|
+
})
|
|
314
|
+
}
|
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
|
+
}
|