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

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,259 @@
1
+ /**
2
+ * Canonical committed evaluation baselines.
3
+ *
4
+ * A baseline is the record of what a suite used to score, committed beside the
5
+ * suite it belongs to. It holds only the fields a comparison reads, and
6
+ * validation rebuilds every record from those fields, so nothing a caller
7
+ * happened to attach to an object travels into a committed artifact.
8
+ *
9
+ * @since 0.1.0
10
+ */
11
+
12
+ import * as Effect from "effect/Effect"
13
+ import { EvalError } from "./EvalError.ts"
14
+ import { CanonicalJson } from "./internal/CanonicalJson.ts"
15
+ import { controlCharacter } from "./internal/controlCharacters.ts"
16
+ import { tupleKey } from "./internal/tupleKey.ts"
17
+ import type { Observation, RunResult } from "./Runner.ts"
18
+
19
+ /**
20
+ * Current committed baseline artifact version.
21
+ *
22
+ * @category models
23
+ * @since 0.1.0
24
+ */
25
+ export const version = 1 as const
26
+
27
+ /**
28
+ * One successful score retained by a baseline.
29
+ *
30
+ * `scorer` is the scorer key a comparison matches on. `scorerName` is the
31
+ * optional readable name of the same scorer.
32
+ *
33
+ * @category models
34
+ * @since 0.1.0
35
+ */
36
+ export interface BaselineRecord {
37
+ readonly suite: string
38
+ readonly case: string
39
+ readonly scorer: string
40
+ readonly scorerName?: string | undefined
41
+ readonly stepKey: string
42
+ readonly score: number
43
+ }
44
+
45
+ /**
46
+ * Canonical committed evaluation baseline.
47
+ *
48
+ * `suite` records artifact ownership even when `records` is empty.
49
+ *
50
+ * @category models
51
+ * @since 0.1.0
52
+ */
53
+ export interface Baseline {
54
+ readonly version: typeof version
55
+ readonly suite: string
56
+ readonly records: ReadonlyArray<BaselineRecord>
57
+ }
58
+
59
+ /**
60
+ * The declared ceilings a committed baseline is validated against.
61
+ *
62
+ * `length` bounds the JSON text `load` will parse, so an oversized committed
63
+ * file fails with a sentence instead of exhausting a CI runner's memory.
64
+ *
65
+ * @category models
66
+ * @since 0.1.0
67
+ */
68
+ export const limits = {
69
+ /** Longest baseline JSON text, in UTF-16 code units. */
70
+ length: 16 * 1024 * 1024
71
+ } as const
72
+
73
+ const fail = (message: string, path?: string): Effect.Effect<never, EvalError> =>
74
+ Effect.fail(
75
+ new EvalError({ code: "invalid_baseline", message, ...(path === undefined ? {} : { path }) })
76
+ )
77
+
78
+ // A committed record is read by a gate whose summary goes to a CI log, so it
79
+ // follows the same control-character rule as a suite name: rejected where it
80
+ // enters the system, not flattened later.
81
+ const stringField = (value: unknown, field: string, path: string): Effect.Effect<string, EvalError> => {
82
+ if (typeof value !== "string") {
83
+ return fail(`Baseline record field '${field}' must be a string, got ${typeof value}`, path)
84
+ }
85
+ const control = controlCharacter(value)
86
+ return control === undefined
87
+ ? Effect.succeed(value)
88
+ : fail(`Baseline record field '${field}' must not contain the control character ${control}`, path)
89
+ }
90
+
91
+ const decodeRecord = (value: unknown, index: number): Effect.Effect<BaselineRecord, EvalError> =>
92
+ Effect.gen(function*() {
93
+ const at = `records[${index}]`
94
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
95
+ return yield* fail(`Baseline record must be an object, got ${value === null ? "null" : typeof value}`, at)
96
+ }
97
+ const source = value as { readonly [key: string]: unknown }
98
+ const rawSuite = source.suite
99
+ const rawCase = source.case
100
+ const rawScorer = source.scorer
101
+ const rawScorerName = source.scorerName
102
+ const rawStepKey = source.stepKey
103
+ const rawScore = source.score
104
+ const suite = yield* stringField(rawSuite, "suite", `${at}.suite`)
105
+ const caseName = yield* stringField(rawCase, "case", `${at}.case`)
106
+ const scorer = yield* stringField(rawScorer, "scorer", `${at}.scorer`)
107
+ const stepKey = yield* stringField(rawStepKey, "stepKey", `${at}.stepKey`)
108
+ const score = rawScore
109
+ if (typeof score !== "number" || !Number.isFinite(score) || score < 0 || score > 1) {
110
+ return yield* fail(
111
+ `Baseline record field 'score' must be a finite number in [0, 1], got ${String(score)}`,
112
+ `${at}.score`
113
+ )
114
+ }
115
+ const scorerName = rawScorerName === undefined
116
+ ? undefined
117
+ : yield* stringField(rawScorerName, "scorerName", `${at}.scorerName`)
118
+ // Every record is rebuilt field by field. Keeping the caller's object would
119
+ // carry unknown keys, and any getter among them, straight into a committed
120
+ // artifact.
121
+ return {
122
+ suite,
123
+ case: caseName,
124
+ scorer,
125
+ ...(scorerName === undefined ? {} : { scorerName }),
126
+ stepKey,
127
+ score: Object.is(score, -0) ? 0 : score
128
+ }
129
+ })
130
+
131
+ const validate = (value: unknown): Effect.Effect<Baseline, EvalError> =>
132
+ Effect.gen(function*() {
133
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
134
+ return yield* fail("Baseline must be an object")
135
+ }
136
+ const artifact = value as { readonly version?: unknown; readonly suite?: unknown; readonly records?: unknown }
137
+ const rawVersion = artifact.version
138
+ const rawSuite = artifact.suite
139
+ const rawRecords = artifact.records
140
+ if (rawVersion !== version) {
141
+ return yield* fail(`Baseline version must be ${version}, got ${String(rawVersion)}`, "version")
142
+ }
143
+ if (rawSuite !== undefined && typeof rawSuite !== "string") {
144
+ return yield* fail(`Baseline field 'suite' must be a string, got ${typeof rawSuite}`, "suite")
145
+ }
146
+ const suiteControl = rawSuite === undefined ? undefined : controlCharacter(rawSuite)
147
+ if (suiteControl !== undefined) {
148
+ return yield* fail(`Baseline field 'suite' must not contain the control character ${suiteControl}`, "suite")
149
+ }
150
+ if (!Array.isArray(rawRecords)) {
151
+ return yield* fail("Baseline records must be an array", "records")
152
+ }
153
+ const records = yield* Effect.forEach(rawRecords, decodeRecord)
154
+ let suite = rawSuite
155
+ if (suite === undefined) {
156
+ const first = records[0]
157
+ if (first === undefined) {
158
+ return yield* fail("Cannot infer baseline suite: legacy artifact has no records", "suite")
159
+ }
160
+ if (records.some((record) => record.suite !== first.suite)) {
161
+ return yield* fail("Cannot infer baseline suite: legacy records name multiple suites", "suite")
162
+ }
163
+ suite = first.suite
164
+ }
165
+ return { version, suite, records: Object.freeze(records) }
166
+ })
167
+
168
+ /**
169
+ * Builds and validates a baseline from a run's successful observations.
170
+ *
171
+ * Inconclusive observations are dropped: a baseline records what was measured,
172
+ * and an inconclusive observation measured nothing.
173
+ *
174
+ * @category constructors
175
+ * @since 0.1.0
176
+ */
177
+ export const fromRun = (run: RunResult): Effect.Effect<Baseline, EvalError> => {
178
+ const records: Array<BaselineRecord> = run.observations.flatMap((observation: Observation) =>
179
+ observation.kind === "score"
180
+ ? [{
181
+ suite: run.suite,
182
+ case: observation.case,
183
+ scorer: observation.scorer,
184
+ ...(observation.scorerName === undefined ? {} : { scorerName: observation.scorerName }),
185
+ stepKey: observation.stepKey,
186
+ score: observation.score
187
+ }]
188
+ : []
189
+ )
190
+ return validate({ version, suite: run.suite, records })
191
+ }
192
+
193
+ /**
194
+ * Validates an in-memory baseline.
195
+ *
196
+ * The result is a snapshot: every known field is read once, then records are
197
+ * rebuilt from those validated values and the array is frozen. Mutating the
198
+ * array or the objects that were passed in cannot change the validated
199
+ * baseline, and a stateful getter cannot substitute a value after validation.
200
+ *
201
+ * Fails with `invalid_baseline` carrying the record index and field name in
202
+ * `path` for a wrong version, a non-array `records`, a record that is not an
203
+ * object, an identity field that is not a string or holds a control
204
+ * character, or a score that is not finite in `[0, 1]`.
205
+ *
206
+ * @category constructors
207
+ * @since 0.1.0
208
+ */
209
+ export const make = (
210
+ baseline: Omit<Baseline, "version"> & { readonly version?: typeof version }
211
+ ): Effect.Effect<Baseline, EvalError> => {
212
+ const artifactVersion = baseline.version ?? version
213
+ const records = baseline.records
214
+ return validate({ version: artifactVersion, suite: baseline.suite, records })
215
+ }
216
+
217
+ /**
218
+ * Serializes a baseline with recursively sorted keys and stable numbers.
219
+ *
220
+ * Records are ordered by an injective encoding of `(suite, case, scorer,
221
+ * stepKey)`, so two records whose names differ only in where a separator falls
222
+ * still sort apart. The result ends with a newline and never throws: a
223
+ * validated baseline holds only strings and finite numbers.
224
+ *
225
+ * @category serialization
226
+ * @since 0.1.0
227
+ */
228
+ export const write = (baseline: Baseline): string => {
229
+ const sortKey = (record: BaselineRecord): string => tupleKey(record.suite, record.case, record.scorer, record.stepKey)
230
+ const records = baseline.records
231
+ .map((record) => [sortKey(record), record] as const)
232
+ .sort(([left], [right]) => CanonicalJson.compareText(left, right))
233
+ .map(([, record]) => record)
234
+ return CanonicalJson.stringify({
235
+ version: baseline.version,
236
+ suite: baseline.suite,
237
+ records
238
+ })
239
+ }
240
+
241
+ /**
242
+ * Loads and validates baseline JSON.
243
+ *
244
+ * A version-1 artifact without a top-level `suite` is accepted only when its
245
+ * nonempty records all name the same suite. That suite becomes the artifact's
246
+ * owner, so writing the result includes it. Empty or ambiguous legacy artifacts
247
+ * fail with `invalid_baseline` at `suite`. Text longer than `limits.length`
248
+ * fails with `invalid_baseline` at `text` before any of it is parsed.
249
+ *
250
+ * @category serialization
251
+ * @since 0.1.0
252
+ */
253
+ export const load = (text: string): Effect.Effect<Baseline, EvalError> =>
254
+ text.length > limits.length
255
+ ? fail(`Baseline must be at most ${limits.length} characters, got ${text.length}`, "text")
256
+ : Effect.try({
257
+ try: () => JSON.parse(text) as unknown,
258
+ catch: (cause) => new EvalError({ code: "invalid_baseline", message: "Baseline is not valid JSON", cause })
259
+ }).pipe(Effect.flatMap(validate))
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Injectable target-flow execution boundary.
3
+ *
4
+ * A suite says what to run; this service says how. Keeping it injectable is
5
+ * what lets the same suite run against a real flow, a scripted host, or nothing
6
+ * at all, and it is the only place a target's failure is converted into a typed
7
+ * evaluation failure.
8
+ *
9
+ * @since 0.1.0
10
+ */
11
+
12
+ import type { Flow } from "@smthrs/core"
13
+ import * as Context from "effect/Context"
14
+ import * as Effect from "effect/Effect"
15
+ import * as Layer from "effect/Layer"
16
+ import { EvalError } from "./EvalError.ts"
17
+ import type { Case } from "./Suite.ts"
18
+
19
+ /**
20
+ * The result of executing one target-flow case.
21
+ *
22
+ * `target` is the flow value the case actually executed. A run matches it
23
+ * against each binding's `appliesTo` by reference identity, so it has to be the
24
+ * declared flow itself rather than a copy of it.
25
+ *
26
+ * @category models
27
+ * @since 0.1.0
28
+ */
29
+ export interface Execution {
30
+ readonly output: unknown
31
+ readonly stepKey: string
32
+ readonly latencyMs: number
33
+ readonly target: Pick<Flow.Any, "name">
34
+ }
35
+
36
+ /**
37
+ * The one callback a case executor is.
38
+ *
39
+ * @category models
40
+ * @since 0.1.0
41
+ */
42
+ export type Run = (suiteCase: Case) => Effect.Effect<Execution, EvalError>
43
+
44
+ /**
45
+ * Runtime shape for an injectable target-flow executor.
46
+ *
47
+ * @category services
48
+ * @since 0.1.0
49
+ */
50
+ export interface Service {
51
+ readonly run: Run
52
+ }
53
+
54
+ /**
55
+ * Injectable execution boundary for a target flow.
56
+ *
57
+ * @category services
58
+ * @since 0.1.0
59
+ */
60
+ export class CaseExecutor extends Context.Service<CaseExecutor, Service>()("flows/evals/CaseExecutor") {}
61
+
62
+ /**
63
+ * Builds an executor from its one callback.
64
+ *
65
+ * Throws a `TypeError` when there is no callback. An executor that silently
66
+ * degraded to {@link makeNoop} turned one wiring mistake into a whole suite of
67
+ * cases failing with `executor`, which reads as a broken target rather than a
68
+ * missing one.
69
+ *
70
+ * @category constructors
71
+ * @since 0.1.0
72
+ */
73
+ export const make = (run: Run): Service => {
74
+ if (typeof run !== "function") {
75
+ throw new TypeError("CaseExecutor.make needs a callback")
76
+ }
77
+ return CaseExecutor.of({ run })
78
+ }
79
+
80
+ /**
81
+ * Builds an executor that fails every case with a typed executor error.
82
+ *
83
+ * @category constructors
84
+ * @since 0.1.0
85
+ */
86
+ export const makeNoop = (): Service =>
87
+ CaseExecutor.of({
88
+ run: (suiteCase) =>
89
+ Effect.fail(
90
+ new EvalError({
91
+ code: "executor",
92
+ message: `No executor is available for case '${suiteCase.name}'`,
93
+ path: `cases['${suiteCase.name}']`
94
+ })
95
+ )
96
+ })
97
+
98
+ /**
99
+ * Provides the unavailable executor.
100
+ *
101
+ * @category layers
102
+ * @since 0.1.0
103
+ */
104
+ export const layerNoop: Layer.Layer<CaseExecutor> = Layer.succeed(CaseExecutor)(makeNoop())
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The typed failures evaluation raises, with the stable codes a gate or a
3
+ * report branches on.
4
+ *
5
+ * @since 0.1.0
6
+ */
7
+
8
+ import * as Schema from "effect/Schema"
9
+
10
+ /**
11
+ * Stable evaluation failure codes.
12
+ *
13
+ * Each code names one thing a caller can do about it, so a gate that branches
14
+ * on the code routes the failure to the right owner:
15
+ *
16
+ * - `invalid_suite`: the suite declaration is wrong. Fix the suite.
17
+ * - `invalid_run_options`: `Runner.run`'s own options are wrong, not the suite.
18
+ * - `invalid_baseline`: the committed baseline is unreadable, belongs to
19
+ * another suite, or holds a record the schema rejects. Regenerate it.
20
+ * - `invalid_tolerance`: the caller passed a tolerance that is not a finite
21
+ * non-negative number. Fix the call, not the baseline.
22
+ * - `invalid_trials`: the caller passed run, pass, or `k` counts to `Trials`
23
+ * outside their bounds. Fix the call.
24
+ * - `executor`: the target flow failed for a case.
25
+ * - `ambiguous_score_job`: two score jobs shared a step key and a scorer, so
26
+ * an order-only batch runner's results cannot be attributed to a case. Give
27
+ * each case its own step key, or use a runner that implements
28
+ * `runBatchCorrelated`.
29
+ * - `scorer_protocol`: a batch runner broke the `runBatch` contract by returning
30
+ * the wrong number of observations, or observations that identify jobs other
31
+ * than the ones it was given. Nothing it returned can be trusted.
32
+ * - `scorer_unavailable`: no batch runner was available to score with.
33
+ *
34
+ * @category models
35
+ * @since 0.1.0
36
+ */
37
+ export const EvalErrorCode = Schema.Literals([
38
+ "invalid_suite",
39
+ "invalid_run_options",
40
+ "invalid_baseline",
41
+ "invalid_tolerance",
42
+ "invalid_trials",
43
+ "executor",
44
+ "ambiguous_score_job",
45
+ "scorer_protocol",
46
+ "scorer_unavailable"
47
+ ])
48
+
49
+ /**
50
+ * Stable evaluation failure code.
51
+ *
52
+ * @category models
53
+ * @since 0.1.0
54
+ */
55
+ export type EvalErrorCode = typeof EvalErrorCode.Type
56
+
57
+ /**
58
+ * A typed failure raised while loading or executing an evaluation.
59
+ *
60
+ * `code` is the stable branch point, `message` is the sentence a CI log shows,
61
+ * and `path` locates the offending value inside the input the caller supplied
62
+ * (`records[3].score`, `cases[1].input`, `options.at`). `cause` retains the
63
+ * original failure; it is never rendered on its own, so a message that matters
64
+ * to an operator has to say so itself.
65
+ *
66
+ * @category errors
67
+ * @since 0.1.0
68
+ */
69
+ export class EvalError extends Schema.TaggedError<EvalError>()("flows/evals/EvalError", {
70
+ code: EvalErrorCode,
71
+ message: Schema.String,
72
+ path: Schema.optional(Schema.String),
73
+ cause: Schema.optional(Schema.Defect())
74
+ }) {}
package/src/Gate.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The pass/fail decision over a finished evaluation run: how a threshold
3
+ * is applied and what a failing gate reports.
4
+ *
5
+ * @since 0.1.0
6
+ */
7
+
8
+ import {
9
+ combine,
10
+ expectScores,
11
+ grade,
12
+ type ScoreGateError,
13
+ type ScoreSample,
14
+ type Verdict
15
+ } from "@smthrs/scorers/ScoreGate"
16
+ import * as Effect from "effect/Effect"
17
+ import { flattenControlCharacters } from "./internal/controlCharacters.ts"
18
+ import { scorerLabel } from "./internal/scorerLabel.ts"
19
+ import type { Report } from "./Regression.ts"
20
+
21
+ /**
22
+ * Thresholds accepted by a CI score gate.
23
+ *
24
+ * `mean` gates the arithmetic mean of every score observation, `min` gates the
25
+ * lowest one, and `perCase` gates each named case's lowest score. With none of
26
+ * them set the gate still runs, as `mean(0)`, so a run with no score at all is
27
+ * undecidable rather than a pass.
28
+ *
29
+ * @since 0.1.0
30
+ * @category models
31
+ */
32
+ export interface Options {
33
+ readonly mean?: number | undefined
34
+ readonly min?: number | undefined
35
+ readonly perCase?: Readonly<Record<string, number>> | undefined
36
+ }
37
+
38
+ const samples = (report: Report): ReadonlyArray<ScoreSample> =>
39
+ report.run.observations.map((observation) =>
40
+ observation.kind === "score"
41
+ ? {
42
+ case: observation.case,
43
+ scorer: observation.scorer,
44
+ stepKey: observation.stepKey,
45
+ kind: "score",
46
+ value: observation.score,
47
+ reason: observation.reason
48
+ }
49
+ : {
50
+ case: observation.case,
51
+ scorer: observation.scorer,
52
+ stepKey: observation.stepKey,
53
+ kind: "inconclusive",
54
+ reason: observation.reason
55
+ }
56
+ )
57
+
58
+ /**
59
+ * An environment fault: something the comparison needed was never observed, so
60
+ * the harness owes an answer it cannot give. A fault withholds a decision;
61
+ * it does not make one.
62
+ */
63
+ const environmentFaults = (report: Report): ReadonlyArray<string> => [
64
+ ...report.run.cases.flatMap((result) =>
65
+ result.error === undefined ? [] : [`case '${result.case}' failed: ${result.error.code}: ${result.error.message}`]
66
+ ),
67
+ ...report.missing.map((item) =>
68
+ `missing ${item.side} observation for ${item.case}/${scorerLabel(item)}/${item.stepKey}`
69
+ )
70
+ ]
71
+
72
+ /**
73
+ * A finding: the run measured something the baseline says it should not have.
74
+ * A regression scored lower at a changed step key, and nondeterminism moved a
75
+ * score at an unchanged one. Both are results, so both are red.
76
+ */
77
+ const findings = (report: Report): ReadonlyArray<string> => [
78
+ ...report.regressions.map((item) =>
79
+ `regression for ${item.case}/${scorerLabel({ scorer: item.scorer, scorerName: item.actual.scorerName })}`
80
+ ),
81
+ ...report.nondeterminism.map((item) =>
82
+ `nondeterminism for ${item.case}/${scorerLabel({ scorer: item.scorer, scorerName: item.actual.scorerName })}`
83
+ )
84
+ ]
85
+
86
+ /**
87
+ * Checks thresholds through `@smthrs/scorers`' shared ScoreGate arithmetic.
88
+ *
89
+ * The threshold gates always run: an unobserved case cannot excuse the cases
90
+ * that were observed. Faults and findings are kept apart, and the verdict
91
+ * carries both, so a regression is a red while an unusable harness stays
92
+ * undecided.
93
+ *
94
+ * @category constructors
95
+ * @since 0.1.0
96
+ */
97
+ export const check = (report: Report, options: Options = {}): Effect.Effect<Verdict, ScoreGateError> =>
98
+ Effect.gen(function*() {
99
+ const expectation = expectScores(samples(report))
100
+ const verdicts: Array<Verdict> = []
101
+ if (options.mean !== undefined) verdicts.push(yield* expectation.mean(options.mean))
102
+ if (options.min !== undefined) verdicts.push(yield* expectation.min(options.min))
103
+ if (options.perCase !== undefined) verdicts.push(yield* expectation.perCase(options.perCase))
104
+ if (options.mean === undefined && options.min === undefined && options.perCase === undefined) {
105
+ verdicts.push(yield* expectation.mean(0))
106
+ }
107
+ const reasons = findings(report)
108
+ if (reasons.length > 0) verdicts.push({ _tag: "Failed", reasons, inconclusive: [] })
109
+ return combine(verdicts, environmentFaults(report))
110
+ })
111
+
112
+ /**
113
+ * Maps a gate verdict to the shared CI convention: a finding is exit code 1,
114
+ * an undecidable run is exit code 5.
115
+ *
116
+ * The summary is one log line: every C0 control and DEL in it becomes a
117
+ * space, the way a report cell is written, so a value that reached a verdict
118
+ * reason from a baseline, a step key or a target's failure cannot emit its own
119
+ * line, which on GitHub Actions would be a workflow command, or a terminal
120
+ * escape.
121
+ *
122
+ * @category grading
123
+ * @since 0.1.0
124
+ */
125
+ export const ciGrade = (verdict: Verdict): { readonly exitCode: 0 | 1 | 5; readonly summary: string } => {
126
+ const graded = grade(verdict)
127
+ return { exitCode: graded.exitCode, summary: flattenControlCharacters(graded.summary) }
128
+ }