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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +62 -2
  4. package/dist/cjs/Baseline.d.ts +118 -0
  5. package/dist/cjs/Baseline.d.ts.map +1 -0
  6. package/dist/cjs/Baseline.js +158 -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 +191 -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 +430 -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 +118 -0
  62. package/dist/esm/Baseline.d.ts.map +1 -0
  63. package/dist/esm/Baseline.js +203 -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 +195 -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 +430 -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 +116 -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 +130 -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 +46 -0
  127. package/docs/quickstart.md +238 -0
  128. package/docs/troubleshooting.md +148 -0
  129. package/package.json +198 -3
  130. package/src/Baseline.ts +259 -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 +314 -0
  135. package/src/Report.ts +268 -0
  136. package/src/Runner.ts +759 -0
  137. package/src/Suite.ts +358 -0
  138. package/src/Trials.ts +154 -0
  139. package/src/index.ts +76 -0
  140. package/src/internal/CanonicalJson.ts +234 -0
  141. package/src/internal/controlCharacters.ts +66 -0
  142. package/src/internal/scorerLabel.ts +35 -0
  143. package/src/internal/tupleKey.ts +20 -0
@@ -0,0 +1,64 @@
1
+ ---
2
+ title: "Determinism and canonical artifacts"
3
+ description: "Caller-supplied run identity, deterministic sampling, and byte-stable serialization."
4
+ sidebar:
5
+ order: 3
6
+ ---
7
+
8
+ The runner guarantees determinism only for what it controls. Given identical
9
+ executor results, scorer results, metadata, and run options, two runs produce
10
+ byte-identical observations, and two identical comparisons produce
11
+ byte-identical reports. Ordering, sampling, job identity, and serialization are
12
+ stable by construction, through four decisions.
13
+
14
+ The runner cannot make your callbacks deterministic. `Runner.run` invokes every
15
+ executor and scorer effect afresh on each run, so the caller owns every input
16
+ those effects read: clocks, model responses, randomness, external state, and
17
+ mutation inside a callback. A scorer that counts its own calls grades the same
18
+ suite `0` on the first run and `1` on the second, with identical options.
19
+
20
+ ## Identity and time come from the caller
21
+
22
+ `Runner.run` takes `runId` and `at` as options. `runId` identifies the
23
+ `RunResult` and joins every score job's identity; `at` stamps every
24
+ observation. The runner itself reads no clock and generates no identifier.
25
+ Pin `at` to a fixed instant when you want a suite to be comparable across days:
26
+ observation timestamps are report material, and pinning them keeps two runs
27
+ with stable callbacks byte-identical.
28
+
29
+ The format is strict because reproducibility is strict: `at` must be a
30
+ canonical UTC timestamp with millisecond precision that parses and re-renders
31
+ to itself, and anything else fails with `invalid_run_options`.
32
+
33
+ ## Sampling is decided, not rolled
34
+
35
+ A binding's sampling policy selects which executions a scorer grades, and the
36
+ selection is a pure function of the policy, the step key, and the scorer key.
37
+ The same run over the same inputs samples the same executions every time, so a
38
+ sampled baseline stays comparable with the next sampled run. For the policy
39
+ types, see the [scorers API](/api/scorers).
40
+
41
+ ## Job identity is injective
42
+
43
+ Every score job carries an identity built from the suite name, run identity,
44
+ sample identity, case, step key, scorer key, and the job's index, encoded as a
45
+ JSON array. Encoding a tuple rather than joining strings on a delimiter is
46
+ what makes the identity injective: two distinct jobs can never produce one
47
+ identity, so a correlated batch runner can always attribute its results.
48
+
49
+ ## Serialization is total and canonical
50
+
51
+ `Baseline.write` and `Report.json` share one encoder. It sorts object keys by
52
+ code unit, drops `undefined` members, and normalizes negative zero. Everything
53
+ JSON cannot express becomes a bracketed marker that names what was there:
54
+ `[circular]`, `[depth exceeded]`, `[NaN]`, `[Infinity]`, `[-Infinity]`,
55
+ `[bigint n]`, `[function]`, `[symbol]`, and `[unreadable: …]` when a foreign
56
+ operation throws. An `Error` becomes an object holding its own fields plus
57
+ `name` and `message`. A `Date` becomes its ISO string, a `Set` becomes an
58
+ array, and a `Map` becomes an array of key/value pairs. Embedded strings are
59
+ capped at 8192 code units and nesting at 64 levels.
60
+
61
+ The encoder is total: a report of a broken run is still a report, never a
62
+ thrown `RangeError` out of a function typed `string`. And it redacts nothing:
63
+ a suite whose cases carry secrets must not print the report where the log is
64
+ readable.
@@ -0,0 +1,71 @@
1
+ ---
2
+ title: "Fixed suites"
3
+ description: "Why a suite is validated once, snapshotted, and frozen, and how a binding finds its target."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ A fixed suite is the input half of an evaluation: named cases, the scorer
9
+ bindings that grade them, and the concurrency the runner is allowed. "Fixed"
10
+ is the load-bearing property. A committed baseline says "this suite scored
11
+ these numbers", and that sentence is only meaningful when the suite that
12
+ produced the baseline is the suite being run. So a suite is validated once, at
13
+ construction, and then never changes.
14
+
15
+ ## The snapshot
16
+
17
+ `Suite.make` reads every option, case field, and binding field once when the
18
+ effect runs. It validates the data before copying it with `structuredClone`,
19
+ then deeply freezes the suite, cases, binding records, and copied data.
20
+ Mutating the caller's data or attempting to edit the returned snapshot cannot
21
+ change the validated suite.
22
+
23
+ Case input, expected values, and binding sampling, ground truth, and context
24
+ admit only these values:
25
+
26
+ - Plain objects with `Object.prototype` or a null prototype, and ordinary arrays.
27
+ - Enumerable, string-keyed data properties. Accessors, symbol keys, and
28
+ non-enumerable properties are rejected, except for the array `length` property.
29
+ - Cloneable primitives: null, undefined, booleans, strings, numbers, and bigints.
30
+ Numbers include NaN, infinities, and negative zero.
31
+
32
+ Cycles and shared references within a data field are preserved. Null-prototype
33
+ records become ordinary objects when cloned. Functions, symbols, class
34
+ instances, and other non-plain objects fail with `invalid_suite` at the
35
+ nested field path. This includes Map, Set, Date, RegExp, buffers, and typed
36
+ arrays: freezing these objects does not protect all their mutable state.
37
+ Nested getters are rejected without invocation. Getters on the outer options,
38
+ case, and binding fields are read once before data validation.
39
+
40
+ Each executor invocation receives its own mutable case copy. Each scorer
41
+ request receives independent copies of the original case input, expected
42
+ value, and binding data. Executor mutations do not change scorer ground
43
+ truth or input, and scorer mutations do not change another request or a later
44
+ run. Sequential and concurrent runs reuse the same protected snapshot.
45
+ Execution output remains the executor's value and is outside the fixed-suite
46
+ data contract.
47
+
48
+ ## Bindings and identity
49
+
50
+ A binding says "this scorer grades that flow", and the match is by reference
51
+ identity: a run grades an execution with a binding only when the execution's
52
+ `target` is the very flow value the binding's `appliesTo` holds. A structurally
53
+ equal copy of the flow is graded by nothing.
54
+
55
+ Identity is what makes a binding durable. Two declarations of a flow with the
56
+ same name are different flows, and a baseline recorded against one says
57
+ nothing about the other. Reference identity makes that distinction exact
58
+ instead of approximate.
59
+
60
+ The scorer has the same split between identity and label. `Observation.scorer`
61
+ is the scorer key, a digest of the scorer's own `{ id, version, config }`
62
+ declaration, and it is what a baseline matches on: change the declaration and
63
+ the old scores no longer claim to describe the new scorer.
64
+ `Observation.scorerName` is the human name from the same declaration, carried
65
+ alongside so a report can be read without grepping for the digest. A Markdown
66
+ report prints `name (first 8 of the key)`, and the bare key when the
67
+ declaration carries no name. Report cells, gate summaries, and runner
68
+ diagnostics share that one rule, so a report never names one scorer two ways.
69
+
70
+ For the scorer, binding, and sampling types themselves, see the
71
+ [scorers API](/api/scorers).
@@ -0,0 +1,60 @@
1
+ ---
2
+ title: "Step keys and comparison"
3
+ description: "The rule that separates a regression from nondeterminism, and how observations pair with baseline records."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ One rule decides what a comparison reports, and it is the mental model the
9
+ whole package is built around: the step key identifies the work a case
10
+ produced.
11
+
12
+ - A score that dropped at a changed step key is a regression: the target
13
+ produced different work, and the different work graded worse.
14
+ - A score that moved at an unchanged step key is nondeterminism: the same work
15
+ graded differently twice.
16
+
17
+ Both are results, and a gate reads both as red. They are different reds: a
18
+ regression points at the target, nondeterminism points at a target or a scorer
19
+ that does not repeat itself.
20
+
21
+ The step key is why the executor contract asks for a key that is stated, not
22
+ derived. If the key were a hash of the output, every output change would read
23
+ as new work and nondeterminism would be unreportable. If the key never
24
+ changed, different work would read as the same work. The executor names the
25
+ step; the comparison believes it.
26
+
27
+ ## Pairing
28
+
29
+ A scorer may fire several times against one case, so comparison cannot zip two
30
+ arrays by position. `Regression.compare` groups baseline records and run
31
+ observations by `(case, scorer)`, then pairs them:
32
+
33
+ 1. Records and observations that share a step key pair first, lowest score to
34
+ lowest score, so a repeated scorer does not report a move that never
35
+ happened.
36
+ 2. Whatever is left over pairs in stable `(stepKey, score)` order.
37
+ 3. An unpaired record on either side is a missing observation, never a silent
38
+ drop. `side: "run"` names a baseline record the run never reproduced;
39
+ `side: "baseline"` names a score no baseline record accounts for.
40
+
41
+ A score that improved at a changed step key is not reported: the comparison
42
+ exists to catch deterioration, and an improvement at a changed key is new work
43
+ that graded better.
44
+
45
+ ## Tolerances
46
+
47
+ A move is reported only when it exceeds both tolerances: the absolute
48
+ difference and the difference relative to the baseline score. Either one alone
49
+ is enough to silence a move, and both default to 0, which reports every move.
50
+ The relative check divides by the baseline score guarded away from zero, so a
51
+ baseline score of exactly 0 cannot make a later move look infinitely large or
52
+ infinitely small.
53
+
54
+ ## What comparison preserves
55
+
56
+ Inconclusive observations decide nothing, and the comparison carries them
57
+ through untouched rather than dropping them: a gate has to see them, because a
58
+ scorer that could not answer is an environment fault, not a measurement.
59
+ Failed cases never reach the comparison at all. They live on the run's case
60
+ results, and the gate reads them from there.
@@ -0,0 +1,115 @@
1
+ ---
2
+ title: "Author a suite"
3
+ description: "Declare fixed cases and scorer bindings, or load them from a JSON Lines fixture."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ A suite is the fixed input half of an evaluation: named cases, the scorer
9
+ bindings that grade them, and the concurrency the runner is allowed. Build one
10
+ with `Suite.make`, or load cases from a JSON Lines fixture with
11
+ `Suite.fromJsonLines`.
12
+
13
+ ## Declare cases
14
+
15
+ A case is a name, an `input` handed to the executor, and an optional
16
+ `expected` offered to a bound scorer as ground truth:
17
+
18
+ ```ts
19
+ const cases = [
20
+ { name: "adds numbers", input: { left: 1, right: 2 }, expected: 3 },
21
+ { name: "multiplies numbers", input: { left: 2, right: 4 }, expected: 8 }
22
+ ]
23
+ ```
24
+
25
+ When a case declares `expected` and the binding also declares `groundTruth`,
26
+ the case's value wins. A declared `null`, `false`, `0` or `""` is a value and
27
+ wins too; only an absent `expected` defers to the binding. When neither
28
+ declares one, the scorer is called without ground truth.
29
+
30
+ Case names must be non-empty, unique within the suite, and free of control
31
+ characters. A control character in a name corrupts Markdown reports and CI log
32
+ lines, so it is rejected where the name enters the system.
33
+
34
+ ## Bind scorers
35
+
36
+ A binding attaches a scorer to the flow it grades:
37
+
38
+ ```ts
39
+ import { Binding } from "@smthrs/scorers"
40
+
41
+ const bindings = [Binding.make({ scorer: polite, appliesTo: greet })]
42
+ ```
43
+
44
+ `appliesTo` is matched against an execution's `target` by reference identity,
45
+ so the binding grades only the exact flow value it was declared against; a
46
+ structurally equal copy is graded by nothing. `Binding.make` also accepts
47
+ `groundTruth`, `context`, and a `sampling` policy, and defaults to scoring
48
+ every target step. For the binding and sampling types, see the
49
+ [scorers API](/api/scorers).
50
+
51
+ `Suite.make` copies each binding's data fields with `structuredClone` but
52
+ keeps `scorer` and `appliesTo` by reference: they are executable identities,
53
+ and copying them would break the identity match.
54
+
55
+ ## Validate at construction
56
+
57
+ `Suite.make` fails with `invalid_suite` when the declaration is wrong, and
58
+ each failure locates the offending value in `path`:
59
+
60
+ | Failure | Path |
61
+ | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
62
+ | Suite name is empty or holds a control character | `name` |
63
+ | No cases, or more than `limits.cases` cases | `cases` |
64
+ | Concurrency is not a safe integer in [1, `limits.concurrency`] | `concurrency` |
65
+ | Case name is empty, holds a control character, or duplicates another case | `cases[N].name` |
66
+ | Case or binding data is not structured-cloneable, such as a function or a class instance | `cases[N].input`, `cases[N].expected`, `bindings[N].sampling`, `bindings[N].groundTruth`, `bindings[N].context` |
67
+
68
+ The clone check doubles as the suite's immutability guarantee: the copy is
69
+ what the suite keeps, so mutating the caller's objects afterwards leaves the
70
+ validated suite unchanged.
71
+
72
+ ## Load cases from JSON Lines
73
+
74
+ For fixtures, `Suite.fromJsonLines` decodes one `{ name, input, expected? }`
75
+ object per line and passes the result through the same validation:
76
+
77
+ ```json
78
+ {"name":"adds numbers","input":{"left":1,"right":2},"expected":3}
79
+ {"name":"multiplies numbers","input":{"left":2,"right":4},"expected":8}
80
+ ```
81
+
82
+ ```ts
83
+ import { Suite } from "@smthrs/evals"
84
+ import { Effect } from "effect"
85
+ import { readFile } from "node:fs/promises"
86
+
87
+ const program = Effect.gen(function*() {
88
+ const text = yield* Effect.promise(() => readFile("suite.jsonl", "utf8"))
89
+ return yield* Suite.fromJsonLines(text, { name: "arithmetic", concurrency: 4, bindings })
90
+ })
91
+ ```
92
+
93
+ Blank lines are skipped, a leading byte-order mark is stripped, and LF and
94
+ CRLF both terminate a line. A malformed line fails with `invalid_suite`
95
+ carrying the 1-based line number in the message and the path: `Invalid JSON on
96
+ line 2` at `line[2]`, or `Invalid suite case on line 1` at `line[1]`.
97
+
98
+ ## Declared limits
99
+
100
+ `Suite.limits` declares the ceilings a suite is validated against, so a
101
+ mistake fails at construction with a sentence instead of exhausting memory in
102
+ a runner:
103
+
104
+ | Limit | Value | Bounds |
105
+ | --------------- | ------- | ----------------------------------------------- |
106
+ | `concurrency` | 1024 | The fibers a run may hold open |
107
+ | `cases` | 10000 | The cases in one suite |
108
+ | `fixtureLength` | 8388608 | The UTF-16 code units in one JSON Lines fixture |
109
+
110
+ Each ceiling is inclusive. A fixture larger than `fixtureLength` is rejected
111
+ before any of it is parsed. After 10000 cases, the next non-blank line fails
112
+ with `invalid_suite` at `cases`, before parsing or decoding that line or any
113
+ later lines. The message reports `got 10001`, the first excess case.
114
+
115
+ Next: [run the suite](./run-a-suite.md).
@@ -0,0 +1,129 @@
1
+ ---
2
+ title: "Gate a run in CI"
3
+ description: "Compare a run to its baseline, render the report, and turn the verdict into an exit code."
4
+ sidebar:
5
+ order: 4
6
+ ---
7
+
8
+ The second half of the pipeline runs in CI: load the committed baseline,
9
+ compare the run against it, render the comparison for the log, and grade it
10
+ into an exit code.
11
+
12
+ ## Compare with tolerances
13
+
14
+ `Regression.compare` pairs baseline records with the run's score observations
15
+ and reports what moved:
16
+
17
+ ```ts
18
+ Effect.gen(function*() {
19
+ const comparison = yield* Regression.compare(baseline, run, {
20
+ absolute: 0.05,
21
+ relative: 0.1
22
+ })
23
+ })
24
+ ```
25
+
26
+ - A score that dropped at a changed step key is a regression.
27
+ - A score that moved at an unchanged step key is nondeterminism.
28
+ - An observation present on only one side is a missing observation.
29
+ - Inconclusive observations are carried through untouched.
30
+
31
+ A move is reported only when it exceeds both tolerances, so either one alone
32
+ silences it. Both default to 0, which reports every move. A tolerance that is
33
+ not finite and non-negative fails with `invalid_tolerance`. For the pairing
34
+ and step-key rules, see [Step keys and comparison](../concepts/step-keys.md).
35
+
36
+ ## Render the report
37
+
38
+ `Report.markdown` is what an operator reads in the CI log. Its summary counts
39
+ regressions, nondeterminism, missing observations, inconclusive observations,
40
+ and failed cases, and every non-zero count gets a section that names its rows.
41
+ `Report.json` is the machine-readable artifact: stable, sorted-key JSON with
42
+ embedded strings capped and everything JSON cannot express replaced by a named
43
+ marker, so two identical runs produce byte-identical reports.
44
+
45
+ Nothing redacts the output embedded in a report. A suite whose cases carry
46
+ secrets must not print the report where the log is readable.
47
+
48
+ ## Set thresholds
49
+
50
+ `Gate.check` evaluates score thresholds through the shared gate arithmetic in
51
+ [@smthrs/scorers/ScoreGate](/api/scorers):
52
+
53
+ ```ts
54
+ Effect.gen(function*() {
55
+ const verdict = yield* Gate.check(comparison, {
56
+ mean: 0.9,
57
+ min: 0.5,
58
+ perCase: { "adds numbers": 0.8 }
59
+ })
60
+ })
61
+ ```
62
+
63
+ - `mean` gates the arithmetic mean of every score observation.
64
+ - `min` gates the lowest score observation.
65
+ - `perCase` gates each named case's lowest score.
66
+
67
+ The threshold gates always run: an unobserved case cannot excuse the cases
68
+ that were observed. With no options the gate still runs, as `mean(0)`, so a
69
+ run with no score at all is undecidable rather than a pass. Regressions and
70
+ nondeterminism are findings and force a `Failed` verdict. Failed cases and
71
+ missing observations are environment faults and travel beside the verdict as
72
+ unresolved.
73
+
74
+ A threshold outside [0, 1] is gate misuse and fails in the error channel with
75
+ a `ScoreGateError`, code `invalid_threshold`. Catch it and report it rather
76
+ than crashing the CI job:
77
+
78
+ ```ts
79
+ Effect.gen(function*() {
80
+ const graded = yield* Gate.check(comparison, { mean: 1 }).pipe(
81
+ Effect.map(Gate.ciGrade),
82
+ Effect.catch((error) =>
83
+ Effect.succeed({
84
+ exitCode: 1 as const,
85
+ summary: `${error.code}: threshold ${error.threshold}, actual ${error.actual}`
86
+ })
87
+ )
88
+ )
89
+ })
90
+ ```
91
+
92
+ ## Grade to an exit code
93
+
94
+ `Gate.ciGrade` maps the verdict to the shared CI convention:
95
+
96
+ | Exit code | Meaning |
97
+ | --------- | ------------------------------------------------------------------------------- |
98
+ | 0 | Passed: every gate met, nothing unresolved |
99
+ | 1 | Failed: a threshold breach, a regression, or nondeterminism |
100
+ | 5 | Inconclusive: the gate could not decide, or passed with unresolved observations |
101
+
102
+ Exit code 5 names an unusable harness, not a result: repair the scorer, the
103
+ executor, or the wiring instead of reading the red. A pass that carries
104
+ unresolved observations also exits 5, because the gates it met were met over
105
+ fewer observations than the suite declared.
106
+
107
+ ## The complete gate
108
+
109
+ Assemble the whole thing as one script that CI runs: build the suite, run it,
110
+ load the committed baseline, compare, print the Markdown report, grade, and set
111
+ `process.exitCode` from the grade. [Quickstart](../quickstart.md) has that
112
+ script in full.
113
+
114
+ Two flags earn their place on it:
115
+
116
+ - `--update` re-records the baseline from this run, for the times a score moved
117
+ for a reason you can name. Keep it off the CI path.
118
+ - `--json` prints `Report.json` instead of the Markdown summary, so a drifting
119
+ run leaves a machine-readable artifact to diff.
120
+
121
+ Then call the script from your CI job and let the exit code decide the step:
122
+
123
+ ```yaml
124
+ - name: Evaluate
125
+ run: node quickstart.ts
126
+ ```
127
+
128
+ The gate's exit code is the whole contract: 0 and the job goes green, 1 and it
129
+ fails on a real red, 5 and it fails on a harness you need to repair.
@@ -0,0 +1,91 @@
1
+ ---
2
+ title: "Maintain a baseline"
3
+ description: "Record what a run scored, commit the canonical artifact, and load it back for comparison."
4
+ sidebar:
5
+ order: 3
6
+ ---
7
+
8
+ A baseline is the record of what a suite used to score, committed beside the
9
+ suite it belongs to. Record one with `Baseline.fromRun` and `Baseline.write`;
10
+ load it back with `Baseline.load`.
11
+
12
+ ## Record a baseline
13
+
14
+ `Baseline.fromRun` keeps a run's successful score observations and drops
15
+ inconclusive ones: a baseline records what was measured, and an inconclusive
16
+ observation measured nothing.
17
+
18
+ ```ts
19
+ Effect.gen(function*() {
20
+ const failures = run.cases.filter((result) => result.error !== undefined)
21
+ if (failures.length > 0) {
22
+ return yield* Effect.fail(
23
+ new EvalError.EvalError({
24
+ code: "executor",
25
+ message: `Refusing to record a baseline: ${failures.length} case(s) did not finish`
26
+ })
27
+ )
28
+ }
29
+ const baseline = yield* Baseline.fromRun(run)
30
+ yield* Effect.promise(() => writeFile("baseline.json", Baseline.write(baseline)))
31
+ })
32
+ ```
33
+
34
+ `Baseline.write` serializes canonical JSON: object keys sorted recursively,
35
+ records ordered by an injective encoding of `(suite, case, scorer, stepKey)`,
36
+ and a trailing newline. Two runs over the same inputs produce byte-identical
37
+ baseline files, so the committed artifact diffs cleanly.
38
+
39
+ Do not record over a broken run. The guard above refuses when any case failed,
40
+ because a baseline recorded over a failed case ratifies the absence of a
41
+ measurement. Keep it on every update path.
42
+
43
+ Re-record the baseline only when a score moved for a reason you can name. The
44
+ baseline is the record of what the target used to do; overwriting it to quiet
45
+ a red gate erases the regression the gate caught.
46
+
47
+ ## The artifact
48
+
49
+ A baseline is versioned JSON owned by one suite. This is the shape
50
+ `Baseline.write` emits, with every object key sorted:
51
+
52
+ ```json
53
+ {
54
+ "records": [
55
+ {
56
+ "case": "adds numbers",
57
+ "score": 1,
58
+ "scorer": "exact",
59
+ "stepKey": "step-a",
60
+ "suite": "arithmetic"
61
+ }
62
+ ],
63
+ "suite": "arithmetic",
64
+ "version": 1
65
+ }
66
+ ```
67
+
68
+ - `version` is `1`, the current artifact version (`Baseline.version`).
69
+ - `suite` names the owning suite even when `records` is empty.
70
+ - Each record carries `suite`, `case`, `scorer`, `stepKey`, and `score`, plus
71
+ an optional `scorerName`. The `scorer` field is the scorer key, a digest of
72
+ the scorer's declaration, and it is what a comparison matches on.
73
+
74
+ ## Load and validate
75
+
76
+ `Baseline.load` parses and validates committed JSON. `Baseline.make` validates
77
+ an in-memory value. Both rebuild every record from the validated fields, so
78
+ nothing a caller happened to attach to an object travels into a committed
79
+ artifact, and the returned array is frozen. Validation fails with
80
+ `invalid_baseline`, carrying the record index and field name in `path`, for a
81
+ wrong version, a non-array `records`, a record that is not an object, a
82
+ top-level `suite` or identity field that holds a control character, a record
83
+ identity field that is not a string, a file longer than
84
+ `Baseline.limits.length`, or a score that is not finite in [0, 1]. A negative zero score is normalized to 0.
85
+
86
+ Ownership is checked at comparison time: `Regression.compare` refuses a
87
+ baseline whose artifact or any record names a suite other than the run's,
88
+ failing with `invalid_baseline` rather than reporting a clean pass for the
89
+ wrong file.
90
+
91
+ Next: [gate the run in CI](./gate-a-run-in-ci.md).
@@ -0,0 +1,150 @@
1
+ ---
2
+ title: "Run a suite"
3
+ description: "Provide a case executor, run the suite deterministically, and choose how scoring happens."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ `Runner.run` executes every case through the injected `CaseExecutor` service
9
+ and grades each execution with the bindings whose `appliesTo` is the flow the
10
+ execution reports as its target.
11
+
12
+ ## Implement the executor
13
+
14
+ The executor is one callback from a case to an `Execution`:
15
+
16
+ ```ts
17
+ import { CaseExecutor } from "@smthrs/evals"
18
+ import { Effect } from "effect"
19
+
20
+ const executor = CaseExecutor.make((suiteCase) =>
21
+ Effect.succeed({
22
+ output: `Hello, ${(suiteCase.input as { readonly name: string }).name}`,
23
+ stepKey: suiteCase.name,
24
+ latencyMs: 0,
25
+ target: greet
26
+ })
27
+ )
28
+ ```
29
+
30
+ `CaseExecutor.make` accepts the bare callback, or an object naming the
31
+ callback `run`. An object without one throws a `TypeError`:
32
+ a wiring mistake fails loudly instead of degrading to an executor that fails
33
+ every case.
34
+
35
+ Four rules make an execution usable:
36
+
37
+ - `target` must be the declared flow value itself. Bindings match it by
38
+ reference identity, so a copy of the flow is graded by nothing.
39
+ - `stepKey` identifies the work the case produced. Fix it per case and state
40
+ it; do not derive it from the run. The comparison reads a changed key as
41
+ different work and a moved score under an unchanged key as nondeterminism.
42
+ For the full rule, see [Step keys and comparison](../concepts/step-keys.md).
43
+ - `latencyMs` is forwarded to the scorer.
44
+ - `output` is handed to the scorer and embedded raw in the JSON report. Do not
45
+ let it carry secrets you would not print.
46
+
47
+ ## Fail a case
48
+
49
+ Return a typed failure and the run records it on the case and moves on:
50
+
51
+ ```ts
52
+ import { EvalError } from "@smthrs/evals"
53
+
54
+ const executor = CaseExecutor.make((suiteCase) =>
55
+ suiteCase.name === "known-bad"
56
+ ? Effect.fail(
57
+ new EvalError.EvalError({ code: "executor", message: "the target crashed", path: "target" })
58
+ )
59
+ : Effect.succeed({ output: 1, stepKey: suiteCase.name, latencyMs: 0, target: greet })
60
+ )
61
+ ```
62
+
63
+ A failed case keeps its typed error and produces no observations; the rest of
64
+ the suite still runs. The error keeps the executor's own `path` when it named
65
+ one and is located at `cases['<name>']` otherwise, and its message is prefixed
66
+ `Target failed for case '<name>':`. An untyped failure is wrapped with the
67
+ `executor` code. Interruption propagates instead: an interrupted target
68
+ interrupts the run.
69
+
70
+ ## Pass run identity
71
+
72
+ `Runner.run` takes `runId` and `at` from the caller. `runId` identifies the
73
+ `RunResult` and joins every score job's identity; `at` stamps every
74
+ observation. Two runs produce identical observations when their executor and
75
+ scorer results are identical too, since the runner invokes those effects afresh
76
+ each time:
77
+
78
+ ```ts
79
+ Effect.gen(function*() {
80
+ const run = yield* Runner.run(suite, {
81
+ runId: "nightly-2026-01-01",
82
+ at: "2026-01-01T00:00:00.000Z"
83
+ })
84
+ })
85
+ ```
86
+
87
+ - `runId` must be non-empty.
88
+ - `at` must be a canonical UTC timestamp with millisecond precision, such as
89
+ `2026-01-01T00:00:00.000Z`. The value is parsed and re-rendered, and one
90
+ that does not round-trip fails with `invalid_run_options`.
91
+ `new Date().toISOString()` satisfies the format.
92
+ - `sampleId` is optional and defaults to `"default"`. It joins the job
93
+ identity, so two samples of one run never share a job identity.
94
+
95
+ ## Choose how scoring happens
96
+
97
+ A run scores through the first of these that exists:
98
+
99
+ 1. `options.scorer`, a batch runner passed at the call site.
100
+ 2. The `Runner` service, when a layer provides one.
101
+ 3. `Runner.makeInline()`, the in-process default.
102
+
103
+ The inline runner executes each job's scorer in the current process and
104
+ validates the result with the scorers package's own `Scorer.validate`. A
105
+ scorer that fails becomes an inconclusive observation naming its cause rather
106
+ than failing the run. Provide `Runner.layerInline` to state the in-process
107
+ choice explicitly. Provide `Runner.layerNoop` to state that a suite must not
108
+ score: every bound score then becomes an inconclusive observation, which a
109
+ gate grades as an undecidable run rather than a red.
110
+
111
+ ## Implement a batch runner
112
+
113
+ An adapter implements `Runner.ScoreBatchRunner`. Each `ScoreJob` carries an
114
+ `identity`, an `observation` seed with `targetStepKey` and `scorerKey`, the
115
+ scorer's `score` effect, and `at` in milliseconds since the epoch.
116
+
117
+ A runner that implements `runBatchCorrelated` tags every result with its job's
118
+ `identity` and may return results in any order. A run rejects a duplicate
119
+ identity, an unknown identity, a missing identity, the wrong result count, or
120
+ an observation that does not echo its job's `targetStepKey` and `scorerKey`,
121
+ all with `scorer_protocol`.
122
+
123
+ A runner that implements only `runBatch` is correlated positionally:
124
+
125
+ 1. Return exactly one observation per job.
126
+ 2. Return them in the order the jobs were given.
127
+ 3. Echo each job's `targetStepKey` and `scorerKey` in each observation.
128
+
129
+ A run verifies all three and fails with `scorer_protocol` when one is broken.
130
+ Before calling an order-only runner, it also refuses two jobs that share a
131
+ step key and scorer with `ambiguous_score_job`: give each case its own step
132
+ key, or implement `runBatchCorrelated`. The runner service in
133
+ `@smthrs/scorers` implements `runBatchCorrelated`, so its service value can be
134
+ used directly and `ambiguous_score_job` never applies to it. For that service,
135
+ see the [scorers API](/api/scorers).
136
+
137
+ A returned score that is not finite and inside [0, 1] becomes an inconclusive
138
+ observation naming the scorer and the offending value, and the run's own
139
+ timestamp, not the adapter's, is what reaches the observation.
140
+
141
+ ## Read the result
142
+
143
+ `RunResult.cases` is in declaration order regardless of completion order. Each
144
+ `CaseResult` carries either an `execution` or an `error`, plus the
145
+ observations attributed to the case. `RunResult.observations` flattens the
146
+ cases' observations into one list of `score` and `inconclusive` entries.
147
+ Reasons are bounded to 2048 characters, because they reach CI logs and
148
+ committed reports.
149
+
150
+ Next: [record a baseline](./maintain-a-baseline.md).