@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
package/src/Suite.ts ADDED
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Fixed evaluation suite declarations.
3
+ *
4
+ * A suite is the fixed input half of an evaluation: named cases, the scorer
5
+ * bindings that grade them, and the concurrency the runner is allowed. It is
6
+ * validated once and then never changes, because the run that produced a
7
+ * committed baseline has to be reproducible from the suite value that was
8
+ * validated.
9
+ *
10
+ * @since 0.1.0
11
+ */
12
+
13
+ import type * as ScorerBinding from "@smthrs/scorers/Binding"
14
+ import * as Effect from "effect/Effect"
15
+ import * as Schema from "effect/Schema"
16
+ import { EvalError } from "./EvalError.ts"
17
+ import { controlCharacter } from "./internal/controlCharacters.ts"
18
+
19
+ /**
20
+ * A scorer binding accepted from `@smthrs/scorers`.
21
+ *
22
+ * The binding's `appliesTo` flow is matched against an execution's `target` by
23
+ * reference identity, so a binding only ever grades the exact flow value it was
24
+ * declared against.
25
+ *
26
+ * @category models
27
+ * @since 0.1.0
28
+ */
29
+ export type Binding = ScorerBinding.Binding
30
+
31
+ /**
32
+ * One immutable fixed-suite case.
33
+ *
34
+ * `input` is handed to the executor and `expected` is offered to a bound scorer
35
+ * as ground truth. A declared `expected`, including `null`, wins over the
36
+ * binding's `groundTruth`; only an absent one defers to the binding. Both are
37
+ * snapshots taken by {@link make}: mutating the object a caller passed in never
38
+ * changes the suite.
39
+ *
40
+ * @category models
41
+ * @since 0.1.0
42
+ */
43
+ export interface Case {
44
+ readonly name: string
45
+ readonly input: unknown
46
+ readonly expected?: unknown | undefined
47
+ }
48
+
49
+ /**
50
+ * Options for constructing a suite.
51
+ *
52
+ * @category models
53
+ * @since 0.1.0
54
+ */
55
+ export interface MakeOptions {
56
+ readonly name: string
57
+ readonly cases: ReadonlyArray<Case>
58
+ readonly bindings?: ReadonlyArray<Binding> | undefined
59
+ readonly concurrency: number
60
+ readonly trials?: number | undefined
61
+ }
62
+
63
+ /**
64
+ * A validated, named collection of fixed cases and scorer bindings.
65
+ *
66
+ * The suite, cases, bindings, and their inert data are deeply frozen snapshots.
67
+ * Executable scorer and target identities remain unchanged.
68
+ *
69
+ * @category models
70
+ * @since 0.1.0
71
+ */
72
+ export interface Suite {
73
+ readonly name: string
74
+ readonly cases: ReadonlyArray<Case>
75
+ readonly bindings: ReadonlyArray<Binding>
76
+ readonly concurrency: number
77
+ readonly trials?: number | undefined
78
+ }
79
+
80
+ /**
81
+ * The declared ceilings a suite is validated against.
82
+ *
83
+ * They exist so a mistake fails at construction with a sentence instead of
84
+ * exhausting memory in a runner: `concurrency` bounds the fibers a run may hold
85
+ * open, `cases` bounds one suite, and `fixtureLength` bounds one JSON Lines
86
+ * fixture.
87
+ *
88
+ * @category models
89
+ * @since 0.1.0
90
+ */
91
+ export const limits = {
92
+ concurrency: 1024,
93
+ cases: 10_000,
94
+ /** Longest JSON Lines fixture, in UTF-16 code units. */
95
+ fixtureLength: 8 * 1024 * 1024
96
+ } as const
97
+
98
+ const invalid = (message: string, path: string, cause?: unknown): EvalError =>
99
+ new EvalError({ code: "invalid_suite", message, path, ...(cause === undefined ? {} : { cause }) })
100
+
101
+ const cloneMessage = "Suite data must be structured-cloneable so the suite cannot change after it is validated"
102
+
103
+ // Validate before structuredClone can erase a prototype or invoke a getter.
104
+ // Only records and arrays can be protected completely with Object.freeze.
105
+ const validateData = (value: unknown, path: string, seen = new WeakSet<object>()): void => {
106
+ if (typeof value === "function" || typeof value === "symbol") throw invalid(cloneMessage, path)
107
+ if (value === null || typeof value !== "object") return
108
+ if (seen.has(value)) return
109
+ seen.add(value)
110
+ const array = Array.isArray(value)
111
+ const prototype = Object.getPrototypeOf(value)
112
+ if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) {
113
+ throw invalid("Suite data must contain only plain objects, arrays, and structured-cloneable primitives", path)
114
+ }
115
+ for (const key of Reflect.ownKeys(value)) {
116
+ if (array && key === "length") continue
117
+ const childPath = typeof key === "string" && array && /^(0|[1-9]\d*)$/.test(key)
118
+ ? `${path}[${key}]`
119
+ : `${path}.${String(key)}`
120
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)!
121
+ if (typeof key === "symbol" || !descriptor.enumerable || !("value" in descriptor)) {
122
+ throw invalid("Suite data properties must be enumerable string-keyed data fields", childPath)
123
+ }
124
+ validateData(descriptor.value, childPath, seen)
125
+ }
126
+ }
127
+
128
+ const freezeData = (value: unknown, seen = new WeakSet<object>()): unknown => {
129
+ if (value === null || typeof value !== "object" || seen.has(value)) return value
130
+ seen.add(value)
131
+ for (const child of Object.values(value)) freezeData(child, seen)
132
+ return Object.freeze(value)
133
+ }
134
+
135
+ const clone = (value: unknown, path: string): Effect.Effect<unknown, EvalError> =>
136
+ Effect.try({
137
+ try: () => {
138
+ validateData(value, path)
139
+ return freezeData(structuredClone(value))
140
+ },
141
+ catch: (cause) => cause instanceof EvalError ? cause : invalid(cloneMessage, path, cause)
142
+ })
143
+
144
+ const validate = (options: MakeOptions & { readonly trials: number }): EvalError | undefined => {
145
+ if (options.name.trim().length === 0) return invalid("Suite name must not be empty", "name")
146
+ const nameControl = controlCharacter(options.name)
147
+ if (nameControl !== undefined) {
148
+ return invalid(`Suite name must not contain the control character ${nameControl}`, "name")
149
+ }
150
+ if (!Number.isSafeInteger(options.trials) || options.trials < 1 || options.trials > 1000) {
151
+ return invalid("Suite trials must be an integer from 1 to 1000", "trials")
152
+ }
153
+ if (options.cases.length === 0) return invalid("Suite must contain at least one case", "cases")
154
+ if (options.cases.length > limits.cases) {
155
+ return invalid(`Suite must contain at most ${limits.cases} cases, got ${options.cases.length}`, "cases")
156
+ }
157
+ if (!Number.isSafeInteger(options.concurrency) || options.concurrency < 1) {
158
+ return invalid(
159
+ `Suite concurrency must be a positive safe integer, got ${String(options.concurrency)}`,
160
+ "concurrency"
161
+ )
162
+ }
163
+ if (options.concurrency > limits.concurrency) {
164
+ return invalid(
165
+ `Suite concurrency must be at most ${limits.concurrency}, got ${options.concurrency}`,
166
+ "concurrency"
167
+ )
168
+ }
169
+ const names = new Set<string>()
170
+ for (const [index, suiteCase] of options.cases.entries()) {
171
+ if (suiteCase.name.trim().length === 0) {
172
+ return invalid("Suite case name must not be empty", `cases[${index}].name`)
173
+ }
174
+ const caseControl = controlCharacter(suiteCase.name)
175
+ if (caseControl !== undefined) {
176
+ return invalid(
177
+ `Suite case name must not contain the control character ${caseControl}`,
178
+ `cases[${index}].name`
179
+ )
180
+ }
181
+ if (names.has(suiteCase.name)) {
182
+ return invalid(`Duplicate suite case: ${suiteCase.name}`, `cases[${index}].name`)
183
+ }
184
+ names.add(suiteCase.name)
185
+ }
186
+ return undefined
187
+ }
188
+
189
+ // Every field is read exactly once, into these two shapes, before anything is
190
+ // validated or copied. Reading a field twice let a getter return the name that
191
+ // passed validation and then a different one into the suite, which is the same
192
+ // hole a baseline record had.
193
+ const readCase = (suiteCase: Case): Case => ({
194
+ name: suiteCase.name,
195
+ input: suiteCase.input,
196
+ expected: suiteCase.expected
197
+ })
198
+
199
+ const readBinding = (binding: Binding): Binding => {
200
+ const read: { -readonly [K in keyof Binding]: Binding[K] } = {
201
+ scorer: binding.scorer,
202
+ appliesTo: binding.appliesTo,
203
+ sampling: binding.sampling
204
+ }
205
+ const groundTruth = binding.groundTruth
206
+ if (groundTruth !== undefined) read.groundTruth = groundTruth
207
+ const context = binding.context
208
+ if (context !== undefined) read.context = context
209
+ return read
210
+ }
211
+
212
+ const copyCase = (suiteCase: Case, index: number): Effect.Effect<Case, EvalError> =>
213
+ Effect.gen(function*() {
214
+ const input = yield* clone(suiteCase.input, `cases[${index}].input`)
215
+ if (suiteCase.expected === undefined) return Object.freeze({ name: suiteCase.name, input })
216
+ const expected = yield* clone(suiteCase.expected, `cases[${index}].expected`)
217
+ return Object.freeze({ name: suiteCase.name, input, expected })
218
+ })
219
+
220
+ const copyBinding = (binding: Binding, index: number): Effect.Effect<Binding, EvalError> =>
221
+ Effect.gen(function*() {
222
+ // `scorer` and `appliesTo` are executable identities matched by reference,
223
+ // so they are carried over unchanged; sampling, ground truth, and context
224
+ // are inert data that must not remain caller-owned.
225
+ const sampling = binding.sampling === undefined
226
+ ? undefined
227
+ : yield* clone(binding.sampling, `bindings[${index}].sampling`)
228
+ const copied: { -readonly [K in keyof Binding]: Binding[K] } = {
229
+ scorer: binding.scorer,
230
+ appliesTo: binding.appliesTo,
231
+ sampling: sampling as Binding["sampling"]
232
+ }
233
+ if (binding.groundTruth !== undefined) {
234
+ copied.groundTruth = yield* clone(binding.groundTruth, `bindings[${index}].groundTruth`)
235
+ }
236
+ if (binding.context !== undefined) {
237
+ copied.context = yield* clone(binding.context, `bindings[${index}].context`)
238
+ }
239
+ return Object.freeze(copied)
240
+ })
241
+
242
+ /**
243
+ * Builds and validates a fixed suite.
244
+ *
245
+ * When the effect runs, it reads every option, case field, and binding field
246
+ * exactly once, then validates and copies only what it read. Validating one
247
+ * value and copying another let a getter hand the suite something validation
248
+ * never saw. Every case and binding is then copied, including a binding's
249
+ * sampling policy, so the suite is a snapshot the caller can no longer reach:
250
+ * mutating an array, case input, or ratio policy afterwards leaves the
251
+ * validated suite unchanged. Data is checked before `structuredClone` and
252
+ * deeply frozen afterwards. Only plain objects, arrays, and cloneable
253
+ * primitives are admitted, with enumerable string-keyed data properties.
254
+ * Functions, class instances, mutable built-ins, and accessors fail with
255
+ * `invalid_suite` naming the offending path. Cycles and shared references are
256
+ * supported; null-prototype records become ordinary objects in the clone.
257
+ *
258
+ * Fails with `invalid_suite` for an empty or control-character name, no cases,
259
+ * more than `limits.cases` cases, a duplicate case name, or a concurrency that
260
+ * is not a safe integer in `[1, limits.concurrency]`.
261
+ *
262
+ * @category constructors
263
+ * @since 0.1.0
264
+ */
265
+ export const make = (options: MakeOptions): Effect.Effect<Suite, EvalError> =>
266
+ Effect.suspend(() => {
267
+ const name = options.name
268
+ const concurrency = options.concurrency
269
+ const cases = [...options.cases].map(readCase)
270
+ const bindings = [...(options.bindings ?? [])].map(readBinding)
271
+ const trials = options.trials ?? 1
272
+ const snapshot = { name, concurrency, cases, bindings, trials }
273
+ const error = validate(snapshot)
274
+ if (error !== undefined) return Effect.fail(error)
275
+ return Effect.gen(function*() {
276
+ const copiedCases = yield* Effect.forEach(cases, copyCase)
277
+ const copiedBindings = yield* Effect.forEach(bindings, copyBinding)
278
+ return Object.freeze({
279
+ name,
280
+ cases: Object.freeze(copiedCases),
281
+ bindings: Object.freeze(copiedBindings),
282
+ concurrency,
283
+ trials
284
+ })
285
+ })
286
+ })
287
+
288
+ /**
289
+ * Options used when decoding JSON Lines.
290
+ *
291
+ * @category models
292
+ * @since 0.1.0
293
+ */
294
+ export interface JsonLinesOptions {
295
+ readonly name: string
296
+ readonly bindings?: ReadonlyArray<Binding> | undefined
297
+ readonly concurrency: number
298
+ readonly trials?: number | undefined
299
+ }
300
+
301
+ const jsonCase = Schema.Struct({
302
+ name: Schema.String,
303
+ input: Schema.Unknown,
304
+ expected: Schema.optional(Schema.Unknown)
305
+ })
306
+
307
+ /**
308
+ * Loads the `{ name, input, expected? }` JSON Lines fixture format.
309
+ *
310
+ * Blank lines are skipped, a leading byte-order mark is stripped, and both LF
311
+ * and CRLF terminate a line. A malformed line fails with `invalid_suite`
312
+ * carrying the 1-based line number in both the message and the path; a fixture
313
+ * larger than `limits.fixtureLength` is rejected before any of it is parsed.
314
+ * The first non-blank line after `limits.cases` cases fails with
315
+ * `invalid_suite` at `cases` before it or any later line is parsed or decoded.
316
+ *
317
+ * @category constructors
318
+ * @since 0.1.0
319
+ */
320
+ export const fromJsonLines = (text: string, options: JsonLinesOptions): Effect.Effect<Suite, EvalError> =>
321
+ Effect.gen(function*() {
322
+ if (text.length > limits.fixtureLength) {
323
+ return yield* Effect.fail(
324
+ invalid(
325
+ `JSON Lines fixture must be at most ${limits.fixtureLength} characters, got ${text.length}`,
326
+ "text"
327
+ )
328
+ )
329
+ }
330
+ const cases: Array<Case> = []
331
+ const body = text.startsWith("\uFEFF") ? text.slice(1) : text
332
+ for (const [index, line] of body.split(/\r?\n/).entries()) {
333
+ const trimmed = line.trim()
334
+ if (trimmed.length === 0) continue
335
+ // Stop at the ceiling before parsing or decoding the excess line: a
336
+ // fixture under the length limit can still hold hundreds of thousands
337
+ // of tiny lines, and reporting the count only in make() would parse and
338
+ // materialize every one of them first.
339
+ if (cases.length === limits.cases) {
340
+ return yield* Effect.fail(
341
+ invalid(`Suite must contain at most ${limits.cases} cases, got ${cases.length + 1}`, "cases")
342
+ )
343
+ }
344
+ const decoded = yield* Effect.try({
345
+ try: () => JSON.parse(trimmed) as unknown,
346
+ catch: (cause) => invalid(`Invalid JSON on line ${index + 1}`, `line[${index + 1}]`, cause)
347
+ }).pipe(
348
+ Effect.flatMap(Schema.decodeUnknownEffect(jsonCase)),
349
+ Effect.mapError((cause) =>
350
+ cause instanceof EvalError
351
+ ? cause
352
+ : invalid(`Invalid suite case on line ${index + 1}`, `line[${index + 1}]`, cause)
353
+ )
354
+ )
355
+ cases.push(decoded)
356
+ }
357
+ return yield* make({ ...options, cases })
358
+ })
package/src/Trials.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Repeated-run statistics for live model evaluations.
3
+ *
4
+ * A live case runs `n` times and passes `c` of them. {@link passAtK} is the
5
+ * chance that at least one of `k` runs passes, the capability a best-of-k
6
+ * caller sees; {@link passHatK} is the chance that all `k` pass, the
7
+ * consistency a user sees on every attempt. Both are exact for sampling `k` of
8
+ * the `n` recorded runs without replacement.
9
+ *
10
+ * Every function is pure and throws an `EvalError` with code `invalid_trials`
11
+ * for counts outside the documented bounds.
12
+ *
13
+ * @since 0.1.0
14
+ */
15
+
16
+ import { EvalError } from "./EvalError.ts"
17
+
18
+ /**
19
+ * Repeated-run statistics for one case.
20
+ *
21
+ * `rate` equals `passAt1`; `stderr` is the standard error of the pass rate,
22
+ * `sqrt(rate * (1 - rate) / n)`, and 0 for a single run.
23
+ *
24
+ * @since 0.1.0
25
+ * @category models
26
+ */
27
+ export interface Summary {
28
+ readonly n: number
29
+ readonly passes: number
30
+ readonly rate: number
31
+ readonly passAt1: number
32
+ readonly passAtK: number
33
+ readonly passHatK: number
34
+ readonly stderr: number
35
+ }
36
+
37
+ /**
38
+ * Repeated-run statistics across cases.
39
+ *
40
+ * `passAt1`, `passAtK`, and `passHatK` are means over cases, and `allPass` is
41
+ * the fraction of cases whose every run passed.
42
+ *
43
+ * @since 0.1.0
44
+ * @category models
45
+ */
46
+ export interface Aggregate {
47
+ readonly cases: number
48
+ readonly passAt1: number
49
+ readonly passAtK: number
50
+ readonly passHatK: number
51
+ readonly allPass: number
52
+ readonly perCase: Readonly<Record<string, Summary>>
53
+ }
54
+
55
+ const invalid = (message: string, path: string): EvalError => new EvalError({ code: "invalid_trials", message, path })
56
+
57
+ const validate = (n: number, c: number, k: number): void => {
58
+ if (!Number.isInteger(n) || n < 1) throw invalid(`Run count must be a positive integer, received ${n}`, "n")
59
+ if (!Number.isInteger(c) || c < 0 || c > n) {
60
+ throw invalid(`Pass count must be an integer from 0 to ${n}, received ${c}`, "c")
61
+ }
62
+ if (!Number.isInteger(k) || k < 1 || k > n) throw invalid(`k must be an integer from 1 to ${n}, received ${k}`, "k")
63
+ }
64
+
65
+ /**
66
+ * The unbiased pass@k estimator, `1 - C(n - c, k) / C(n, k)` (Chen et al.,
67
+ * 2021): the chance that at least one of `k` runs drawn from `n` passes. It is
68
+ * computed as a product, so large `n` neither overflows nor loses precision.
69
+ *
70
+ * Throws `invalid_trials` unless `n`, `c`, and `k` are integers with
71
+ * `0 <= c <= n` and `1 <= k <= n`.
72
+ *
73
+ * @since 0.1.0
74
+ * @category statistics
75
+ */
76
+ export const passAtK = (n: number, c: number, k: number): number => {
77
+ validate(n, c, k)
78
+ if (n - c < k) return 1
79
+ let fail = 1
80
+ for (let i = n - c + 1; i <= n; i++) fail *= 1 - k / i
81
+ return 1 - fail
82
+ }
83
+
84
+ /**
85
+ * pass^k, `C(c, k) / C(n, k)`: the chance that all `k` runs drawn from `n`
86
+ * without replacement pass, the consistency measure of tau-bench.
87
+ *
88
+ * Throws `invalid_trials` under the same bounds as {@link passAtK}.
89
+ *
90
+ * @since 0.1.0
91
+ * @category statistics
92
+ */
93
+ export const passHatK = (n: number, c: number, k: number): number => {
94
+ validate(n, c, k)
95
+ let all = 1
96
+ for (let i = 0; i < k; i++) all *= (c - i) / (n - i)
97
+ return Math.max(0, all)
98
+ }
99
+
100
+ /**
101
+ * Summarizes one case's run outcomes at `k`.
102
+ *
103
+ * Throws `invalid_trials` for no outcomes or a `k` outside `1..n`.
104
+ *
105
+ * @since 0.1.0
106
+ * @category statistics
107
+ */
108
+ export const summarize = (outcomes: ReadonlyArray<boolean>, k: number): Summary => {
109
+ const n = outcomes.length
110
+ const passes = outcomes.filter(Boolean).length
111
+ validate(n, passes, k)
112
+ const rate = passes / n
113
+ return {
114
+ n,
115
+ passes,
116
+ rate,
117
+ passAt1: rate,
118
+ passAtK: passAtK(n, passes, k),
119
+ passHatK: passHatK(n, passes, k),
120
+ stderr: n <= 1 ? 0 : Math.sqrt(rate * (1 - rate) / n)
121
+ }
122
+ }
123
+
124
+ const mean = (values: ReadonlyArray<number>): number =>
125
+ values.length === 0 ? 0 : values.reduce((total, value) => total + value, 0) / values.length
126
+
127
+ /**
128
+ * Summarizes every case at `k` and averages over cases.
129
+ *
130
+ * A case with fewer than `k` runs is summarized at `k = n`, its own run
131
+ * count, so a short case still contributes rather than failing the whole
132
+ * aggregate. No cases aggregate to zeros. Throws `invalid_trials` for a case
133
+ * with no runs or a `k` that is not a positive integer.
134
+ *
135
+ * @since 0.1.0
136
+ * @category statistics
137
+ */
138
+ export const aggregate = (cases: Readonly<Record<string, ReadonlyArray<boolean>>>, k: number): Aggregate => {
139
+ if (!Number.isInteger(k) || k < 1) throw invalid(`k must be a positive integer, received ${k}`, "k")
140
+ const entries = Object.entries(cases).map(([name, outcomes]): [string, Summary] => {
141
+ if (outcomes.length === 0) throw invalid(`Case '${name}' has no runs`, `cases['${name}']`)
142
+ return [name, summarize(outcomes, Math.min(k, outcomes.length))]
143
+ })
144
+ // fromEntries defines own properties, so a case named `__proto__` stays a case.
145
+ const perCase: Record<string, Summary> = Object.fromEntries(entries)
146
+ const summaries = Object.values(perCase)
147
+ return {
148
+ cases: summaries.length,
149
+ passAt1: mean(summaries.map((summary) => summary.passAt1)),
150
+ passAtK: mean(summaries.map((summary) => summary.passAtK)),
151
+ passHatK: mean(summaries.map((summary) => summary.passHatK)),
152
+ allPass: mean(summaries.map((summary) => summary.passes === summary.n ? 1 : 0)),
153
+ perCase
154
+ }
155
+ }
package/src/index.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Fixed-suite evaluation tools for flows.
3
+ *
4
+ * The pipeline runs in one direction: a {@link Suite} declares fixed cases and
5
+ * the scorers bound to them, a {@link Runner} executes them through an injected
6
+ * {@link CaseExecutor} and grades the executions, {@link Baseline} records what
7
+ * a run scored, {@link Regression} compares the next run with that record,
8
+ * {@link Report} renders the comparison, and {@link Gate} turns it into a CI
9
+ * exit code.
10
+ *
11
+ * @since 0.1.0
12
+ */
13
+
14
+ /**
15
+ * Typed evaluation failures and the stable codes a caller branches on.
16
+ *
17
+ * @since 0.1.0
18
+ * @category errors
19
+ */
20
+ export * as EvalError from "./EvalError.ts"
21
+ /**
22
+ * Fixed suite declarations and their JSON Lines fixture format.
23
+ *
24
+ * @since 0.1.0
25
+ * @category suites
26
+ */
27
+ export * as Suite from "./Suite.ts"
28
+ /**
29
+ * The injectable boundary that executes one case against a target flow.
30
+ *
31
+ * @since 0.1.0
32
+ * @category services
33
+ */
34
+ export * as CaseExecutor from "./CaseExecutor.ts"
35
+ /**
36
+ * Deterministic suite execution and bound scorer evaluation.
37
+ *
38
+ * @since 0.1.0
39
+ * @category runners
40
+ */
41
+ export * as Runner from "./Runner.ts"
42
+ /**
43
+ * Committed baselines: what a suite used to score.
44
+ *
45
+ * @since 0.1.0
46
+ * @category baselines
47
+ */
48
+ export * as Baseline from "./Baseline.ts"
49
+ /**
50
+ * Step-key-aware comparison of a run against a baseline.
51
+ *
52
+ * @since 0.1.0
53
+ * @category regression
54
+ */
55
+ export * as Regression from "./Regression.ts"
56
+ /**
57
+ * Canonical JSON and Markdown renderings of a comparison.
58
+ *
59
+ * @since 0.1.0
60
+ * @category reports
61
+ */
62
+ export * as Report from "./Report.ts"
63
+ /**
64
+ * Score thresholds and the CI exit grade a comparison earns.
65
+ *
66
+ * @since 0.1.0
67
+ * @category gates
68
+ */
69
+ export * as Gate from "./Gate.ts"
70
+ /**
71
+ * Repeated-run statistics for live evaluations: pass@k and pass^k.
72
+ *
73
+ * @since 0.1.0
74
+ * @category statistics
75
+ */
76
+ export * as Trials from "./Trials.ts"