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

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/Runner.ts ADDED
@@ -0,0 +1,789 @@
1
+ /**
2
+ * Deterministic fixed-suite execution and bound scorer evaluation.
3
+ *
4
+ * A run executes every case through the injected {@link CaseExecutor}, then
5
+ * grades each execution with the scorers bound to the flow it executed. The
6
+ * timestamp and the run identity come from the caller, so two runs of the same
7
+ * suite over the same inputs produce byte-identical observations.
8
+ *
9
+ * @since 0.1.0
10
+ */
11
+
12
+ import * as ScorerRunner from "@smthrs/scorers/Runner"
13
+ import * as Sampling from "@smthrs/scorers/Sampling"
14
+ import * as Scorer from "@smthrs/scorers/Scorer"
15
+ import type * as ScoreStore from "@smthrs/scorers/ScoreStore"
16
+ import * as Cause from "effect/Cause"
17
+ import * as Context from "effect/Context"
18
+ import * as Effect from "effect/Effect"
19
+ import * as Layer from "effect/Layer"
20
+ import { CaseExecutor, type Execution, type Service as CaseExecutorService } from "./CaseExecutor.ts"
21
+ import { EvalError } from "./EvalError.ts"
22
+ import { flattenControlCharacters } from "./internal/controlCharacters.ts"
23
+ import { scorerLabel } from "./internal/scorerLabel.ts"
24
+ import { tupleKey } from "./internal/tupleKey.ts"
25
+ import type { Binding, Case, Suite } from "./Suite.ts"
26
+ import * as Trials from "./Trials.ts"
27
+
28
+ /**
29
+ * One score observation emitted by a suite run.
30
+ *
31
+ * `scorer` is the scorer key, a digest of the scorer's own declaration, which
32
+ * is what a baseline matches on. `scorerName` is the human name from the same
33
+ * declaration, carried alongside so a report can be read without grepping for
34
+ * the digest. `at` is the run's timestamp, not the scorer's: a run's
35
+ * observations all carry one instant so a baseline stays reproducible.
36
+ *
37
+ * @category models
38
+ * @since 0.1.0
39
+ */
40
+ export type Observation =
41
+ & {
42
+ readonly case: string
43
+ readonly scorer: string
44
+ readonly scorerName?: string | undefined
45
+ readonly stepKey: string
46
+ readonly at: string
47
+ }
48
+ & (
49
+ | {
50
+ readonly kind: "score"
51
+ readonly score: number
52
+ readonly reason?: string | undefined
53
+ readonly meta?: unknown
54
+ }
55
+ | { readonly kind: "inconclusive"; readonly reason: string; readonly meta?: unknown }
56
+ )
57
+
58
+ /**
59
+ * A request sent to the scorers batch runner.
60
+ *
61
+ * @category models
62
+ * @since 0.1.0
63
+ */
64
+ export interface ScoreRequest {
65
+ readonly case: string
66
+ readonly stepKey: string
67
+ readonly binding: Binding
68
+ readonly input: {
69
+ readonly input: unknown
70
+ readonly output: unknown
71
+ readonly groundTruth?: unknown
72
+ readonly context?: unknown
73
+ readonly latencyMs?: number
74
+ }
75
+ }
76
+
77
+ /**
78
+ * A blocking scorer job, matching the `Runner` module of `@smthrs/scorers`.
79
+ *
80
+ * @category models
81
+ * @since 0.1.0
82
+ */
83
+ export type ScoreJob = ScorerRunner.Job
84
+
85
+ /**
86
+ * Structural adapter for `@smthrs/scorers`' blocking batch runner.
87
+ *
88
+ * A runner that implements `runBatchCorrelated` tags every observation with
89
+ * its job identity and may return results in any order. A runner that implements
90
+ * only `runBatch` is correlated positionally: it must return exactly one
91
+ * observation per job, in order, and each observation must echo its job's
92
+ * `targetStepKey` and `scorerKey`. A run refuses the order-only protocol before
93
+ * scoring when two jobs share that pair, because their results could not be
94
+ * attributed to a case.
95
+ *
96
+ * A run fails with `scorer_protocol` when either protocol is broken.
97
+ * `@smthrs/scorers`' `Runner.Service` implements `runBatchCorrelated`, so its
98
+ * service value can be used directly and `ambiguous_score_job` never applies
99
+ * to it.
100
+ *
101
+ * @category services
102
+ * @since 0.1.0
103
+ */
104
+ export interface ScoreBatchRunner {
105
+ readonly runBatch: (
106
+ jobs: ReadonlyArray<ScoreJob>,
107
+ options?: { readonly concurrency?: number | undefined }
108
+ ) => Effect.Effect<ReadonlyArray<ScoreObservation>, unknown>
109
+ readonly runBatchCorrelated?:
110
+ | ((
111
+ jobs: ReadonlyArray<ScoreJob>,
112
+ options?: { readonly concurrency?: number | undefined }
113
+ ) => Effect.Effect<ReadonlyArray<BatchResult>, unknown>)
114
+ | undefined
115
+ }
116
+
117
+ /**
118
+ * A score result aligned with a {@link ScoreRequest}.
119
+ *
120
+ * @category models
121
+ * @since 0.1.0
122
+ */
123
+ export type ScoreObservation = ScoreStore.Observation
124
+
125
+ /**
126
+ * A batch result tagged with the identity of the job that produced it.
127
+ *
128
+ * @category models
129
+ * @since 0.1.0
130
+ */
131
+ export interface BatchResult {
132
+ readonly identity: string
133
+ readonly observation: ScoreObservation
134
+ }
135
+
136
+ /**
137
+ * Per-case result retained by the deterministic runner.
138
+ *
139
+ * @category models
140
+ * @since 0.1.0
141
+ */
142
+ export interface CaseResult {
143
+ readonly case: string
144
+ readonly execution?: Execution | undefined
145
+ readonly error?: EvalError | undefined
146
+ readonly observations: ReadonlyArray<Observation>
147
+ readonly trials?: Trials.Summary | undefined
148
+ }
149
+
150
+ /**
151
+ * Stable result of a suite run.
152
+ *
153
+ * @category models
154
+ * @since 0.1.0
155
+ */
156
+ export interface RunResult {
157
+ readonly runId: string
158
+ readonly suite: string
159
+ readonly cases: ReadonlyArray<CaseResult>
160
+ readonly observations: ReadonlyArray<Observation>
161
+ readonly trials?: Trials.Aggregate | undefined
162
+ readonly k?: number | undefined
163
+ }
164
+
165
+ /**
166
+ * Options for a deterministic suite run.
167
+ *
168
+ * @category models
169
+ * @since 0.1.0
170
+ */
171
+ export interface RunOptions {
172
+ readonly scorer?: ScoreBatchRunner | undefined
173
+ readonly runId: string
174
+ readonly sampleId?: string | undefined
175
+ readonly trials?: number | undefined
176
+ readonly k?: number | undefined
177
+ readonly at: string
178
+ }
179
+
180
+ /**
181
+ * Injectable batch-runner service used when a caller wants a reusable adapter.
182
+ *
183
+ * {@link run} does not require it: a run scores with `options.scorer` when one
184
+ * is passed, otherwise with this service when one is provided, otherwise
185
+ * in process through {@link makeInline}.
186
+ *
187
+ * @category services
188
+ * @since 0.1.0
189
+ */
190
+ export class Runner extends Context.Service<Runner, ScoreBatchRunner>()("flows/evals/Runner") {}
191
+
192
+ /** The longest cause summary a public reason carries. */
193
+ const maxReasonLength = 2048
194
+
195
+ const boundedReason = (reason: string): string =>
196
+ reason.length <= maxReasonLength ? reason : `${reason.slice(0, maxReasonLength)}[truncated]`
197
+
198
+ const scorerKeyOf = (binding: Binding): string => binding.scorer.scorerKey
199
+
200
+ // A scorer is a flow and every flow carries a name, so a run always has one to
201
+ // report. A stored observation may still lack it, which is why `scorerLabel`
202
+ // and the `Observation` field both keep the absent case.
203
+ const scorerNameOf = (binding: Binding): string => binding.scorer.name
204
+
205
+ const label = (binding: Binding): string =>
206
+ scorerLabel({ scorer: scorerKeyOf(binding), scorerName: scorerNameOf(binding) })
207
+
208
+ const inconclusive = (request: ScoreRequest, reason: string, at: string): Observation => {
209
+ return {
210
+ case: request.case,
211
+ scorer: scorerKeyOf(request.binding),
212
+ scorerName: scorerNameOf(request.binding),
213
+ stepKey: request.stepKey,
214
+ kind: "inconclusive",
215
+ reason,
216
+ at
217
+ }
218
+ }
219
+
220
+ // A case failure always locates itself. An executor that named the offending
221
+ // value keeps its own path; one that named nothing still gets the case, because
222
+ // a failure with no path leaves a caller nothing to branch on but prose.
223
+ const casePath = (name: string): string => `cases['${name}']`
224
+
225
+ // The executor boundary handles every non-interruption cause, not only typed
226
+ // failures: an executor that wraps a parser in `Effect.sync` dies with a
227
+ // defect, and one case's defect must not abort its siblings or the report.
228
+ // The wrapped message is flattened because a gate prints it to a CI log, where
229
+ // a newline from the target would let it forge a workflow command; the
230
+ // original message survives untouched on `cause`.
231
+ const caseError = (suiteCase: Case, cause: Cause.Cause<unknown>): EvalError => {
232
+ const reason = Cause.squash(cause)
233
+ return reason instanceof EvalError
234
+ ? new EvalError({
235
+ code: reason.code,
236
+ message: flattenControlCharacters(`Target failed for case '${suiteCase.name}': ${reason.message}`),
237
+ path: reason.path ?? casePath(suiteCase.name),
238
+ cause: reason
239
+ })
240
+ : new EvalError({
241
+ code: "executor",
242
+ message: flattenControlCharacters(`Target failed for case '${suiteCase.name}': ${String(reason)}`),
243
+ path: casePath(suiteCase.name),
244
+ cause: reason
245
+ })
246
+ }
247
+
248
+ // A step key is a runtime value the target returns, so unlike a suite name it
249
+ // is flattened rather than rejected: it reaches baselines, reports and the
250
+ // gate summary, and a control character in any of them corrupts a CI log line.
251
+ // An executor written in plain JavaScript can return any shape, so the two
252
+ // fields every scorer and baseline reads are checked here, where the failure
253
+ // can still name the case and the field.
254
+ const malformedExecution = (suiteCase: Case, execution: Execution): EvalError | undefined => {
255
+ const shape: { readonly stepKey?: unknown; readonly latencyMs?: unknown } =
256
+ typeof execution === "object" && execution !== null ? execution : {}
257
+ if (typeof shape.stepKey !== "string") {
258
+ return new EvalError({
259
+ code: "executor",
260
+ message: `executor returned a stepKey that is not a string (${typeof shape.stepKey})`,
261
+ path: `${casePath(suiteCase.name)}.stepKey`
262
+ })
263
+ }
264
+ if (typeof shape.latencyMs !== "number" || !Number.isFinite(shape.latencyMs) || shape.latencyMs < 0) {
265
+ return new EvalError({
266
+ code: "executor",
267
+ message: `executor returned a latencyMs that is not a finite non-negative number (${
268
+ typeof shape.latencyMs === "number" ? String(shape.latencyMs) : typeof shape.latencyMs
269
+ })`,
270
+ path: `${casePath(suiteCase.name)}.latencyMs`
271
+ })
272
+ }
273
+ return undefined
274
+ }
275
+
276
+ const runCase = (executor: CaseExecutorService, suiteCase: Case): Effect.Effect<CaseResult> =>
277
+ Effect.suspend(() => executor.run(structuredClone(suiteCase))).pipe(
278
+ Effect.flatMap((execution) => {
279
+ const malformed = malformedExecution(suiteCase, execution)
280
+ return malformed === undefined ? Effect.succeed(execution) : Effect.fail(malformed)
281
+ }),
282
+ Effect.map((execution): CaseResult => ({
283
+ case: suiteCase.name,
284
+ execution: { ...execution, stepKey: flattenControlCharacters(execution.stepKey) },
285
+ observations: []
286
+ })),
287
+ Effect.catchCause((cause) =>
288
+ Cause.hasInterrupts(cause)
289
+ ? Effect.interrupt
290
+ : Effect.succeed<CaseResult>({ case: suiteCase.name, error: caseError(suiteCase, cause), observations: [] })
291
+ ),
292
+ Effect.withSpan("Eval.case", { attributes: { case: suiteCase.name } })
293
+ )
294
+
295
+ // A declared case value is ground truth even when it is `null`, `false`, `0`
296
+ // or the empty string; only an absent `expected` defers to the binding.
297
+ const groundTruthFor = (suiteCase: Case, binding: Binding): { readonly groundTruth?: unknown } => {
298
+ const groundTruth = suiteCase.expected !== undefined ? suiteCase.expected : binding.groundTruth
299
+ return groundTruth === undefined ? {} : { groundTruth: structuredClone(groundTruth) }
300
+ }
301
+
302
+ const requestFor = (suiteCase: Case, execution: Execution, binding: Binding): ScoreRequest => ({
303
+ case: suiteCase.name,
304
+ stepKey: execution.stepKey,
305
+ binding: {
306
+ ...structuredClone({
307
+ sampling: binding.sampling,
308
+ ...(binding.groundTruth === undefined ? {} : { groundTruth: binding.groundTruth }),
309
+ ...(binding.context === undefined ? {} : { context: binding.context })
310
+ }),
311
+ scorer: binding.scorer,
312
+ appliesTo: binding.appliesTo
313
+ },
314
+ input: {
315
+ input: structuredClone(suiteCase.input),
316
+ output: execution.output,
317
+ ...(groundTruthFor(suiteCase, binding)),
318
+ ...(binding.context === undefined ? {} : { context: structuredClone(binding.context) }),
319
+ latencyMs: execution.latencyMs
320
+ }
321
+ })
322
+
323
+ const observationFor = (request: ScoreRequest, result: ScoreObservation, at: string): Observation => {
324
+ const source = result as {
325
+ readonly kind: unknown
326
+ readonly score?: unknown
327
+ readonly reason?: unknown
328
+ readonly meta?: unknown
329
+ }
330
+ const kind = source.kind
331
+ if (kind !== "score" && kind !== "inconclusive") {
332
+ return inconclusive(
333
+ request,
334
+ boundedReason(`Scorer ${label(request.binding)} returned an unusable observation kind '${String(kind)}'`),
335
+ at
336
+ )
337
+ }
338
+ const reason = source.reason
339
+ if (kind === "inconclusive") {
340
+ return inconclusive(
341
+ request,
342
+ typeof reason === "string" && reason.length > 0
343
+ ? boundedReason(reason)
344
+ : boundedReason(`Scorer ${label(request.binding)} returned an inconclusive observation with no reason`),
345
+ at
346
+ )
347
+ }
348
+ const score = source.score
349
+ if (typeof score !== "number" || !Number.isFinite(score) || score < 0 || score > 1) {
350
+ return inconclusive(
351
+ request,
352
+ boundedReason(`Scorer ${label(request.binding)} returned a score outside [0, 1]: ${String(score)}`),
353
+ at
354
+ )
355
+ }
356
+ const meta = source.meta
357
+ return {
358
+ case: request.case,
359
+ scorer: scorerKeyOf(request.binding),
360
+ scorerName: scorerNameOf(request.binding),
361
+ stepKey: request.stepKey,
362
+ kind: "score",
363
+ score,
364
+ ...(typeof reason === "string" ? { reason: boundedReason(reason) } : {}),
365
+ ...(meta === undefined ? {} : { meta }),
366
+ at
367
+ }
368
+ }
369
+
370
+ const executeInline = (job: ScoreJob): Effect.Effect<ScoreObservation> =>
371
+ job.score.pipe(
372
+ Effect.flatMap(Scorer.validate),
373
+ Effect.map((result): ScoreStore.ScoreObservation => ({
374
+ ...job.observation,
375
+ kind: "score",
376
+ score: result.score,
377
+ ...(result.reason === undefined ? {} : { reason: result.reason }),
378
+ ...(result.meta === undefined ? {} : { meta: result.meta }),
379
+ at: job.at
380
+ })),
381
+ Effect.catchCause((cause) =>
382
+ Cause.hasInterrupts(cause)
383
+ ? Effect.interrupt
384
+ : Effect.succeed(ScorerRunner.inconclusive(job, cause))
385
+ )
386
+ )
387
+
388
+ /**
389
+ * Builds the in-process batch runner a run scores with by default.
390
+ *
391
+ * Each job's scorer runs in the current process, its result is checked by
392
+ * `@smthrs/scorers`' own `Scorer.validate`, and a scorer that fails becomes an
393
+ * inconclusive observation naming its cause rather than failing the run. It
394
+ * honours the {@link ScoreBatchRunner} protocol, so it is also the reference
395
+ * a custom adapter can be compared against.
396
+ *
397
+ * @category constructors
398
+ * @since 0.1.0
399
+ */
400
+ export const makeInline = (): ScoreBatchRunner => {
401
+ const runBatchCorrelated = (
402
+ jobs: ReadonlyArray<ScoreJob>,
403
+ options?: { readonly concurrency?: number | undefined }
404
+ ): Effect.Effect<ReadonlyArray<BatchResult>> =>
405
+ Effect.forEach(
406
+ jobs,
407
+ (job) => executeInline(job).pipe(Effect.map((observation) => ({ identity: job.identity, observation }))),
408
+ { concurrency: options?.concurrency ?? 1 }
409
+ )
410
+ return {
411
+ runBatchCorrelated,
412
+ runBatch: (jobs, options) =>
413
+ runBatchCorrelated(jobs, options).pipe(Effect.map((results) => results.map((result) => result.observation)))
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Provides the in-process batch runner built by {@link makeInline}.
419
+ *
420
+ * @category layers
421
+ * @since 0.1.0
422
+ */
423
+ export const layerInline: Layer.Layer<Runner> = Layer.succeed(Runner)(makeInline())
424
+
425
+ interface ScoreContext {
426
+ readonly at: string
427
+ readonly runId: string
428
+ readonly sampleId: string
429
+ readonly concurrency: number
430
+ }
431
+
432
+ const buildJobs = (
433
+ cases: ReadonlyArray<CaseResult>,
434
+ suite: Suite,
435
+ { at, runId, sampleId, concurrency }: ScoreContext
436
+ ): Effect.Effect<
437
+ { readonly requests: ReadonlyArray<ScoreRequest>; readonly jobs: ReadonlyArray<ScoreJob> },
438
+ EvalError
439
+ > =>
440
+ Effect.gen(function*() {
441
+ // `cases` is the result of one `Effect.forEach` over `suite.cases`, so the
442
+ // two arrays are index-aligned by construction; nothing here has to look a
443
+ // case back up by name.
444
+ const candidates = cases.flatMap((caseResult, index) => {
445
+ const execution = caseResult.execution
446
+ if (execution === undefined) return []
447
+ const suiteCase = suite.cases[index]!
448
+ return suite.bindings.flatMap((binding, bindingIndex) =>
449
+ binding.appliesTo === execution.target
450
+ ? [{ request: requestFor(suiteCase, execution, binding), bindingIndex }]
451
+ : []
452
+ )
453
+ })
454
+ const sampled = yield* Effect.forEach(
455
+ candidates,
456
+ ({ request, bindingIndex }) =>
457
+ Sampling.decide(
458
+ request.binding.sampling,
459
+ request.stepKey,
460
+ scorerKeyOf(request.binding)
461
+ ).pipe(
462
+ Effect.map((selected) => selected ? request : undefined),
463
+ Effect.mapError((cause) =>
464
+ new EvalError({
465
+ code: "invalid_suite",
466
+ message: `Invalid sampling policy for scorer ${label(request.binding)}`,
467
+ path: `bindings[${bindingIndex}].sampling`,
468
+ cause
469
+ })
470
+ )
471
+ ),
472
+ { concurrency }
473
+ )
474
+ const requests = sampled.filter((request): request is ScoreRequest => request !== undefined)
475
+ const jobs: Array<ScoreJob> = requests.map((request, index) => ({
476
+ identity: tupleKey(
477
+ suite.name,
478
+ runId,
479
+ sampleId,
480
+ request.case,
481
+ request.stepKey,
482
+ scorerKeyOf(request.binding),
483
+ `${index}`
484
+ ),
485
+ observation: { targetStepKey: request.stepKey, scorerKey: scorerKeyOf(request.binding) },
486
+ score: Effect.suspend(() => request.binding.scorer.score(request.input)),
487
+ at: Date.parse(at)
488
+ }))
489
+ return { requests, jobs }
490
+ })
491
+
492
+ const batchFallback = (
493
+ jobs: ReadonlyArray<ScoreJob>,
494
+ cause: Cause.Cause<unknown>
495
+ ): Effect.Effect<ReadonlyArray<ScoreObservation>> =>
496
+ Cause.hasInterrupts(cause)
497
+ ? Effect.interrupt
498
+ : Effect.succeed(jobs.map((job) => ScorerRunner.inconclusive(job, cause)))
499
+
500
+ const scoreCorrelated = (
501
+ jobs: ReadonlyArray<ScoreJob>,
502
+ scorer: ScoreBatchRunner,
503
+ runBatchCorrelated: NonNullable<ScoreBatchRunner["runBatchCorrelated"]>,
504
+ { concurrency }: ScoreContext
505
+ ): Effect.Effect<ReadonlyArray<ScoreObservation>, EvalError> =>
506
+ Effect.gen(function*() {
507
+ const batchResults = yield* Effect.suspend(() => runBatchCorrelated.call(scorer, jobs, { concurrency })).pipe(
508
+ Effect.catchCause((cause) =>
509
+ batchFallback(jobs, cause).pipe(
510
+ Effect.map((observations) =>
511
+ observations.map((observation, index) => ({
512
+ identity: jobs[index]!.identity,
513
+ observation
514
+ }))
515
+ )
516
+ )
517
+ )
518
+ )
519
+ if (batchResults.length !== jobs.length) {
520
+ return yield* Effect.fail(
521
+ new EvalError({
522
+ code: "scorer_protocol",
523
+ message:
524
+ `Correlated scorer batch returned ${batchResults.length} results for ${jobs.length} jobs; a batch runner must return exactly one result per job identity`,
525
+ path: "runBatchCorrelated"
526
+ })
527
+ )
528
+ }
529
+ const jobIndexByIdentity = new Map(jobs.map((job, index) => [job.identity, index] as const))
530
+ const byIdentity = new Map<string, ScoreObservation>()
531
+ let unknown: { readonly identity: string; readonly index: number } | undefined
532
+ for (const [index, result] of batchResults.entries()) {
533
+ const jobIndex = jobIndexByIdentity.get(result.identity)
534
+ if (jobIndex !== undefined && byIdentity.has(result.identity)) {
535
+ return yield* Effect.fail(
536
+ new EvalError({
537
+ code: "scorer_protocol",
538
+ message:
539
+ `Correlated scorer batch returned duplicate identity '${result.identity}' for job ${jobIndex} at result index ${index}`,
540
+ path: `runBatchCorrelated[${index}]`
541
+ })
542
+ )
543
+ }
544
+ if (jobIndex === undefined) unknown = { identity: result.identity, index }
545
+ byIdentity.set(result.identity, result.observation)
546
+ }
547
+ if (unknown !== undefined) {
548
+ const missingJobIndex = jobs.findIndex((job) => !byIdentity.has(job.identity))
549
+ const missingIdentity = jobs[missingJobIndex]!.identity
550
+ return yield* Effect.fail(
551
+ new EvalError({
552
+ code: "scorer_protocol",
553
+ message:
554
+ `Correlated scorer batch returned unknown identity '${unknown.identity}' at result index ${unknown.index}; job ${missingJobIndex} identity '${missingIdentity}' is absent`,
555
+ path: `runBatchCorrelated[${unknown.index}]`
556
+ })
557
+ )
558
+ }
559
+ return jobs.map((job) => byIdentity.get(job.identity)!)
560
+ })
561
+
562
+ const scoreOrderOnly = (
563
+ jobs: ReadonlyArray<ScoreJob>,
564
+ requests: ReadonlyArray<ScoreRequest>,
565
+ scorer: ScoreBatchRunner,
566
+ { concurrency }: ScoreContext
567
+ ): Effect.Effect<ReadonlyArray<ScoreObservation>, EvalError> =>
568
+ Effect.gen(function*() {
569
+ const pairIndexes = new Map<string, number>()
570
+ for (const [index, job] of jobs.entries()) {
571
+ const pair = tupleKey(job.observation.targetStepKey, job.observation.scorerKey)
572
+ const previous = pairIndexes.get(pair)
573
+ if (previous !== undefined) {
574
+ const first = requests[previous]!
575
+ const second = requests[index]!
576
+ return yield* Effect.fail(
577
+ new EvalError({
578
+ code: "ambiguous_score_job",
579
+ message:
580
+ `Cannot attribute score jobs for cases '${first.case}' and '${second.case}': both use step key '${job.observation.targetStepKey}' and scorer ${
581
+ label(second.binding)
582
+ }. Give each case its own step key, or provide a batch runner that implements runBatchCorrelated`,
583
+ path: "runBatch"
584
+ })
585
+ )
586
+ }
587
+ pairIndexes.set(pair, index)
588
+ }
589
+ const results = yield* Effect.suspend(() => scorer.runBatch(jobs, { concurrency })).pipe(
590
+ Effect.catchCause((cause) => batchFallback(jobs, cause))
591
+ )
592
+ if (results.length !== jobs.length) {
593
+ return yield* Effect.fail(
594
+ new EvalError({
595
+ code: "scorer_protocol",
596
+ message:
597
+ `Scorer batch returned ${results.length} observations for ${jobs.length} jobs; a batch runner must return exactly one observation per job, in order`,
598
+ path: "runBatch"
599
+ })
600
+ )
601
+ }
602
+ return results
603
+ })
604
+
605
+ const score = (
606
+ cases: ReadonlyArray<CaseResult>,
607
+ suite: Suite,
608
+ scorer: ScoreBatchRunner,
609
+ context: ScoreContext
610
+ ): Effect.Effect<ReadonlyArray<CaseResult>, EvalError> =>
611
+ Effect.gen(function*() {
612
+ const { requests, jobs } = yield* buildJobs(cases, suite, context)
613
+ if (requests.length === 0) return cases
614
+ const runBatchCorrelated = scorer.runBatchCorrelated
615
+ const protocol = runBatchCorrelated === undefined ? "runBatch" : "runBatchCorrelated"
616
+ const results = yield* runBatchCorrelated === undefined
617
+ ? scoreOrderOnly(jobs, requests, scorer, context)
618
+ : scoreCorrelated(jobs, scorer, runBatchCorrelated, context)
619
+ const { at } = context
620
+ const observations: Array<Observation> = []
621
+ for (const [index, request] of requests.entries()) {
622
+ const result = results[index]!
623
+ if (result.targetStepKey !== request.stepKey || result.scorerKey !== scorerKeyOf(request.binding)) {
624
+ return yield* Effect.fail(
625
+ new EvalError({
626
+ code: "scorer_protocol",
627
+ message: protocol === "runBatchCorrelated"
628
+ ? `Correlated scorer batch identity '${
629
+ jobs[index]!.identity
630
+ }' returned step '${result.targetStepKey}' and scorer '${result.scorerKey}' where job ${index} asked for scorer '${
631
+ scorerKeyOf(request.binding)
632
+ }' at step '${request.stepKey}'`
633
+ : `Scorer batch returned an observation for '${result.scorerKey}' at step '${result.targetStepKey}' where job ${index} asked for '${
634
+ scorerKeyOf(request.binding)
635
+ }' at step '${request.stepKey}'; results must stay aligned with their jobs`,
636
+ path: `${protocol}[${index}]`
637
+ })
638
+ )
639
+ }
640
+ observations.push(observationFor(request, result, at))
641
+ }
642
+ const byCase = new Map<string, Array<Observation>>()
643
+ for (const observation of observations) {
644
+ const bucket = byCase.get(observation.case)
645
+ if (bucket === undefined) byCase.set(observation.case, [observation])
646
+ else bucket.push(observation)
647
+ }
648
+ return cases.map((caseResult) => ({
649
+ ...caseResult,
650
+ observations: byCase.get(caseResult.case) ?? []
651
+ }))
652
+ })
653
+
654
+ /**
655
+ * Runs a fixed suite with bounded execution and declaration-order results.
656
+ *
657
+ * Cases run through the provided {@link CaseExecutor} at the suite's
658
+ * concurrency and are returned in declaration order. Each executor receives
659
+ * its own mutable case copy. Each scorer receives independent copies of the
660
+ * original case input, expected value, and binding data. Every execution is then
661
+ * graded by the bindings whose `appliesTo` is the flow the execution reports as
662
+ * its target, matched by reference identity, so a binding attached to another
663
+ * flow contributes no observations.
664
+ *
665
+ * Scoring goes through `options.scorer` when the caller passes one, the
666
+ * {@link Runner} service when one is provided, and {@link makeInline}
667
+ * otherwise. A case whose target failed keeps its typed error, locates itself
668
+ * with the executor's own `path` or with `cases['<name>']`, and produces no
669
+ * observations; a scorer that failed produces an inconclusive observation. The
670
+ * run itself fails only with `invalid_run_options` for a non-canonical run
671
+ * identity or timestamp, `invalid_suite` for an unusable sampling policy,
672
+ * `ambiguous_score_job` when an order-only runner cannot distinguish two jobs,
673
+ * or `scorer_protocol` for a batch runner that broke the
674
+ * {@link ScoreBatchRunner} contract.
675
+ *
676
+ * @category constructors
677
+ * @since 0.1.0
678
+ */
679
+ export const run = (
680
+ suite: Suite,
681
+ options: RunOptions
682
+ ): Effect.Effect<RunResult, EvalError, CaseExecutor> =>
683
+ Effect.gen(function*() {
684
+ const executor = yield* CaseExecutor
685
+ const injected = yield* Effect.serviceOption(Runner)
686
+ const scorer = options.scorer ?? (injected._tag === "Some" ? injected.value : makeInline())
687
+ const atMillis = Date.parse(options.at)
688
+ if (options.runId.trim().length === 0) {
689
+ return yield* Effect.fail(
690
+ new EvalError({
691
+ code: "invalid_run_options",
692
+ message: "Deterministic runs require a non-empty runId",
693
+ path: "options.runId"
694
+ })
695
+ )
696
+ }
697
+ if (
698
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(options.at) ||
699
+ !Number.isFinite(atMillis) ||
700
+ new Date(atMillis).toISOString() !== options.at
701
+ ) {
702
+ return yield* Effect.fail(
703
+ new EvalError({
704
+ code: "invalid_run_options",
705
+ message:
706
+ `Deterministic runs require a canonical UTC timestamp such as 2026-01-01T00:00:00.000Z, got '${options.at}'`,
707
+ path: "options.at"
708
+ })
709
+ )
710
+ }
711
+ const n = options.trials ?? suite.trials ?? 1
712
+ const k = options.k ?? 1
713
+ if (!Number.isSafeInteger(n) || n < 1 || n > 1000 || !Number.isSafeInteger(k) || k < 1 || k > n) {
714
+ return yield* Effect.fail(
715
+ new EvalError({
716
+ code: "invalid_trials",
717
+ message: "trials must be an integer from 1 to 1000 and k must be an integer from 1 to trials",
718
+ path: "options.trials"
719
+ })
720
+ )
721
+ }
722
+ const at = options.at
723
+ const runId = options.runId
724
+ const sampleId = options.sampleId ?? "default"
725
+ // Await each batch before starting the next: concurrency within a trial is
726
+ // bounded by the suite, and neither scheduling nor scorer latency reorders trials.
727
+ const batches = yield* Effect.forEach(Array.from({ length: n }, (_, index) => index), (index) =>
728
+ Effect.gen(function*() {
729
+ const cases = yield* Effect.forEach(suite.cases, (suiteCase) =>
730
+ runCase(executor, suiteCase), {
731
+ concurrency: suite.concurrency,
732
+ discard: false
733
+ })
734
+ return yield* score(cases, suite, scorer, {
735
+ concurrency: suite.concurrency,
736
+ at,
737
+ runId,
738
+ sampleId: n === 1 ? sampleId : tupleKey(sampleId, String(index))
739
+ })
740
+ }), { concurrency: 1 })
741
+ const outcomes: Array<[string, ReadonlyArray<boolean>]> = []
742
+ const cases = suite.cases.map((suiteCase, index) => {
743
+ const attempts = batches.map((batch) =>
744
+ batch[index]!
745
+ )
746
+ const passed = attempts.map((attempt) =>
747
+ attempt.error === undefined && attempt.observations.length > 0 &&
748
+ attempt.observations.every((observation) => observation.kind === "score" && observation.score === 1)
749
+ )
750
+ outcomes.push([suiteCase.name, passed])
751
+ return {
752
+ ...attempts[0]!,
753
+ error: attempts.find((attempt) => attempt.error !== undefined)?.error,
754
+ observations: attempts.flatMap((attempt) => attempt.observations),
755
+ trials: Trials.summarize(passed, k)
756
+ }
757
+ })
758
+ return {
759
+ runId,
760
+ suite: suite.name,
761
+ cases,
762
+ observations: cases.flatMap((caseResult) => caseResult.observations),
763
+ // fromEntries defines own properties, so a case named `__proto__` stays a case.
764
+ trials: Trials.aggregate(Object.fromEntries(outcomes), k),
765
+ k
766
+ }
767
+ }).pipe(Effect.withSpan("Eval.run", { attributes: { suite: suite.name, runId: options.runId } }))
768
+
769
+ /**
770
+ * Provides a batch runner that is never available.
771
+ *
772
+ * Every bound score under this layer becomes an inconclusive observation, which
773
+ * a gate grades as an undecidable run rather than a red. Provide it to state
774
+ * that a suite must not score, and {@link layerInline} to score in process.
775
+ *
776
+ * @category layers
777
+ * @since 0.1.0
778
+ */
779
+ export const layerNoop: Layer.Layer<Runner> = Layer.sync(Runner)(() => {
780
+ const unavailable = new EvalError({ code: "scorer_unavailable", message: "No scorer batch runner is available" })
781
+ return {
782
+ runBatch: () => Effect.fail(unavailable),
783
+ runBatchCorrelated: (jobs) =>
784
+ Effect.succeed(jobs.map((job) => ({
785
+ identity: job.identity,
786
+ observation: ScorerRunner.inconclusive(job, unavailable)
787
+ })))
788
+ }
789
+ })