@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.
- package/CHANGELOG.md +142 -0
- package/LICENSE +21 -0
- package/README.md +62 -2
- package/dist/cjs/Baseline.d.ts +118 -0
- package/dist/cjs/Baseline.d.ts.map +1 -0
- package/dist/cjs/Baseline.js +158 -0
- package/dist/cjs/Baseline.js.map +7 -0
- package/dist/cjs/CaseExecutor.d.ts +85 -0
- package/dist/cjs/CaseExecutor.d.ts.map +1 -0
- package/dist/cjs/CaseExecutor.js +59 -0
- package/dist/cjs/CaseExecutor.js.map +7 -0
- package/dist/cjs/EvalError.d.ts +64 -0
- package/dist/cjs/EvalError.d.ts.map +1 -0
- package/dist/cjs/EvalError.js +54 -0
- package/dist/cjs/EvalError.js.map +7 -0
- package/dist/cjs/Gate.d.ts +55 -0
- package/dist/cjs/Gate.d.ts.map +1 -0
- package/dist/cjs/Gate.js +88 -0
- package/dist/cjs/Gate.js.map +7 -0
- package/dist/cjs/Regression.d.ts +116 -0
- package/dist/cjs/Regression.d.ts.map +1 -0
- package/dist/cjs/Regression.js +191 -0
- package/dist/cjs/Regression.js.map +7 -0
- package/dist/cjs/Report.d.ts +114 -0
- package/dist/cjs/Report.d.ts.map +1 -0
- package/dist/cjs/Report.js +161 -0
- package/dist/cjs/Report.js.map +7 -0
- package/dist/cjs/Runner.d.ts +229 -0
- package/dist/cjs/Runner.d.ts.map +1 -0
- package/dist/cjs/Runner.js +430 -0
- package/dist/cjs/Runner.js.map +7 -0
- package/dist/cjs/Suite.d.ts +139 -0
- package/dist/cjs/Suite.d.ts.map +1 -0
- package/dist/cjs/Suite.js +221 -0
- package/dist/cjs/Suite.js.map +7 -0
- package/dist/cjs/Trials.d.ts +93 -0
- package/dist/cjs/Trials.d.ts.map +1 -0
- package/dist/cjs/Trials.js +82 -0
- package/dist/cjs/Trials.js.map +7 -0
- package/dist/cjs/index.d.ts +76 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +51 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/CanonicalJson.d.ts +44 -0
- package/dist/cjs/internal/CanonicalJson.d.ts.map +1 -0
- package/dist/cjs/internal/CanonicalJson.js +114 -0
- package/dist/cjs/internal/CanonicalJson.js.map +7 -0
- package/dist/cjs/internal/controlCharacters.d.ts +44 -0
- package/dist/cjs/internal/controlCharacters.d.ts.map +1 -0
- package/dist/cjs/internal/controlCharacters.js +42 -0
- package/dist/cjs/internal/controlCharacters.js.map +7 -0
- package/dist/cjs/internal/scorerLabel.d.ts +33 -0
- package/dist/cjs/internal/scorerLabel.d.ts.map +1 -0
- package/dist/cjs/internal/scorerLabel.js +28 -0
- package/dist/cjs/internal/scorerLabel.js.map +7 -0
- package/dist/cjs/internal/tupleKey.d.ts +20 -0
- package/dist/cjs/internal/tupleKey.d.ts.map +1 -0
- package/dist/cjs/internal/tupleKey.js +25 -0
- package/dist/cjs/internal/tupleKey.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/esm/Baseline.d.ts +118 -0
- package/dist/esm/Baseline.d.ts.map +1 -0
- package/dist/esm/Baseline.js +203 -0
- package/dist/esm/Baseline.js.map +1 -0
- package/dist/esm/CaseExecutor.d.ts +85 -0
- package/dist/esm/CaseExecutor.d.ts.map +1 -0
- package/dist/esm/CaseExecutor.js +60 -0
- package/dist/esm/CaseExecutor.js.map +1 -0
- package/dist/esm/EvalError.d.ts +64 -0
- package/dist/esm/EvalError.d.ts.map +1 -0
- package/dist/esm/EvalError.js +65 -0
- package/dist/esm/EvalError.js.map +1 -0
- package/dist/esm/Gate.d.ts +55 -0
- package/dist/esm/Gate.d.ts.map +1 -0
- package/dist/esm/Gate.js +90 -0
- package/dist/esm/Gate.js.map +1 -0
- package/dist/esm/Regression.d.ts +116 -0
- package/dist/esm/Regression.d.ts.map +1 -0
- package/dist/esm/Regression.js +195 -0
- package/dist/esm/Regression.js.map +1 -0
- package/dist/esm/Report.d.ts +114 -0
- package/dist/esm/Report.d.ts.map +1 -0
- package/dist/esm/Report.js +188 -0
- package/dist/esm/Report.js.map +1 -0
- package/dist/esm/Runner.d.ts +229 -0
- package/dist/esm/Runner.d.ts.map +1 -0
- package/dist/esm/Runner.js +430 -0
- package/dist/esm/Runner.js.map +1 -0
- package/dist/esm/Suite.d.ts +139 -0
- package/dist/esm/Suite.d.ts.map +1 -0
- package/dist/esm/Suite.js +255 -0
- package/dist/esm/Suite.js.map +1 -0
- package/dist/esm/Trials.d.ts +93 -0
- package/dist/esm/Trials.d.ts.map +1 -0
- package/dist/esm/Trials.js +116 -0
- package/dist/esm/Trials.js.map +1 -0
- package/dist/esm/index.d.ts +76 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +76 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/CanonicalJson.d.ts +44 -0
- package/dist/esm/internal/CanonicalJson.d.ts.map +1 -0
- package/dist/esm/internal/CanonicalJson.js +202 -0
- package/dist/esm/internal/CanonicalJson.js.map +1 -0
- package/dist/esm/internal/controlCharacters.d.ts +44 -0
- package/dist/esm/internal/controlCharacters.d.ts.map +1 -0
- package/dist/esm/internal/controlCharacters.js +65 -0
- package/dist/esm/internal/controlCharacters.js.map +1 -0
- package/dist/esm/internal/scorerLabel.d.ts +33 -0
- package/dist/esm/internal/scorerLabel.d.ts.map +1 -0
- package/dist/esm/internal/scorerLabel.js +33 -0
- package/dist/esm/internal/scorerLabel.js.map +1 -0
- package/dist/esm/internal/tupleKey.d.ts +20 -0
- package/dist/esm/internal/tupleKey.d.ts.map +1 -0
- package/dist/esm/internal/tupleKey.js +20 -0
- package/dist/esm/internal/tupleKey.js.map +1 -0
- package/docs/README.md +130 -0
- package/docs/api.md +626 -0
- package/docs/concepts/determinism.md +64 -0
- package/docs/concepts/fixed-suites.md +71 -0
- package/docs/concepts/step-keys.md +60 -0
- package/docs/guides/author-a-suite.md +115 -0
- package/docs/guides/gate-a-run-in-ci.md +129 -0
- package/docs/guides/maintain-a-baseline.md +91 -0
- package/docs/guides/run-a-suite.md +150 -0
- package/docs/installation.md +46 -0
- package/docs/quickstart.md +238 -0
- package/docs/troubleshooting.md +148 -0
- package/package.json +198 -3
- package/src/Baseline.ts +259 -0
- package/src/CaseExecutor.ts +104 -0
- package/src/EvalError.ts +74 -0
- package/src/Gate.ts +128 -0
- package/src/Regression.ts +314 -0
- package/src/Report.ts +268 -0
- package/src/Runner.ts +759 -0
- package/src/Suite.ts +358 -0
- package/src/Trials.ts +154 -0
- package/src/index.ts +76 -0
- package/src/internal/CanonicalJson.ts +234 -0
- package/src/internal/controlCharacters.ts +66 -0
- package/src/internal/scorerLabel.ts +35 -0
- 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).
|