@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/docs/api.md ADDED
@@ -0,0 +1,626 @@
1
+ ---
2
+ title: "API reference"
3
+ description: "Every @smthrs/evals export: the pipeline, comparison semantics, failure codes, the batch protocol, and limits."
4
+ ---
5
+
6
+ Fixed-suite evaluation, baselines, regression reports, and score gates for
7
+ flows.
8
+
9
+ For how to add the package to a project, see
10
+ [Installation](./installation.md).
11
+
12
+ ## The pipeline
13
+
14
+ One direction, six modules:
15
+
16
+ 1. `Suite` declares the fixed cases and the scorer bindings that grade them.
17
+ 2. `Runner` executes every case through an injected `CaseExecutor` and grades the
18
+ executions, producing observations.
19
+ 3. `Baseline` records what a run scored, as a committed artifact.
20
+ 4. `Regression` compares the next run with that artifact.
21
+ 5. `Report` renders the comparison as JSON or Markdown.
22
+ 6. `Gate` turns the comparison into a verdict and a CI exit code.
23
+
24
+ ```ts
25
+ import { Baseline, CaseExecutor, Gate, Regression, Runner, Suite } from "@smthrs/evals"
26
+ import { Binding, Scorer } from "@smthrs/scorers"
27
+ import { Effect, Layer } from "effect"
28
+ import { readFile } from "node:fs/promises"
29
+
30
+ const greet = { name: "greet" }
31
+
32
+ const polite = Scorer.make({
33
+ id: "example/polite",
34
+ version: "1",
35
+ name: "polite",
36
+ score: ({ output }) => Effect.succeed({ score: String(output).startsWith("Hello") ? 1 : 0 })
37
+ })
38
+
39
+ const executor = CaseExecutor.make((suiteCase) =>
40
+ Effect.succeed({
41
+ output: `Hello, ${(suiteCase.input as { readonly name: string }).name}`,
42
+ stepKey: suiteCase.name,
43
+ latencyMs: 0,
44
+ target: greet
45
+ })
46
+ )
47
+
48
+ const program = Effect.gen(function*() {
49
+ const suite = yield* Suite.make({
50
+ name: "smoke",
51
+ cases: [{ name: "hello", input: { name: "Ada" } }],
52
+ bindings: [Binding.make({ scorer: polite, appliesTo: greet })],
53
+ concurrency: 1
54
+ })
55
+ const run = yield* Runner.run(suite, { runId: "nightly-2026-01-01", at: "2026-01-01T00:00:00.000Z" })
56
+ const committed = yield* Baseline.load(
57
+ yield* Effect.promise(() => readFile("baseline.json", "utf8"))
58
+ )
59
+ const comparison = yield* Regression.compare(committed, run)
60
+ return Gate.ciGrade(yield* Gate.check(comparison, { mean: 0.9 }))
61
+ }).pipe(Effect.provide(Layer.succeed(CaseExecutor.CaseExecutor)(executor)))
62
+ ```
63
+
64
+ `Runner.run` needs only `CaseExecutor`. Scoring runs in process by default;
65
+ provide `Runner.layerInline` to say so explicitly, `Runner.layerNoop` to state
66
+ that a suite must not score, or your own adapter through `options.scorer` or
67
+ the `Runner` service.
68
+
69
+ A run executes under the span `Eval.run` (attributes `suite` and `runId`), and
70
+ each case under a child span `Eval.case` (attribute `case`), so any tracer the
71
+ host provides shows which case a slow or failing run spent its time in.
72
+
73
+ ## How comparison works
74
+
75
+ Four rules decide what a comparison reports, and none of them are obvious:
76
+
77
+ - **The step key decides which finding you get.** A score that dropped at a
78
+ _changed_ step key is a `regression`: the target produced different work and it
79
+ graded worse. A score that moved at an _unchanged_ step key is
80
+ `nondeterminism`: the same work graded differently twice. A gate reads both as
81
+ red.
82
+ - **`Observation.scorer` is a digest, not a name.** It is the scorer key, derived
83
+ from the scorer's own `{ id, version, config }`, and it is what a baseline
84
+ matches on. `Observation.scorerName` carries the readable name beside it, and a
85
+ Markdown report prints `name (first 8 of the key)`, or the bare key for a
86
+ scorer declared without a name. Report cells, gate summaries, and runner
87
+ diagnostics all use that one label.
88
+ - **A binding matches its target by reference identity.** `binding.appliesTo` has
89
+ to be the same flow value the execution reports as its `target`; a structurally
90
+ equal copy grades nothing.
91
+ - **A baseline belongs to one suite.** Validated baselines and newly written
92
+ artifacts require a top-level `suite`, even with no records. Every record
93
+ requires its own `suite`. `Baseline.load` accepts a suite-less version-1
94
+ artifact only when its nonempty records all name the same suite, then sets
95
+ the top-level `suite` to that value. Empty or ambiguous legacy artifacts fail
96
+ with `invalid_baseline` at `suite`. Comparison refuses an artifact or any
97
+ record that names a suite other than the run's with `invalid_baseline`.
98
+
99
+ ## Failure codes
100
+
101
+ `EvalError.code` is the stable branch point.
102
+
103
+ | Code | Raised when | Who fixes it |
104
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
105
+ | `invalid_suite` | The suite declaration is wrong: a name, a case, a concurrency, a fixture line, non-cloneable case data, or an undecidable sampling policy. | The suite author |
106
+ | `invalid_run_options` | `Runner.run`'s own options are wrong: an empty `runId`, or an `at` that is not a canonical UTC instant. | The caller |
107
+ | `invalid_baseline` | The committed baseline is unreadable, holds a record the schema rejects, or belongs to another suite. | Regenerate the baseline |
108
+ | `invalid_tolerance` | A comparison tolerance is not a finite non-negative number. | The caller |
109
+ | `invalid_trials` | A `Trials` run, pass, or `k` count is outside its bounds. | The caller |
110
+ | `executor` | The target flow failed for a case, or no executor was available. | The target or the wiring |
111
+ | `ambiguous_score_job` | Two jobs share a step key and scorer, so an order-only runner cannot attribute their results to cases. | The suite or batch runner |
112
+ | `scorer_protocol` | A batch runner returned the wrong number of observations, or observations identifying jobs other than the ones it was given. | The batch runner |
113
+ | `scorer_unavailable` | No batch runner was available to score with. | The wiring |
114
+
115
+ Most failures carry a `path` locating the offending value: `cases[1].input`,
116
+ `records[3].score`, `options.at`, `runBatch[0]`. A case whose target failed keeps
117
+ the executor's own path when it named one and is located at `cases['<name>']`
118
+ otherwise, so a failed case is always locatable. Only two failures carry no path:
119
+ one about an artifact as a whole (`Baseline` is not JSON, or is not an object)
120
+ and one about the absence of a runner (`scorer_unavailable`).
121
+
122
+ ## The batch protocol
123
+
124
+ `Runner.ScoreBatchRunner.runBatchCorrelated` returns each observation with its
125
+ job identity. A correlated runner may return results in any order. A run rejects
126
+ a duplicate identity, an unknown identity, a missing identity, the wrong result
127
+ count, or an observation that does not echo its job's `targetStepKey` and
128
+ `scorerKey` with `scorer_protocol`.
129
+
130
+ `runBatch` remains the order-only protocol for adapters that cannot return job
131
+ identities. Its contract is positional:
132
+
133
+ 1. Exactly one observation per job.
134
+ 2. In the order the jobs were given.
135
+ 3. Each observation repeats its job's `targetStepKey` and `scorerKey`.
136
+
137
+ A run verifies all three and fails with `scorer_protocol` when one is broken.
138
+ Before calling an order-only runner, it also refuses two jobs that share a step
139
+ key and scorer with `ambiguous_score_job`. Give each case its own step key, or
140
+ provide a runner that implements `runBatchCorrelated`. A returned score that is
141
+ not finite and inside [0, 1] becomes an inconclusive observation naming the
142
+ scorer and the offending value; the run's own timestamp, not the adapter's, is
143
+ what reaches the observation.
144
+
145
+ Both adapter methods retain their receiver and receive the suite's concurrency.
146
+ Scorer callbacks execute inside their job effects. A callback that throws
147
+ synchronously or a batch that fails produces inconclusive observations; failure
148
+ text is bounded and safe to convert. Interruption still cancels the run.
149
+ `layerNoop` returns identity-correlated inconclusive results, including when
150
+ cases share a step key and scorer, without invoking scorer callbacks.
151
+
152
+ ## Determinism and limits
153
+
154
+ - `Runner.run` takes `runId` and `at` from the caller. `runId` identifies the
155
+ `RunResult` and joins every score job's identity; `at` stamps every
156
+ observation. Given identical executor results, scorer results, metadata, and
157
+ options, two runs produce identical observations and identical `Report.json`
158
+ bytes. Executor and scorer effects run afresh each time, so clocks, models,
159
+ randomness, and external state inside them are the caller's to control.
160
+ - When the `Suite.make` effect runs, it snapshots its options, copies case and
161
+ binding data with `structuredClone`, and freezes the arrays it returns. The
162
+ suite cannot change after it is validated.
163
+ - `Suite.limits` declares the ceilings: `concurrency` 1024, `cases` 10000,
164
+ `fixtureLength` 8388608 code units for one JSON Lines fixture.
165
+ - `Report.json` embeds each case's raw `execution.output`. Strings are capped at
166
+ 8192 code units and everything JSON cannot express becomes a named marker
167
+ (`[circular]`, `[depth exceeded]`, `[NaN]`, `[function]`), so the serializer is
168
+ total. Nothing is redacted: a suite whose cases carry secrets must not print
169
+ the report where the log is readable.
170
+ - The encoder is bounded in total, not only per branch. A value referenced
171
+ twice is expanded twice, which a shared acyclic graph turns into exponential
172
+ work well below the depth ceiling, so traversal stops after 2000000 values or
173
+ 33554432 output code units and every remaining value becomes
174
+ `[budget exceeded]`. The budget is spent in traversal order, which the key
175
+ sort fixes, so one value always truncates at the same place.
176
+ - `Report.markdown` neutralizes inline GFM and raw HTML in every cell and the
177
+ suite heading value. It caps each value at 240 escaped UTF-16 code units,
178
+ plus an ellipsis when truncated, without splitting escapes or code points.
179
+
180
+ ## Module reference
181
+
182
+ The root entry point exports these namespaces; each is also importable from
183
+ `@smthrs/evals/<Module>`. `@smthrs/evals/package.json` is exported too;
184
+ `internal/*` and nested `*/index` subpaths are not public.
185
+
186
+ ### EvalError
187
+
188
+ Typed evaluation failures and the stable codes a caller branches on.
189
+
190
+ ```ts
191
+ class EvalError extends Schema.TaggedError<EvalError>()("flows/evals/EvalError", {
192
+ code: EvalErrorCode,
193
+ message: Schema.String,
194
+ path: Schema.optional(Schema.String),
195
+ cause: Schema.optional(Schema.Defect())
196
+ }) {}
197
+ ```
198
+
199
+ `code` is the stable branch point, `message` is the sentence a CI log shows,
200
+ and `path` locates the offending value inside the input the caller supplied
201
+ (`records[3].score`, `cases[1].input`, `options.at`). `cause` retains the
202
+ original failure; it is never rendered on its own, so a message that matters
203
+ to an operator says so itself. `EvalErrorCode` is the schema and type for the
204
+ nine codes in the failure table.
205
+
206
+ ### Suite
207
+
208
+ Fixed suite declarations and their JSON Lines fixture format.
209
+
210
+ - `Case`: `{ name, input, expected? }`. `input` is handed to the executor and
211
+ `expected` is offered to a bound scorer as ground truth. A declared
212
+ `expected`, including `null`, wins over the binding's `groundTruth`; only an
213
+ absent one defers to the binding. Both are snapshots taken by `make`.
214
+ - `Binding`: a scorer binding accepted from [@smthrs/scorers](/api/scorers).
215
+ The binding's `appliesTo` flow is matched against an execution's `target` by
216
+ reference identity.
217
+ - `MakeOptions`: `{ name, cases, bindings?, concurrency }`.
218
+ - `Suite`: `{ name, cases, bindings, concurrency }`, with `cases` and
219
+ `bindings` frozen and every data field a copy the caller cannot reach.
220
+ - `limits`: `{ concurrency: 1024, cases: 10000, fixtureLength: 8388608 }`, the
221
+ declared ceilings a suite is validated against.
222
+
223
+ ```ts
224
+ const make = (options: MakeOptions): Effect.Effect<Suite, EvalError>
225
+ ```
226
+
227
+ Builds and validates a fixed suite. When the effect runs, it reads every
228
+ option, case field, and binding field exactly once, then validates and copies
229
+ only what it read, so a getter cannot hand the suite a value validation never
230
+ saw. Every case and binding is then copied with `structuredClone`, which is
231
+ also the check that the data is inert. Fails with `invalid_suite` for an empty
232
+ or control-character name, no cases, more than `limits.cases` cases, a
233
+ duplicate case name, or a concurrency that is not a safe integer in
234
+ [1, `limits.concurrency`].
235
+
236
+ ```ts
237
+ const fromJsonLines = (text: string, options: JsonLinesOptions): Effect.Effect<Suite, EvalError>
238
+ ```
239
+
240
+ Loads the `{ name, input, expected? }` JSON Lines fixture format.
241
+ `JsonLinesOptions` is `{ name, bindings?, concurrency }`. Blank lines are
242
+ skipped, a leading byte-order mark is stripped, and both LF and CRLF terminate
243
+ a line. A malformed line fails with `invalid_suite` carrying the 1-based line
244
+ number in both the message and the path; a fixture larger than
245
+ `limits.fixtureLength` is rejected before any of it is parsed. All ceilings
246
+ are inclusive. After 10000 cases, the next non-blank line fails before parsing
247
+ or decoding with `invalid_suite` at `cases` and the message
248
+ `Suite must contain at most 10000 cases, got 10001`; later lines are not decoded.
249
+
250
+ ### CaseExecutor
251
+
252
+ The injectable boundary that executes one case against a target flow. A suite
253
+ says what to run; this service says how.
254
+
255
+ - `Execution`: `{ output, stepKey, latencyMs, target }`. `target` is the flow
256
+ value the case actually executed, matched against each binding's `appliesTo`
257
+ by reference identity, so it has to be the declared flow itself rather than
258
+ a copy of it.
259
+ - `Run`: `(suiteCase: Suite.Case) => Effect.Effect<Execution, EvalError>`.
260
+ - `Service`: `{ run: Run }`.
261
+ - `CaseExecutor`: the `Context.Service` tag, `flows/evals/CaseExecutor`.
262
+
263
+ ```ts
264
+ const make = (run: Run): Service
265
+ ```
266
+
267
+ Builds an executor from its one callback. Throws a `TypeError` when `run` is
268
+ not a function: an executor that
269
+ silently degraded would turn one wiring mistake into a whole suite of cases
270
+ failing with `executor`, which reads as a broken target rather than a missing
271
+ one.
272
+
273
+ `makeNoop()` builds an executor that fails every case with a typed `executor`
274
+ error, and `layerNoop` provides it.
275
+
276
+ ### Runner
277
+
278
+ Deterministic suite execution and bound scorer evaluation.
279
+
280
+ - `Observation`: one score observation emitted by a suite run. Every
281
+ observation carries `case`, `scorer`, `stepKey`, and `at`, plus an optional
282
+ `scorerName`; a `score` observation adds `score` in [0, 1] with an optional
283
+ `reason`, and an `inconclusive` observation adds a required `reason`. Both
284
+ kinds may carry `meta`. `at` is the run's timestamp, not the scorer's: a
285
+ run's observations all carry one instant so a baseline stays reproducible.
286
+ - `ScoreRequest`: a request sent to the scorers batch runner, joining the
287
+ case, the step key, the binding, and the scorer input (`input`, `output`,
288
+ optional `groundTruth` and `context`, `latencyMs`).
289
+ - `ScoreJob`: a blocking scorer job, matching the `Runner` module in
290
+ [@smthrs/scorers](/api/scorers): an `identity`, an `observation` seed with
291
+ `targetStepKey` and `scorerKey`, the `score` effect, and `at` in
292
+ milliseconds.
293
+ - `ScoreObservation`: a score result aligned with a `ScoreRequest`.
294
+ - `BatchResult`: `{ identity, observation }`, a batch result tagged with the
295
+ identity of the job that produced it.
296
+ - `CaseResult`: `{ case, execution?, error?, observations }`, the per-case
297
+ result retained by the runner.
298
+ - `RunResult`: `{ runId, suite, cases, observations }`, the stable result of a
299
+ suite run.
300
+ - `RunOptions`: `{ scorer?, runId, sampleId?, at }`.
301
+ - `Runner`: the `Context.Service` tag, `flows/evals/Runner`, for an injectable
302
+ batch runner. `run` does not require it: a run scores with `options.scorer`
303
+ when one is passed, otherwise with this service when one is provided,
304
+ otherwise in process through `makeInline`.
305
+ - `ScoreBatchRunner`: the structural adapter described in the batch protocol
306
+ section.
307
+
308
+ ```ts
309
+ const run = (
310
+ suite: Suite,
311
+ options: RunOptions
312
+ ): Effect.Effect<RunResult, EvalError, CaseExecutor>
313
+ ```
314
+
315
+ Runs a fixed suite with bounded execution and declaration-order results. Cases
316
+ run through the provided `CaseExecutor` at the suite's concurrency. Every
317
+ execution is graded by the bindings whose `appliesTo` is the flow the
318
+ execution reports as its target, matched by reference identity. A case whose
319
+ target failed keeps its typed error and produces no observations; a scorer
320
+ that failed produces an inconclusive observation. The run itself fails only
321
+ with `invalid_run_options` for a non-canonical run identity or timestamp,
322
+ `invalid_suite` for an unusable sampling policy, `ambiguous_score_job` when an
323
+ order-only runner cannot distinguish two jobs, or `scorer_protocol` for a
324
+ batch runner that broke the `ScoreBatchRunner` contract.
325
+
326
+ ```ts
327
+ const makeInline = (): ScoreBatchRunner
328
+ ```
329
+
330
+ Builds the in-process batch runner a run scores with by default. Each job's
331
+ scorer runs in the current process, its result is checked by the scorers
332
+ package's own `Scorer.validate`, and a scorer that fails becomes an
333
+ inconclusive observation naming its cause rather than failing the run.
334
+ `layerInline` provides it as the `Runner` service.
335
+
336
+ `layerNoop` provides a batch runner that is never available. Every bound score
337
+ under it becomes an inconclusive observation, which a gate grades as an
338
+ undecidable run rather than a red. Provide it to state that a suite must not
339
+ score.
340
+
341
+ ### Baseline
342
+
343
+ Committed baselines: what a suite used to score.
344
+
345
+ - `version`: `1`, the current committed baseline artifact version.
346
+ - `BaselineRecord`: `{ suite, case, scorer, scorerName?, stepKey, score }`,
347
+ one successful score retained by a baseline.
348
+ - `Baseline`: `{ version, suite, records }`. `suite` records artifact
349
+ ownership even when `records` is empty.
350
+ - `limits`: `{ length: 16777216 }`, the longest baseline JSON text `load`
351
+ parses, in UTF-16 code units.
352
+
353
+ ```ts
354
+ const fromRun = (run: RunResult): Effect.Effect<Baseline, EvalError>
355
+ ```
356
+
357
+ Builds and validates a baseline from a run's successful observations.
358
+ Inconclusive observations are dropped: a baseline records what was measured,
359
+ and an inconclusive observation measured nothing.
360
+
361
+ ```ts
362
+ const make = (
363
+ baseline: Omit<Baseline, "version"> & { readonly version?: typeof version }
364
+ ): Effect.Effect<Baseline, EvalError>
365
+ ```
366
+
367
+ Validates an in-memory baseline. Every known field is read once, records are
368
+ rebuilt from the validated values, and the array is frozen. Fails with
369
+ `invalid_baseline` carrying the record index and field name in `path` for a
370
+ wrong version, a non-array `records`, a record that is not an object, an
371
+ identity field that is not a string or holds a control character, or a score
372
+ that is not finite in [0, 1]. A control character is any of the C0 controls (U+0000 through U+001F), DEL (U+007F), C1 controls (U+0080
373
+ through U+009F), and the bidirectional controls U+061C, U+200E, U+200F,
374
+ U+202A through U+202E, and U+2066 through U+2069.
375
+ A top-level `suite` holding a control character fails at `path: "suite"`.
376
+
377
+ ```ts
378
+ const write = (baseline: Baseline): string
379
+ const load = (text: string): Effect.Effect<Baseline, EvalError>
380
+ ```
381
+
382
+ `write` serializes a baseline with recursively sorted keys and stable numbers,
383
+ records ordered by an injective encoding of `(suite, case, scorer, stepKey)`,
384
+ ending with a newline and always including the top-level `suite`. `load`
385
+ parses and validates baseline JSON, failing with `invalid_baseline`. For a
386
+ version-1 artifact without a top-level `suite`, it derives ownership only when
387
+ all records name the same suite. Empty or ambiguous legacy artifacts fail at
388
+ `path: "suite"` with an explicit reason. A present non-string `suite` is invalid.
389
+ Text longer than `limits.length` fails at `path: "text"` before any of it is
390
+ parsed.
391
+ Writing the loaded baseline retains its scores and adds the inferred `suite`.
392
+
393
+ ### Regression
394
+
395
+ Step-key-aware comparison of a run against a baseline.
396
+
397
+ - `Tolerances`: `{ absolute?, relative? }`. A move is reported only when it
398
+ exceeds both tolerances, so either one alone is enough to silence it. Both
399
+ default to 0, which reports every move.
400
+ - `Regression`: `{ case, scorer, baseline, actual, drop }`, a score drop at a
401
+ changed step key.
402
+ - `Nondeterminism`: `{ case, scorer, baseline, actual, delta }`, a changed
403
+ score at the same step key.
404
+ - `MissingObservation`: `{ side, case, scorer, scorerName?, stepKey }`. `side`
405
+ names the side the observation is missing from: `"run"` for a baseline
406
+ record the run never reproduced, `"baseline"` for a score no baseline record
407
+ accounts for.
408
+ - `Report`: `{ suite, baseline, run, regressions, nondeterminism, missing,
409
+ samples, inconclusive }`, the complete comparison. `samples` and
410
+ `inconclusive` are in-memory views filtered out of `run.observations` by
411
+ `kind`; `Report.json` recomputes rather than serializes them.
412
+
413
+ ```ts
414
+ const compare = (
415
+ baseline: Baseline,
416
+ run: RunResult,
417
+ tolerances?: Tolerances
418
+ ): Effect.Effect<Report, EvalError>
419
+ ```
420
+
421
+ Compares a run to a baseline, preserving missing and inconclusive
422
+ observations. Records and observations are grouped by `(case, scorer)` and
423
+ then paired by step key, so a scorer that ran several times against one case
424
+ is compared pairwise instead of by array position. Fails with
425
+ `invalid_tolerance` when a tolerance is not finite and non-negative, and with
426
+ `invalid_baseline` when the artifact or any of its records belongs to a suite
427
+ other than the one the run reports.
428
+
429
+ ### Report
430
+
431
+ Canonical JSON and Markdown renderings of a comparison.
432
+
433
+ ```ts
434
+ const version: 1
435
+ interface Data {
436
+ readonly reportVersion: 1
437
+ readonly suite: string
438
+ readonly baseline: Baseline.Baseline
439
+ readonly run: { runId, suite, cases: ReadonlyArray<Case>, observations: ReadonlyArray<Runner.Observation> }
440
+ readonly regressions: ReadonlyArray<Omit<Regression.Regression, "actual"> & { actual: number }>
441
+ readonly nondeterminism: ReadonlyArray<Omit<Regression.Nondeterminism, "actual"> & { actual: number }>
442
+ readonly missing: ReadonlyArray<Regression.MissingObservation>
443
+ }
444
+ const data = (report: Regression.Report): Data
445
+ const json = (report: Regression.Report): string
446
+ const markdown = (report: Regression.Report): string
447
+ ```
448
+
449
+ `Data` is the serialized wire shape and `version` is the format version
450
+ stamped on it as `reportVersion`. It versions the comparison independently of
451
+ the nested `Baseline.version`: the wire shape can change without the committed
452
+ baseline artifact changing.
453
+
454
+ `run.observations` is the observation table. Every observation the report
455
+ mentions appears in it exactly once and everything else refers to one by
456
+ index: `run.cases[i].observations` is a list of indexes, and a regression or
457
+ nondeterminism entry's `actual` is a single index. `samples` and
458
+ `inconclusive` are absent, because both are filters of `run.observations` by
459
+ `kind` and a reader recomputes them. An in-memory `Regression.Report` holds
460
+ the same observation in up to four of those places, so encoding it directly
461
+ repeated every `reason` and every `meta` that many times.
462
+
463
+ `data` projects a comparison into that shape. Observations are pooled by
464
+ identity, not by value, so two distinct objects that happen to be equal stay
465
+ two rows. The table starts as `run.observations` and grows to hold any
466
+ observation reached only through a case or a comparison entry, so a hand-built
467
+ report whose case lists are not flattened still serializes every reference.
468
+
469
+ `json` serializes a regression report as stable, sorted-key `Data` JSON. The
470
+ report embeds each case's raw `execution.output`, which comes from an
471
+ arbitrary target flow, so the encoding is total rather than trusting: keys are
472
+ sorted by code unit, embedded strings are capped, total traversal is bounded,
473
+ and anything JSON cannot express becomes a named marker instead of a
474
+ `RangeError` or a silent `null`. Two identical runs therefore produce
475
+ byte-identical JSON. Nothing redacts the embedded output.
476
+
477
+ `markdown` renders the report an operator reads in a CI log. Every count in
478
+ the summary that is not zero has a section naming its rows: the regressions
479
+ and the nondeterminism a gate reads as red, and the case failures, missing
480
+ observations, and inconclusive observations that leave a gate undecided.
481
+ Every cell and the suite heading value replace each control character (see
482
+ [Baseline](#baseline)) with a space. Backslashes, pipes, backticks, asterisks,
483
+ underscores, brackets, angle brackets, exclamation marks, hashes, tildes,
484
+ ampersands, dots, colons, and at signs are backslash-escaped. This preserves
485
+ literal text instead of interpreting table delimiters, inline GFM formatting,
486
+ links, images, raw HTML, entities, or URL/email autolinks. Each value is capped
487
+ at 240 escaped UTF-16 code units, followed by an ellipsis when truncated;
488
+ escapes and Unicode code points remain whole.
489
+
490
+ ### Gate
491
+
492
+ Score thresholds and the CI exit grade a comparison earns.
493
+
494
+ - `Options`: `{ mean?, min?, perCase? }`. `mean` gates the arithmetic mean of
495
+ every score observation, `min` gates the lowest one, and `perCase` gates
496
+ each named case's lowest score. With none of them set the gate still runs,
497
+ as `mean(0)`, so a run with no score at all is undecidable rather than a
498
+ pass.
499
+
500
+ ```ts
501
+ const check = (report: Regression.Report, options?: Options): Effect.Effect<Verdict, ScoreGateError>
502
+ ```
503
+
504
+ Checks thresholds through the shared ScoreGate arithmetic in
505
+ [@smthrs/scorers/ScoreGate](/api/scorers). The threshold gates always run: an
506
+ unobserved case cannot excuse the cases that were observed. Regressions and
507
+ nondeterminism are findings and force a `Failed` verdict; failed cases and
508
+ missing observations are environment faults and travel beside the verdict. A
509
+ threshold outside [0, 1] fails in the error channel with a `ScoreGateError`,
510
+ code `invalid_threshold`.
511
+
512
+ ```ts
513
+ const ciGrade = (verdict: Verdict): { readonly exitCode: 0 | 1 | 5; readonly summary: string }
514
+ ```
515
+
516
+ Maps a gate verdict to the shared CI convention: a finding is exit code 1, an
517
+ undecidable run is exit code 5, and a clean pass is exit code 0. A pass that
518
+ carries unresolved observations exits 5 as well: the gates it met were met
519
+ over fewer observations than the suite declared. The summary is one log line:
520
+ every control character in it is replaced with a space, so a step key or a
521
+ target failure that reached a verdict reason cannot print its own line or a
522
+ terminal escape.
523
+
524
+ ### Trials
525
+
526
+ Repeated-run statistics for live model evaluations. A case that runs `n`
527
+ times and passes `c` of them is summarized at `k`.
528
+
529
+ ```ts
530
+ const passAtK = (n: number, c: number, k: number): number
531
+ const passHatK = (n: number, c: number, k: number): number
532
+ ```
533
+
534
+ `passAtK` is the unbiased estimator `1 - C(n - c, k) / C(n, k)` (Chen et al.,
535
+ 2021): the chance at least one of `k` runs passes. `passHatK` is
536
+ `C(c, k) / C(n, k)`: the chance all `k` pass, the consistency measure of
537
+ tau-bench. Both are computed as products, so large `n` stays exact. Counts must
538
+ be integers with `0 <= c <= n` and `1 <= k <= n`; anything else throws an
539
+ `EvalError` with code `invalid_trials`.
540
+
541
+ ```ts
542
+ const summarize = (outcomes: ReadonlyArray<boolean>, k: number): Summary
543
+ const aggregate = (cases: Readonly<Record<string, ReadonlyArray<boolean>>>, k: number): Aggregate
544
+ ```
545
+
546
+ - `Summary`: `{ n, passes, rate, passAt1, passAtK, passHatK, stderr }`.
547
+ `stderr` is `sqrt(rate * (1 - rate) / n)`, and 0 for one run.
548
+ - `Aggregate`: `{ cases, passAt1, passAtK, passHatK, allPass, perCase }`. The
549
+ rates are means over cases; `allPass` is the fraction of cases whose every
550
+ run passed. A case with fewer than `k` runs is summarized at `k = n`, and no
551
+ cases aggregate to zeros. A case with no runs throws `invalid_trials`.
552
+
553
+ ## Export index
554
+
555
+ | Export | Category | Summary |
556
+ | ------------------------------- | ------------- | ------------------------------------------------------------------------------- |
557
+ | `EvalError.EvalErrorCode` | models | Stable evaluation failure codes. |
558
+ | `EvalError.EvalError` | errors | A typed failure raised while loading or executing an evaluation. |
559
+ | `Suite.Binding` | models | A scorer binding accepted from `@smthrs/scorers`. |
560
+ | `Suite.Case` | models | One immutable fixed-suite case. |
561
+ | `Suite.MakeOptions` | models | Options for constructing a suite. |
562
+ | `Suite.Suite` | models | A validated, named collection of fixed cases and scorer bindings. |
563
+ | `Suite.limits` | models | The declared ceilings a suite is validated against. |
564
+ | `Suite.make` | constructors | Builds and validates a fixed suite. |
565
+ | `Suite.JsonLinesOptions` | models | Options used when decoding JSON Lines. |
566
+ | `Suite.fromJsonLines` | constructors | Loads the `{ name, input, expected? }` JSON Lines fixture format. |
567
+ | `CaseExecutor.Execution` | models | The result of executing one target-flow case. |
568
+ | `CaseExecutor.Run` | models | The one callback a case executor is. |
569
+ | `CaseExecutor.Service` | services | Runtime shape for an injectable target-flow executor. |
570
+ | `CaseExecutor.CaseExecutor` | services | Injectable execution boundary for a target flow. |
571
+ | `CaseExecutor.make` | constructors | Builds an executor from its one callback. |
572
+ | `CaseExecutor.makeNoop` | constructors | Builds an executor that fails every case with a typed executor error. |
573
+ | `CaseExecutor.layerNoop` | layers | Provides the unavailable executor. |
574
+ | `Runner.Observation` | models | One score observation emitted by a suite run. |
575
+ | `Runner.ScoreRequest` | models | A request sent to the scorers batch runner. |
576
+ | `Runner.ScoreJob` | models | A blocking scorer job, matching the `Runner` module of `@smthrs/scorers`. |
577
+ | `Runner.ScoreBatchRunner` | services | Structural adapter for `@smthrs/scorers`' blocking batch runner. |
578
+ | `Runner.ScoreObservation` | models | A score result aligned with a `ScoreRequest`. |
579
+ | `Runner.BatchResult` | models | A batch result tagged with the identity of the job that produced it. |
580
+ | `Runner.CaseResult` | models | Per-case result retained by the deterministic runner. |
581
+ | `Runner.RunResult` | models | Stable result of a suite run. |
582
+ | `Runner.RunOptions` | models | Options for a deterministic suite run. |
583
+ | `Runner.Runner` | services | Injectable batch-runner service used when a caller wants a reusable adapter. |
584
+ | `Runner.makeInline` | constructors | Builds the in-process batch runner a run scores with by default. |
585
+ | `Runner.layerInline` | layers | Provides the in-process batch runner built by `makeInline`. |
586
+ | `Runner.run` | constructors | Runs a fixed suite with bounded execution and declaration-order results. |
587
+ | `Runner.layerNoop` | layers | Provides a batch runner that is never available. |
588
+ | `Baseline.version` | models | Current committed baseline artifact version. |
589
+ | `Baseline.BaselineRecord` | models | One successful score retained by a baseline. |
590
+ | `Baseline.Baseline` | models | Canonical committed evaluation baseline. |
591
+ | `Baseline.limits` | models | The declared ceilings a committed baseline is validated against. |
592
+ | `Baseline.fromRun` | constructors | Builds and validates a baseline from a run's successful observations. |
593
+ | `Baseline.make` | constructors | Validates an in-memory baseline. |
594
+ | `Baseline.write` | serialization | Serializes a baseline with recursively sorted keys and stable numbers. |
595
+ | `Baseline.load` | serialization | Loads and validates canonical baseline JSON. |
596
+ | `Regression.Tolerances` | models | Tolerances used for score comparisons. |
597
+ | `Regression.Regression` | models | A score drop at a changed step key. |
598
+ | `Regression.Nondeterminism` | models | A changed score at the same step key, indicating nondeterminism. |
599
+ | `Regression.MissingObservation` | models | An observation present on only one side of a comparison. |
600
+ | `Regression.Report` | models | Complete regression comparison. |
601
+ | `Regression.compare` | constructors | Compares a run to a baseline, preserving missing and inconclusive observations. |
602
+ | `Report.version` | serialization | Current serialized report format version. |
603
+ | `Report.Data` | serialization | The serialized shape of a regression report. |
604
+ | `Report.data` | serialization | Projects a comparison into its serialized form. |
605
+ | `Report.json` | serialization | Serializes a regression report as stable, sorted-key JSON. |
606
+ | `Report.markdown` | rendering | Renders a concise stable Markdown regression report. |
607
+ | `Gate.Options` | models | Thresholds accepted by a CI score gate. |
608
+ | `Gate.check` | constructors | Checks thresholds through `@smthrs/scorers`' shared ScoreGate arithmetic. |
609
+ | `Gate.ciGrade` | grading | Maps a gate verdict to the shared CI convention. |
610
+ | `Trials.Summary` | models | Repeated-run statistics for one case. |
611
+ | `Trials.Aggregate` | models | Repeated-run statistics averaged over cases. |
612
+ | `Trials.passAtK` | statistics | The unbiased pass@k estimator. |
613
+ | `Trials.passHatK` | statistics | The chance all `k` of `n` recorded runs pass. |
614
+ | `Trials.summarize` | statistics | Summarizes one case's run outcomes at `k`. |
615
+ | `Trials.aggregate` | statistics | Summarizes every case at `k` and averages over cases. |
616
+
617
+ ## A worked suite
618
+
619
+ [`evals/agent`](https://github.com/smithersai/smithers/tree/main/evals/agent)
620
+ is a suite built on these modules that evaluates the Smithers agent from
621
+ [@smthrs/agent](/api/agent). Each case is a whole agent run against a scripted
622
+ model with no network access, reduced to one observation, and two scorers grade
623
+ it: one asks whether the run did what the case declares, the other asks whether
624
+ the observation is well formed at all. The run is gated on a committed
625
+ baseline. For the shape of that pipeline in your own code, see
626
+ [Gate a run in CI](./guides/gate-a-run-in-ci.md).