@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,65 @@
|
|
|
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
|
+
import * as Schema from "effect/Schema";
|
|
8
|
+
/**
|
|
9
|
+
* Stable evaluation failure codes.
|
|
10
|
+
*
|
|
11
|
+
* Each code names one thing a caller can do about it, so a gate that branches
|
|
12
|
+
* on the code routes the failure to the right owner:
|
|
13
|
+
*
|
|
14
|
+
* - `invalid_suite`: the suite declaration is wrong. Fix the suite.
|
|
15
|
+
* - `invalid_run_options`: `Runner.run`'s own options are wrong, not the suite.
|
|
16
|
+
* - `invalid_baseline`: the committed baseline is unreadable, belongs to
|
|
17
|
+
* another suite, or holds a record the schema rejects. Regenerate it.
|
|
18
|
+
* - `invalid_tolerance`: the caller passed a tolerance that is not a finite
|
|
19
|
+
* non-negative number. Fix the call, not the baseline.
|
|
20
|
+
* - `invalid_trials`: the caller passed run, pass, or `k` counts to `Trials`
|
|
21
|
+
* outside their bounds. Fix the call.
|
|
22
|
+
* - `executor`: the target flow failed for a case.
|
|
23
|
+
* - `ambiguous_score_job`: two score jobs shared a step key and a scorer, so
|
|
24
|
+
* an order-only batch runner's results cannot be attributed to a case. Give
|
|
25
|
+
* each case its own step key, or use a runner that implements
|
|
26
|
+
* `runBatchCorrelated`.
|
|
27
|
+
* - `scorer_protocol`: a batch runner broke the `runBatch` contract by returning
|
|
28
|
+
* the wrong number of observations, or observations that identify jobs other
|
|
29
|
+
* than the ones it was given. Nothing it returned can be trusted.
|
|
30
|
+
* - `scorer_unavailable`: no batch runner was available to score with.
|
|
31
|
+
*
|
|
32
|
+
* @category models
|
|
33
|
+
* @since 0.1.0
|
|
34
|
+
*/
|
|
35
|
+
export const EvalErrorCode = Schema.Literals([
|
|
36
|
+
"invalid_suite",
|
|
37
|
+
"invalid_run_options",
|
|
38
|
+
"invalid_baseline",
|
|
39
|
+
"invalid_tolerance",
|
|
40
|
+
"invalid_trials",
|
|
41
|
+
"executor",
|
|
42
|
+
"ambiguous_score_job",
|
|
43
|
+
"scorer_protocol",
|
|
44
|
+
"scorer_unavailable"
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* A typed failure raised while loading or executing an evaluation.
|
|
48
|
+
*
|
|
49
|
+
* `code` is the stable branch point, `message` is the sentence a CI log shows,
|
|
50
|
+
* and `path` locates the offending value inside the input the caller supplied
|
|
51
|
+
* (`records[3].score`, `cases[1].input`, `options.at`). `cause` retains the
|
|
52
|
+
* original failure; it is never rendered on its own, so a message that matters
|
|
53
|
+
* to an operator has to say so itself.
|
|
54
|
+
*
|
|
55
|
+
* @category errors
|
|
56
|
+
* @since 0.1.0
|
|
57
|
+
*/
|
|
58
|
+
export class EvalError extends Schema.TaggedError()("flows/evals/EvalError", {
|
|
59
|
+
code: EvalErrorCode,
|
|
60
|
+
message: Schema.String,
|
|
61
|
+
path: Schema.optional(Schema.String),
|
|
62
|
+
cause: Schema.optional(Schema.Defect())
|
|
63
|
+
}) {
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=EvalError.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EvalError.js","sourceRoot":"","sources":["../../src/EvalError.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AAEvC;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,CAAC,QAAQ,CAAC;IAC3C,eAAe;IACf,qBAAqB;IACrB,kBAAkB;IAClB,mBAAmB;IACnB,gBAAgB;IAChB,UAAU;IACV,qBAAqB;IACrB,iBAAiB;IACjB,oBAAoB;CACrB,CAAC,CAAA;AAUF;;;;;;;;;;;GAWG;AACH,MAAM,OAAO,SAAU,SAAQ,MAAM,CAAC,WAAW,EAAa,CAAC,uBAAuB,EAAE;IACtF,IAAI,EAAE,aAAa;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM;IACtB,IAAI,EAAE,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAC;IACpC,KAAK,EAAE,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;CACxC,CAAC;CAAG","sourcesContent":["/**\n * The typed failures evaluation raises, with the stable codes a gate or a\n * report branches on.\n *\n * @since 0.1.0\n */\n\nimport * as Schema from \"effect/Schema\"\n\n/**\n * Stable evaluation failure codes.\n *\n * Each code names one thing a caller can do about it, so a gate that branches\n * on the code routes the failure to the right owner:\n *\n * - `invalid_suite`: the suite declaration is wrong. Fix the suite.\n * - `invalid_run_options`: `Runner.run`'s own options are wrong, not the suite.\n * - `invalid_baseline`: the committed baseline is unreadable, belongs to\n * another suite, or holds a record the schema rejects. Regenerate it.\n * - `invalid_tolerance`: the caller passed a tolerance that is not a finite\n * non-negative number. Fix the call, not the baseline.\n * - `invalid_trials`: the caller passed run, pass, or `k` counts to `Trials`\n * outside their bounds. Fix the call.\n * - `executor`: the target flow failed for a case.\n * - `ambiguous_score_job`: two score jobs shared a step key and a scorer, so\n * an order-only batch runner's results cannot be attributed to a case. Give\n * each case its own step key, or use a runner that implements\n * `runBatchCorrelated`.\n * - `scorer_protocol`: a batch runner broke the `runBatch` contract by returning\n * the wrong number of observations, or observations that identify jobs other\n * than the ones it was given. Nothing it returned can be trusted.\n * - `scorer_unavailable`: no batch runner was available to score with.\n *\n * @category models\n * @since 0.1.0\n */\nexport const EvalErrorCode = Schema.Literals([\n \"invalid_suite\",\n \"invalid_run_options\",\n \"invalid_baseline\",\n \"invalid_tolerance\",\n \"invalid_trials\",\n \"executor\",\n \"ambiguous_score_job\",\n \"scorer_protocol\",\n \"scorer_unavailable\"\n])\n\n/**\n * Stable evaluation failure code.\n *\n * @category models\n * @since 0.1.0\n */\nexport type EvalErrorCode = typeof EvalErrorCode.Type\n\n/**\n * A typed failure raised while loading or executing an evaluation.\n *\n * `code` is the stable branch point, `message` is the sentence a CI log shows,\n * and `path` locates the offending value inside the input the caller supplied\n * (`records[3].score`, `cases[1].input`, `options.at`). `cause` retains the\n * original failure; it is never rendered on its own, so a message that matters\n * to an operator has to say so itself.\n *\n * @category errors\n * @since 0.1.0\n */\nexport class EvalError extends Schema.TaggedError<EvalError>()(\"flows/evals/EvalError\", {\n code: EvalErrorCode,\n message: Schema.String,\n path: Schema.optional(Schema.String),\n cause: Schema.optional(Schema.Defect())\n}) {}\n"]}
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
import { type ScoreGateError, type Verdict } from "@smthrs/scorers/ScoreGate";
|
|
8
|
+
import * as Effect from "effect/Effect";
|
|
9
|
+
import type { Report } from "./Regression.ts";
|
|
10
|
+
/**
|
|
11
|
+
* Thresholds accepted by a CI score gate.
|
|
12
|
+
*
|
|
13
|
+
* `mean` gates the arithmetic mean of every score observation, `min` gates the
|
|
14
|
+
* lowest one, and `perCase` gates each named case's lowest score. With none of
|
|
15
|
+
* them set the gate still runs, as `mean(0)`, so a run with no score at all is
|
|
16
|
+
* undecidable rather than a pass.
|
|
17
|
+
*
|
|
18
|
+
* @since 0.1.0
|
|
19
|
+
* @category models
|
|
20
|
+
*/
|
|
21
|
+
export interface Options {
|
|
22
|
+
readonly mean?: number | undefined;
|
|
23
|
+
readonly min?: number | undefined;
|
|
24
|
+
readonly perCase?: Readonly<Record<string, number>> | undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Checks thresholds through `@smthrs/scorers`' shared ScoreGate arithmetic.
|
|
28
|
+
*
|
|
29
|
+
* The threshold gates always run: an unobserved case cannot excuse the cases
|
|
30
|
+
* that were observed. Faults and findings are kept apart, and the verdict
|
|
31
|
+
* carries both, so a regression is a red while an unusable harness stays
|
|
32
|
+
* undecided.
|
|
33
|
+
*
|
|
34
|
+
* @category constructors
|
|
35
|
+
* @since 0.1.0
|
|
36
|
+
*/
|
|
37
|
+
export declare const check: (report: Report, options?: Options) => Effect.Effect<Verdict, ScoreGateError>;
|
|
38
|
+
/**
|
|
39
|
+
* Maps a gate verdict to the shared CI convention: a finding is exit code 1,
|
|
40
|
+
* an undecidable run is exit code 5.
|
|
41
|
+
*
|
|
42
|
+
* The summary is one log line: every C0 control and DEL in it becomes a
|
|
43
|
+
* space, the way a report cell is written, so a value that reached a verdict
|
|
44
|
+
* reason from a baseline, a step key or a target's failure cannot emit its own
|
|
45
|
+
* line, which on GitHub Actions would be a workflow command, or a terminal
|
|
46
|
+
* escape.
|
|
47
|
+
*
|
|
48
|
+
* @category grading
|
|
49
|
+
* @since 0.1.0
|
|
50
|
+
*/
|
|
51
|
+
export declare const ciGrade: (verdict: Verdict) => {
|
|
52
|
+
readonly exitCode: 0 | 1 | 5;
|
|
53
|
+
readonly summary: string;
|
|
54
|
+
};
|
|
55
|
+
//# sourceMappingURL=Gate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Gate.d.ts","sourceRoot":"","sources":["../../src/Gate.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAIL,KAAK,cAAc,EAEnB,KAAK,OAAO,EACb,MAAM,2BAA2B,CAAA;AAClC,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AAGvC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAA;AAE7C;;;;;;;;;;GAUG;AACH,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACjC,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,CAAA;CAChE;AAkDD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,GAAI,QAAQ,MAAM,EAAE,UAAS,OAAY,KAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,cAAc,CAa/F,CAAA;AAEJ;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,OAAO,GAAI,SAAS,OAAO,KAAG;IAAE,QAAQ,CAAC,QAAQ,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAGlG,CAAA"}
|
package/dist/esm/Gate.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
import { combine, expectScores, grade } from "@smthrs/scorers/ScoreGate";
|
|
8
|
+
import * as Effect from "effect/Effect";
|
|
9
|
+
import { flattenControlCharacters } from "./internal/controlCharacters.js";
|
|
10
|
+
import { scorerLabel } from "./internal/scorerLabel.js";
|
|
11
|
+
const samples = (report) => report.run.observations.map((observation) => observation.kind === "score"
|
|
12
|
+
? {
|
|
13
|
+
case: observation.case,
|
|
14
|
+
scorer: observation.scorer,
|
|
15
|
+
stepKey: observation.stepKey,
|
|
16
|
+
kind: "score",
|
|
17
|
+
value: observation.score,
|
|
18
|
+
reason: observation.reason
|
|
19
|
+
}
|
|
20
|
+
: {
|
|
21
|
+
case: observation.case,
|
|
22
|
+
scorer: observation.scorer,
|
|
23
|
+
stepKey: observation.stepKey,
|
|
24
|
+
kind: "inconclusive",
|
|
25
|
+
reason: observation.reason
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* An environment fault: something the comparison needed was never observed, so
|
|
29
|
+
* the harness owes an answer it cannot give. A fault withholds a decision;
|
|
30
|
+
* it does not make one.
|
|
31
|
+
*/
|
|
32
|
+
const environmentFaults = (report) => [
|
|
33
|
+
...report.run.cases.flatMap((result) => result.error === undefined ? [] : [`case '${result.case}' failed: ${result.error.code}: ${result.error.message}`]),
|
|
34
|
+
...report.missing.map((item) => `missing ${item.side} observation for ${item.case}/${scorerLabel(item)}/${item.stepKey}`)
|
|
35
|
+
];
|
|
36
|
+
/**
|
|
37
|
+
* A finding: the run measured something the baseline says it should not have.
|
|
38
|
+
* A regression scored lower at a changed step key, and nondeterminism moved a
|
|
39
|
+
* score at an unchanged one. Both are results, so both are red.
|
|
40
|
+
*/
|
|
41
|
+
const findings = (report) => [
|
|
42
|
+
...report.regressions.map((item) => `regression for ${item.case}/${scorerLabel({ scorer: item.scorer, scorerName: item.actual.scorerName })}`),
|
|
43
|
+
...report.nondeterminism.map((item) => `nondeterminism for ${item.case}/${scorerLabel({ scorer: item.scorer, scorerName: item.actual.scorerName })}`)
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Checks thresholds through `@smthrs/scorers`' shared ScoreGate arithmetic.
|
|
47
|
+
*
|
|
48
|
+
* The threshold gates always run: an unobserved case cannot excuse the cases
|
|
49
|
+
* that were observed. Faults and findings are kept apart, and the verdict
|
|
50
|
+
* carries both, so a regression is a red while an unusable harness stays
|
|
51
|
+
* undecided.
|
|
52
|
+
*
|
|
53
|
+
* @category constructors
|
|
54
|
+
* @since 0.1.0
|
|
55
|
+
*/
|
|
56
|
+
export const check = (report, options = {}) => Effect.gen(function* () {
|
|
57
|
+
const expectation = expectScores(samples(report));
|
|
58
|
+
const verdicts = [];
|
|
59
|
+
if (options.mean !== undefined)
|
|
60
|
+
verdicts.push(yield* expectation.mean(options.mean));
|
|
61
|
+
if (options.min !== undefined)
|
|
62
|
+
verdicts.push(yield* expectation.min(options.min));
|
|
63
|
+
if (options.perCase !== undefined)
|
|
64
|
+
verdicts.push(yield* expectation.perCase(options.perCase));
|
|
65
|
+
if (options.mean === undefined && options.min === undefined && options.perCase === undefined) {
|
|
66
|
+
verdicts.push(yield* expectation.mean(0));
|
|
67
|
+
}
|
|
68
|
+
const reasons = findings(report);
|
|
69
|
+
if (reasons.length > 0)
|
|
70
|
+
verdicts.push({ _tag: "Failed", reasons, inconclusive: [] });
|
|
71
|
+
return combine(verdicts, environmentFaults(report));
|
|
72
|
+
});
|
|
73
|
+
/**
|
|
74
|
+
* Maps a gate verdict to the shared CI convention: a finding is exit code 1,
|
|
75
|
+
* an undecidable run is exit code 5.
|
|
76
|
+
*
|
|
77
|
+
* The summary is one log line: every C0 control and DEL in it becomes a
|
|
78
|
+
* space, the way a report cell is written, so a value that reached a verdict
|
|
79
|
+
* reason from a baseline, a step key or a target's failure cannot emit its own
|
|
80
|
+
* line, which on GitHub Actions would be a workflow command, or a terminal
|
|
81
|
+
* escape.
|
|
82
|
+
*
|
|
83
|
+
* @category grading
|
|
84
|
+
* @since 0.1.0
|
|
85
|
+
*/
|
|
86
|
+
export const ciGrade = (verdict) => {
|
|
87
|
+
const graded = grade(verdict);
|
|
88
|
+
return { exitCode: graded.exitCode, summary: flattenControlCharacters(graded.summary) };
|
|
89
|
+
};
|
|
90
|
+
//# sourceMappingURL=Gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Gate.js","sourceRoot":"","sources":["../../src/Gate.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The pass/fail decision over a finished evaluation run: how a threshold\n * is applied and what a failing gate reports.\n *\n * @since 0.1.0\n */\n\nimport {\n combine,\n expectScores,\n grade,\n type ScoreGateError,\n type ScoreSample,\n type Verdict\n} from \"@smthrs/scorers/ScoreGate\"\nimport * as Effect from \"effect/Effect\"\nimport { flattenControlCharacters } from \"./internal/controlCharacters.ts\"\nimport { scorerLabel } from \"./internal/scorerLabel.ts\"\nimport type { Report } from \"./Regression.ts\"\n\n/**\n * Thresholds accepted by a CI score gate.\n *\n * `mean` gates the arithmetic mean of every score observation, `min` gates the\n * lowest one, and `perCase` gates each named case's lowest score. With none of\n * them set the gate still runs, as `mean(0)`, so a run with no score at all is\n * undecidable rather than a pass.\n *\n * @since 0.1.0\n * @category models\n */\nexport interface Options {\n readonly mean?: number | undefined\n readonly min?: number | undefined\n readonly perCase?: Readonly<Record<string, number>> | undefined\n}\n\nconst samples = (report: Report): ReadonlyArray<ScoreSample> =>\n report.run.observations.map((observation) =>\n observation.kind === \"score\"\n ? {\n case: observation.case,\n scorer: observation.scorer,\n stepKey: observation.stepKey,\n kind: \"score\",\n value: observation.score,\n reason: observation.reason\n }\n : {\n case: observation.case,\n scorer: observation.scorer,\n stepKey: observation.stepKey,\n kind: \"inconclusive\",\n reason: observation.reason\n }\n )\n\n/**\n * An environment fault: something the comparison needed was never observed, so\n * the harness owes an answer it cannot give. A fault withholds a decision;\n * it does not make one.\n */\nconst environmentFaults = (report: Report): ReadonlyArray<string> => [\n ...report.run.cases.flatMap((result) =>\n result.error === undefined ? [] : [`case '${result.case}' failed: ${result.error.code}: ${result.error.message}`]\n ),\n ...report.missing.map((item) =>\n `missing ${item.side} observation for ${item.case}/${scorerLabel(item)}/${item.stepKey}`\n )\n]\n\n/**\n * A finding: the run measured something the baseline says it should not have.\n * A regression scored lower at a changed step key, and nondeterminism moved a\n * score at an unchanged one. Both are results, so both are red.\n */\nconst findings = (report: Report): ReadonlyArray<string> => [\n ...report.regressions.map((item) =>\n `regression for ${item.case}/${scorerLabel({ scorer: item.scorer, scorerName: item.actual.scorerName })}`\n ),\n ...report.nondeterminism.map((item) =>\n `nondeterminism for ${item.case}/${scorerLabel({ scorer: item.scorer, scorerName: item.actual.scorerName })}`\n )\n]\n\n/**\n * Checks thresholds through `@smthrs/scorers`' shared ScoreGate arithmetic.\n *\n * The threshold gates always run: an unobserved case cannot excuse the cases\n * that were observed. Faults and findings are kept apart, and the verdict\n * carries both, so a regression is a red while an unusable harness stays\n * undecided.\n *\n * @category constructors\n * @since 0.1.0\n */\nexport const check = (report: Report, options: Options = {}): Effect.Effect<Verdict, ScoreGateError> =>\n Effect.gen(function*() {\n const expectation = expectScores(samples(report))\n const verdicts: Array<Verdict> = []\n if (options.mean !== undefined) verdicts.push(yield* expectation.mean(options.mean))\n if (options.min !== undefined) verdicts.push(yield* expectation.min(options.min))\n if (options.perCase !== undefined) verdicts.push(yield* expectation.perCase(options.perCase))\n if (options.mean === undefined && options.min === undefined && options.perCase === undefined) {\n verdicts.push(yield* expectation.mean(0))\n }\n const reasons = findings(report)\n if (reasons.length > 0) verdicts.push({ _tag: \"Failed\", reasons, inconclusive: [] })\n return combine(verdicts, environmentFaults(report))\n })\n\n/**\n * Maps a gate verdict to the shared CI convention: a finding is exit code 1,\n * an undecidable run is exit code 5.\n *\n * The summary is one log line: every C0 control and DEL in it becomes a\n * space, the way a report cell is written, so a value that reached a verdict\n * reason from a baseline, a step key or a target's failure cannot emit its own\n * line, which on GitHub Actions would be a workflow command, or a terminal\n * escape.\n *\n * @category grading\n * @since 0.1.0\n */\nexport const ciGrade = (verdict: Verdict): { readonly exitCode: 0 | 1 | 5; readonly summary: string } => {\n const graded = grade(verdict)\n return { exitCode: graded.exitCode, summary: flattenControlCharacters(graded.summary) }\n}\n"]}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step-key-aware evaluation regression comparison.
|
|
3
|
+
*
|
|
4
|
+
* The step key is what separates the two findings this module reports. A score
|
|
5
|
+
* that moved at a *changed* step key is a regression: the target produced
|
|
6
|
+
* different work and it graded worse. A score that moved at an *unchanged* step
|
|
7
|
+
* key is nondeterminism: the same work graded differently twice. Both are
|
|
8
|
+
* results, and a gate reads both as red.
|
|
9
|
+
*
|
|
10
|
+
* @since 0.1.0
|
|
11
|
+
*/
|
|
12
|
+
import * as Effect from "effect/Effect";
|
|
13
|
+
import type { Baseline, BaselineRecord } from "./Baseline.ts";
|
|
14
|
+
import { EvalError } from "./EvalError.ts";
|
|
15
|
+
import type { Observation, RunResult } from "./Runner.ts";
|
|
16
|
+
/**
|
|
17
|
+
* Tolerances used for score comparisons.
|
|
18
|
+
*
|
|
19
|
+
* A move is reported only when it exceeds both tolerances, so either one alone
|
|
20
|
+
* is enough to silence it. Both default to `0`, which reports every move.
|
|
21
|
+
*
|
|
22
|
+
* @category models
|
|
23
|
+
* @since 0.1.0
|
|
24
|
+
*/
|
|
25
|
+
export interface Tolerances {
|
|
26
|
+
readonly absolute?: number | undefined;
|
|
27
|
+
readonly relative?: number | undefined;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A score drop at a changed step key.
|
|
31
|
+
*
|
|
32
|
+
* @category models
|
|
33
|
+
* @since 0.1.0
|
|
34
|
+
*/
|
|
35
|
+
export interface Regression {
|
|
36
|
+
readonly case: string;
|
|
37
|
+
readonly scorer: string;
|
|
38
|
+
readonly baseline: BaselineRecord;
|
|
39
|
+
readonly actual: Extract<Observation, {
|
|
40
|
+
readonly kind: "score";
|
|
41
|
+
}>;
|
|
42
|
+
readonly drop: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A changed score at the same step key, indicating nondeterminism.
|
|
46
|
+
*
|
|
47
|
+
* @category models
|
|
48
|
+
* @since 0.1.0
|
|
49
|
+
*/
|
|
50
|
+
export interface Nondeterminism {
|
|
51
|
+
readonly case: string;
|
|
52
|
+
readonly scorer: string;
|
|
53
|
+
readonly baseline: BaselineRecord;
|
|
54
|
+
readonly actual: Extract<Observation, {
|
|
55
|
+
readonly kind: "score";
|
|
56
|
+
}>;
|
|
57
|
+
readonly delta: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* An observation present on only one side of a comparison.
|
|
61
|
+
*
|
|
62
|
+
* `side` names the side it is missing from: `"run"` for a baseline record the
|
|
63
|
+
* run never reproduced, `"baseline"` for a score the run produced that no
|
|
64
|
+
* baseline record accounts for. `scorerName` comes from the observation that
|
|
65
|
+
* is present, when that artifact carried one.
|
|
66
|
+
*
|
|
67
|
+
* @category models
|
|
68
|
+
* @since 0.1.0
|
|
69
|
+
*/
|
|
70
|
+
export interface MissingObservation {
|
|
71
|
+
readonly side: "baseline" | "run";
|
|
72
|
+
readonly case: string;
|
|
73
|
+
readonly scorer: string;
|
|
74
|
+
readonly scorerName?: string | undefined;
|
|
75
|
+
readonly stepKey: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Complete regression comparison.
|
|
79
|
+
*
|
|
80
|
+
* @category models
|
|
81
|
+
* @since 0.1.0
|
|
82
|
+
*/
|
|
83
|
+
export interface Report {
|
|
84
|
+
readonly suite: string;
|
|
85
|
+
readonly baseline: Baseline;
|
|
86
|
+
readonly run: RunResult;
|
|
87
|
+
readonly regressions: ReadonlyArray<Regression>;
|
|
88
|
+
readonly nondeterminism: ReadonlyArray<Nondeterminism>;
|
|
89
|
+
readonly missing: ReadonlyArray<MissingObservation>;
|
|
90
|
+
readonly samples: ReadonlyArray<Extract<Observation, {
|
|
91
|
+
readonly kind: "score";
|
|
92
|
+
}>>;
|
|
93
|
+
readonly inconclusive: ReadonlyArray<Extract<Observation, {
|
|
94
|
+
readonly kind: "inconclusive";
|
|
95
|
+
}>>;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Compares a run to a baseline, preserving missing and inconclusive observations.
|
|
99
|
+
*
|
|
100
|
+
* Records and observations are grouped by `(case, scorer)` and then paired by
|
|
101
|
+
* step key, so a scorer that ran several times against one case is compared
|
|
102
|
+
* pairwise instead of by array position. A pair whose step key is unchanged and
|
|
103
|
+
* whose score moved is nondeterminism; a pair whose step key changed and whose
|
|
104
|
+
* score dropped is a regression; an unpaired record on either side is a missing
|
|
105
|
+
* observation. Inconclusive observations are carried through untouched, because
|
|
106
|
+
* they decide nothing and a gate has to see them.
|
|
107
|
+
*
|
|
108
|
+
* Fails with `invalid_tolerance` when a tolerance is not finite and
|
|
109
|
+
* non-negative, and with `invalid_baseline` when the artifact or any of its
|
|
110
|
+
* records belongs to a suite other than the one the run reports.
|
|
111
|
+
*
|
|
112
|
+
* @category constructors
|
|
113
|
+
* @since 0.1.0
|
|
114
|
+
*/
|
|
115
|
+
export declare const compare: (baseline: Baseline, run: RunResult, tolerances?: Tolerances) => Effect.Effect<Report, EvalError>;
|
|
116
|
+
//# sourceMappingURL=Regression.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Regression.d.ts","sourceRoot":"","sources":["../../src/Regression.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAC7D,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAI1C,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAEzD;;;;;;;;GAQG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACtC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CACvC;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAA;IACjC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,WAAW,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,CAAA;IACjE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAA;IACjC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,WAAW,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,CAAA;IACjE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACvB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,KAAK,CAAA;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACxC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAED;;;;;GAKG;AACH,MAAM,WAAW,MAAM;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;IAC3B,QAAQ,CAAC,GAAG,EAAE,SAAS,CAAA;IACvB,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,UAAU,CAAC,CAAA;IAC/C,QAAQ,CAAC,cAAc,EAAE,aAAa,CAAC,cAAc,CAAC,CAAA;IACtD,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,kBAAkB,CAAC,CAAA;IACnD,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,WAAW,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC,CAAA;IACjF,QAAQ,CAAC,YAAY,EAAE,aAAa,CAAC,OAAO,CAAC,WAAW,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAA;KAAE,CAAC,CAAC,CAAA;CAC9F;AA2ED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,OAAO,GAClB,UAAU,QAAQ,EAClB,KAAK,SAAS,EACd,aAAY,UAAe,KAC1B,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,CAwHjC,CAAA"}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step-key-aware evaluation regression comparison.
|
|
3
|
+
*
|
|
4
|
+
* The step key is what separates the two findings this module reports. A score
|
|
5
|
+
* that moved at a *changed* step key is a regression: the target produced
|
|
6
|
+
* different work and it graded worse. A score that moved at an *unchanged* step
|
|
7
|
+
* key is nondeterminism: the same work graded differently twice. Both are
|
|
8
|
+
* results, and a gate reads both as red.
|
|
9
|
+
*
|
|
10
|
+
* @since 0.1.0
|
|
11
|
+
*/
|
|
12
|
+
import * as Effect from "effect/Effect";
|
|
13
|
+
import { EvalError } from "./EvalError.js";
|
|
14
|
+
import { CanonicalJson } from "./internal/CanonicalJson.js";
|
|
15
|
+
import { flattenControlCharacters } from "./internal/controlCharacters.js";
|
|
16
|
+
import { tupleKey } from "./internal/tupleKey.js";
|
|
17
|
+
// Injective: two distinct (case, scorer) pairs can never encode to one key,
|
|
18
|
+
// which a delimiter join could not promise once a name may contain the
|
|
19
|
+
// delimiter.
|
|
20
|
+
const key = (caseName, scorer) => tupleKey(caseName, scorer);
|
|
21
|
+
const validTolerance = (value) => Number.isFinite(value) && value >= 0;
|
|
22
|
+
const groupBy = (items, of) => {
|
|
23
|
+
const groups = new Map();
|
|
24
|
+
for (const item of items) {
|
|
25
|
+
const bucket = groups.get(of(item));
|
|
26
|
+
if (bucket === undefined)
|
|
27
|
+
groups.set(of(item), [item]);
|
|
28
|
+
else
|
|
29
|
+
bucket.push(item);
|
|
30
|
+
}
|
|
31
|
+
return groups;
|
|
32
|
+
};
|
|
33
|
+
const byScore = (items) => [...items].sort((left, right) => left.score - right.score);
|
|
34
|
+
const byStepThenScore = (items) => [...items].sort((left, right) => CanonicalJson.compareText(left.stepKey, right.stepKey) || left.score - right.score);
|
|
35
|
+
/**
|
|
36
|
+
* Pairs one case's baseline records with one case's observations.
|
|
37
|
+
*
|
|
38
|
+
* Same-step-key pairs are matched first, lowest score to lowest score, so a
|
|
39
|
+
* repeated scorer does not report a move that never happened. Whatever is left
|
|
40
|
+
* over is paired in stable order, and an unpaired record on either side is a
|
|
41
|
+
* missing observation rather than a silent drop.
|
|
42
|
+
*/
|
|
43
|
+
const pairsFor = (expected, observed) => {
|
|
44
|
+
const pairs = [];
|
|
45
|
+
const expectedByStep = groupBy(expected, (record) => record.stepKey);
|
|
46
|
+
const observedByStep = groupBy(observed, (record) => record.stepKey);
|
|
47
|
+
const leftoverExpected = [];
|
|
48
|
+
const leftoverObserved = [];
|
|
49
|
+
for (const stepKey of [...expectedByStep.keys()].sort(CanonicalJson.compareText)) {
|
|
50
|
+
const atStep = byScore(expectedByStep.get(stepKey));
|
|
51
|
+
const observedAtStep = byScore(observedByStep.get(stepKey) ?? []);
|
|
52
|
+
const count = Math.min(atStep.length, observedAtStep.length);
|
|
53
|
+
for (let index = 0; index < count; index++) {
|
|
54
|
+
pairs.push({ kind: "matched", expected: atStep[index], observed: observedAtStep[index] });
|
|
55
|
+
}
|
|
56
|
+
leftoverExpected.push(...atStep.slice(count));
|
|
57
|
+
leftoverObserved.push(...observedAtStep.slice(count));
|
|
58
|
+
}
|
|
59
|
+
for (const [stepKey, atStep] of observedByStep) {
|
|
60
|
+
if (!expectedByStep.has(stepKey))
|
|
61
|
+
leftoverObserved.push(...atStep);
|
|
62
|
+
}
|
|
63
|
+
const remainingExpected = byStepThenScore(leftoverExpected);
|
|
64
|
+
const remainingObserved = byStepThenScore(leftoverObserved);
|
|
65
|
+
const matched = Math.min(remainingExpected.length, remainingObserved.length);
|
|
66
|
+
for (let index = 0; index < matched; index++) {
|
|
67
|
+
pairs.push({ kind: "matched", expected: remainingExpected[index], observed: remainingObserved[index] });
|
|
68
|
+
}
|
|
69
|
+
for (const record of remainingExpected.slice(matched))
|
|
70
|
+
pairs.push({ kind: "missing-run", expected: record });
|
|
71
|
+
for (const record of remainingObserved.slice(matched))
|
|
72
|
+
pairs.push({ kind: "missing-baseline", observed: record });
|
|
73
|
+
return pairs;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Compares a run to a baseline, preserving missing and inconclusive observations.
|
|
77
|
+
*
|
|
78
|
+
* Records and observations are grouped by `(case, scorer)` and then paired by
|
|
79
|
+
* step key, so a scorer that ran several times against one case is compared
|
|
80
|
+
* pairwise instead of by array position. A pair whose step key is unchanged and
|
|
81
|
+
* whose score moved is nondeterminism; a pair whose step key changed and whose
|
|
82
|
+
* score dropped is a regression; an unpaired record on either side is a missing
|
|
83
|
+
* observation. Inconclusive observations are carried through untouched, because
|
|
84
|
+
* they decide nothing and a gate has to see them.
|
|
85
|
+
*
|
|
86
|
+
* Fails with `invalid_tolerance` when a tolerance is not finite and
|
|
87
|
+
* non-negative, and with `invalid_baseline` when the artifact or any of its
|
|
88
|
+
* records belongs to a suite other than the one the run reports.
|
|
89
|
+
*
|
|
90
|
+
* @category constructors
|
|
91
|
+
* @since 0.1.0
|
|
92
|
+
*/
|
|
93
|
+
export const compare = (baseline, run, tolerances = {}) => {
|
|
94
|
+
const absolute = tolerances.absolute ?? 0;
|
|
95
|
+
const relative = tolerances.relative ?? 0;
|
|
96
|
+
if (!validTolerance(absolute)) {
|
|
97
|
+
return Effect.fail(new EvalError({
|
|
98
|
+
code: "invalid_tolerance",
|
|
99
|
+
message: `Regression tolerance 'absolute' must be finite and non-negative, got ${String(absolute)}`,
|
|
100
|
+
path: "tolerances.absolute"
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
if (!validTolerance(relative)) {
|
|
104
|
+
return Effect.fail(new EvalError({
|
|
105
|
+
code: "invalid_tolerance",
|
|
106
|
+
message: `Regression tolerance 'relative' must be finite and non-negative, got ${String(relative)}`,
|
|
107
|
+
path: "tolerances.relative"
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
const baselineSuite = baseline.suite;
|
|
111
|
+
if (baselineSuite !== run.suite) {
|
|
112
|
+
return Effect.fail(new EvalError({
|
|
113
|
+
code: "invalid_baseline",
|
|
114
|
+
message: flattenControlCharacters(`Baseline belongs to suite '${baselineSuite}', but the run is suite '${run.suite}'`),
|
|
115
|
+
path: "baseline.suite"
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
const foreign = [...new Set(baseline.records.filter((record) => record.suite !== run.suite).map((r) => r.suite))];
|
|
119
|
+
if (foreign.length > 0) {
|
|
120
|
+
return Effect.fail(new EvalError({
|
|
121
|
+
code: "invalid_baseline",
|
|
122
|
+
message: flattenControlCharacters(`Baseline holds records for suite ${foreign.map((suite) => `'${suite}'`).join(", ")}, but the run is suite '${run.suite}'`),
|
|
123
|
+
path: "baseline.records"
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
const actual = run.observations.filter((observation) => observation.kind === "score");
|
|
127
|
+
const inconclusive = run.observations.filter((observation) => observation.kind === "inconclusive");
|
|
128
|
+
const baselineByKey = groupBy(baseline.records, (record) => key(record.case, record.scorer));
|
|
129
|
+
const actualByKey = groupBy(actual, (observation) => key(observation.case, observation.scorer));
|
|
130
|
+
const regressions = [];
|
|
131
|
+
const nondeterminism = [];
|
|
132
|
+
const missing = [];
|
|
133
|
+
for (const groupedKey of new Set([...baselineByKey.keys(), ...actualByKey.keys()])) {
|
|
134
|
+
const pairs = pairsFor(baselineByKey.get(groupedKey) ?? [], actualByKey.get(groupedKey) ?? []);
|
|
135
|
+
for (const pair of pairs) {
|
|
136
|
+
if (pair.kind === "missing-baseline") {
|
|
137
|
+
missing.push({
|
|
138
|
+
side: "baseline",
|
|
139
|
+
case: pair.observed.case,
|
|
140
|
+
scorer: pair.observed.scorer,
|
|
141
|
+
...(pair.observed.scorerName === undefined ? {} : { scorerName: pair.observed.scorerName }),
|
|
142
|
+
stepKey: pair.observed.stepKey
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (pair.kind === "missing-run") {
|
|
147
|
+
missing.push({
|
|
148
|
+
side: "run",
|
|
149
|
+
case: pair.expected.case,
|
|
150
|
+
scorer: pair.expected.scorer,
|
|
151
|
+
...(pair.expected.scorerName === undefined ? {} : { scorerName: pair.expected.scorerName }),
|
|
152
|
+
stepKey: pair.expected.stepKey
|
|
153
|
+
});
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const baselineRecord = pair.expected;
|
|
157
|
+
const observation = pair.observed;
|
|
158
|
+
const delta = observation.score - baselineRecord.score;
|
|
159
|
+
// Both branches divide by the same guarded magnitude. A baseline score of
|
|
160
|
+
// exactly 0 used to be special-cased in the regression branch, which was
|
|
161
|
+
// dead: a score is validated into [0, 1], so nothing can drop below 0.
|
|
162
|
+
if (observation.stepKey === baselineRecord.stepKey) {
|
|
163
|
+
if (Math.abs(delta) > absolute && Math.abs(delta) / Math.max(Math.abs(baselineRecord.score), 1e-12) > relative) {
|
|
164
|
+
nondeterminism.push({
|
|
165
|
+
case: observation.case,
|
|
166
|
+
scorer: observation.scorer,
|
|
167
|
+
baseline: baselineRecord,
|
|
168
|
+
actual: observation,
|
|
169
|
+
delta
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else if (delta < 0 && -delta > absolute && -delta / Math.max(baselineRecord.score, 1e-12) > relative) {
|
|
174
|
+
regressions.push({
|
|
175
|
+
case: observation.case,
|
|
176
|
+
scorer: observation.scorer,
|
|
177
|
+
baseline: baselineRecord,
|
|
178
|
+
actual: observation,
|
|
179
|
+
drop: -delta
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return Effect.succeed({
|
|
185
|
+
suite: run.suite,
|
|
186
|
+
baseline,
|
|
187
|
+
run,
|
|
188
|
+
regressions,
|
|
189
|
+
nondeterminism,
|
|
190
|
+
missing,
|
|
191
|
+
samples: actual,
|
|
192
|
+
inconclusive
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
//# sourceMappingURL=Regression.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Regression.js","sourceRoot":"","sources":["../../src/Regression.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Step-key-aware evaluation regression comparison.\n *\n * The step key is what separates the two findings this module reports. A score\n * that moved at a *changed* step key is a regression: the target produced\n * different work and it graded worse. A score that moved at an *unchanged* step\n * key is nondeterminism: the same work graded differently twice. Both are\n * results, and a gate reads both as red.\n *\n * @since 0.1.0\n */\n\nimport * as Effect from \"effect/Effect\"\nimport type { Baseline, BaselineRecord } from \"./Baseline.ts\"\nimport { EvalError } from \"./EvalError.ts\"\nimport { CanonicalJson } from \"./internal/CanonicalJson.ts\"\nimport { flattenControlCharacters } from \"./internal/controlCharacters.ts\"\nimport { tupleKey } from \"./internal/tupleKey.ts\"\nimport type { Observation, RunResult } from \"./Runner.ts\"\n\n/**\n * Tolerances used for score comparisons.\n *\n * A move is reported only when it exceeds both tolerances, so either one alone\n * is enough to silence it. Both default to `0`, which reports every move.\n *\n * @category models\n * @since 0.1.0\n */\nexport interface Tolerances {\n readonly absolute?: number | undefined\n readonly relative?: number | undefined\n}\n\n/**\n * A score drop at a changed step key.\n *\n * @category models\n * @since 0.1.0\n */\nexport interface Regression {\n readonly case: string\n readonly scorer: string\n readonly baseline: BaselineRecord\n readonly actual: Extract<Observation, { readonly kind: \"score\" }>\n readonly drop: number\n}\n\n/**\n * A changed score at the same step key, indicating nondeterminism.\n *\n * @category models\n * @since 0.1.0\n */\nexport interface Nondeterminism {\n readonly case: string\n readonly scorer: string\n readonly baseline: BaselineRecord\n readonly actual: Extract<Observation, { readonly kind: \"score\" }>\n readonly delta: number\n}\n\n/**\n * An observation present on only one side of a comparison.\n *\n * `side` names the side it is missing from: `\"run\"` for a baseline record the\n * run never reproduced, `\"baseline\"` for a score the run produced that no\n * baseline record accounts for. `scorerName` comes from the observation that\n * is present, when that artifact carried one.\n *\n * @category models\n * @since 0.1.0\n */\nexport interface MissingObservation {\n readonly side: \"baseline\" | \"run\"\n readonly case: string\n readonly scorer: string\n readonly scorerName?: string | undefined\n readonly stepKey: string\n}\n\n/**\n * Complete regression comparison.\n *\n * @category models\n * @since 0.1.0\n */\nexport interface Report {\n readonly suite: string\n readonly baseline: Baseline\n readonly run: RunResult\n readonly regressions: ReadonlyArray<Regression>\n readonly nondeterminism: ReadonlyArray<Nondeterminism>\n readonly missing: ReadonlyArray<MissingObservation>\n readonly samples: ReadonlyArray<Extract<Observation, { readonly kind: \"score\" }>>\n readonly inconclusive: ReadonlyArray<Extract<Observation, { readonly kind: \"inconclusive\" }>>\n}\n\ntype Score = Extract<Observation, { readonly kind: \"score\" }>\n\n// Injective: two distinct (case, scorer) pairs can never encode to one key,\n// which a delimiter join could not promise once a name may contain the\n// delimiter.\nconst key = (caseName: string, scorer: string): string => tupleKey(caseName, scorer)\nconst validTolerance = (value: number): boolean => Number.isFinite(value) && value >= 0\n\nconst groupBy = <A>(items: ReadonlyArray<A>, of: (item: A) => string): Map<string, Array<A>> => {\n const groups = new Map<string, Array<A>>()\n for (const item of items) {\n const bucket = groups.get(of(item))\n if (bucket === undefined) groups.set(of(item), [item])\n else bucket.push(item)\n }\n return groups\n}\n\nconst byScore = <A extends { readonly score: number }>(items: ReadonlyArray<A>): Array<A> =>\n [...items].sort((left, right) => left.score - right.score)\n\nconst byStepThenScore = <A extends { readonly stepKey: string; readonly score: number }>(\n items: ReadonlyArray<A>\n): Array<A> =>\n [...items].sort((left, right) => CanonicalJson.compareText(left.stepKey, right.stepKey) || left.score - right.score)\n\n/** One matched pair, or one record that nothing on the other side accounts for. */\ntype Pair =\n | { readonly kind: \"matched\"; readonly expected: BaselineRecord; readonly observed: Score }\n | { readonly kind: \"missing-run\"; readonly expected: BaselineRecord }\n | { readonly kind: \"missing-baseline\"; readonly observed: Score }\n\n/**\n * Pairs one case's baseline records with one case's observations.\n *\n * Same-step-key pairs are matched first, lowest score to lowest score, so a\n * repeated scorer does not report a move that never happened. Whatever is left\n * over is paired in stable order, and an unpaired record on either side is a\n * missing observation rather than a silent drop.\n */\nconst pairsFor = (\n expected: ReadonlyArray<BaselineRecord>,\n observed: ReadonlyArray<Score>\n): ReadonlyArray<Pair> => {\n const pairs: Array<Pair> = []\n const expectedByStep = groupBy(expected, (record) => record.stepKey)\n const observedByStep = groupBy(observed, (record) => record.stepKey)\n const leftoverExpected: Array<BaselineRecord> = []\n const leftoverObserved: Array<Score> = []\n for (const stepKey of [...expectedByStep.keys()].sort(CanonicalJson.compareText)) {\n const atStep = byScore(expectedByStep.get(stepKey)!)\n const observedAtStep = byScore(observedByStep.get(stepKey) ?? [])\n const count = Math.min(atStep.length, observedAtStep.length)\n for (let index = 0; index < count; index++) {\n pairs.push({ kind: \"matched\", expected: atStep[index]!, observed: observedAtStep[index]! })\n }\n leftoverExpected.push(...atStep.slice(count))\n leftoverObserved.push(...observedAtStep.slice(count))\n }\n for (const [stepKey, atStep] of observedByStep) {\n if (!expectedByStep.has(stepKey)) leftoverObserved.push(...atStep)\n }\n const remainingExpected = byStepThenScore(leftoverExpected)\n const remainingObserved = byStepThenScore(leftoverObserved)\n const matched = Math.min(remainingExpected.length, remainingObserved.length)\n for (let index = 0; index < matched; index++) {\n pairs.push({ kind: \"matched\", expected: remainingExpected[index]!, observed: remainingObserved[index]! })\n }\n for (const record of remainingExpected.slice(matched)) pairs.push({ kind: \"missing-run\", expected: record })\n for (const record of remainingObserved.slice(matched)) pairs.push({ kind: \"missing-baseline\", observed: record })\n return pairs\n}\n\n/**\n * Compares a run to a baseline, preserving missing and inconclusive observations.\n *\n * Records and observations are grouped by `(case, scorer)` and then paired by\n * step key, so a scorer that ran several times against one case is compared\n * pairwise instead of by array position. A pair whose step key is unchanged and\n * whose score moved is nondeterminism; a pair whose step key changed and whose\n * score dropped is a regression; an unpaired record on either side is a missing\n * observation. Inconclusive observations are carried through untouched, because\n * they decide nothing and a gate has to see them.\n *\n * Fails with `invalid_tolerance` when a tolerance is not finite and\n * non-negative, and with `invalid_baseline` when the artifact or any of its\n * records belongs to a suite other than the one the run reports.\n *\n * @category constructors\n * @since 0.1.0\n */\nexport const compare = (\n baseline: Baseline,\n run: RunResult,\n tolerances: Tolerances = {}\n): Effect.Effect<Report, EvalError> => {\n const absolute = tolerances.absolute ?? 0\n const relative = tolerances.relative ?? 0\n if (!validTolerance(absolute)) {\n return Effect.fail(\n new EvalError({\n code: \"invalid_tolerance\",\n message: `Regression tolerance 'absolute' must be finite and non-negative, got ${String(absolute)}`,\n path: \"tolerances.absolute\"\n })\n )\n }\n if (!validTolerance(relative)) {\n return Effect.fail(\n new EvalError({\n code: \"invalid_tolerance\",\n message: `Regression tolerance 'relative' must be finite and non-negative, got ${String(relative)}`,\n path: \"tolerances.relative\"\n })\n )\n }\n const baselineSuite = baseline.suite\n if (baselineSuite !== run.suite) {\n return Effect.fail(\n new EvalError({\n code: \"invalid_baseline\",\n message: flattenControlCharacters(\n `Baseline belongs to suite '${baselineSuite}', but the run is suite '${run.suite}'`\n ),\n path: \"baseline.suite\"\n })\n )\n }\n const foreign = [...new Set(baseline.records.filter((record) => record.suite !== run.suite).map((r) => r.suite))]\n if (foreign.length > 0) {\n return Effect.fail(\n new EvalError({\n code: \"invalid_baseline\",\n message: flattenControlCharacters(\n `Baseline holds records for suite ${\n foreign.map((suite) => `'${suite}'`).join(\", \")\n }, but the run is suite '${run.suite}'`\n ),\n path: \"baseline.records\"\n })\n )\n }\n const actual = run.observations.filter((observation): observation is Score => observation.kind === \"score\")\n const inconclusive = run.observations.filter((\n observation\n ): observation is Extract<Observation, { readonly kind: \"inconclusive\" }> => observation.kind === \"inconclusive\")\n const baselineByKey = groupBy(baseline.records, (record) => key(record.case, record.scorer))\n const actualByKey = groupBy(actual, (observation) => key(observation.case, observation.scorer))\n const regressions: Array<Regression> = []\n const nondeterminism: Array<Nondeterminism> = []\n const missing: Array<MissingObservation> = []\n for (const groupedKey of new Set([...baselineByKey.keys(), ...actualByKey.keys()])) {\n const pairs = pairsFor(baselineByKey.get(groupedKey) ?? [], actualByKey.get(groupedKey) ?? [])\n for (const pair of pairs) {\n if (pair.kind === \"missing-baseline\") {\n missing.push({\n side: \"baseline\",\n case: pair.observed.case,\n scorer: pair.observed.scorer,\n ...(pair.observed.scorerName === undefined ? {} : { scorerName: pair.observed.scorerName }),\n stepKey: pair.observed.stepKey\n })\n continue\n }\n if (pair.kind === \"missing-run\") {\n missing.push({\n side: \"run\",\n case: pair.expected.case,\n scorer: pair.expected.scorer,\n ...(pair.expected.scorerName === undefined ? {} : { scorerName: pair.expected.scorerName }),\n stepKey: pair.expected.stepKey\n })\n continue\n }\n const baselineRecord = pair.expected\n const observation = pair.observed\n const delta = observation.score - baselineRecord.score\n // Both branches divide by the same guarded magnitude. A baseline score of\n // exactly 0 used to be special-cased in the regression branch, which was\n // dead: a score is validated into [0, 1], so nothing can drop below 0.\n if (observation.stepKey === baselineRecord.stepKey) {\n if (\n Math.abs(delta) > absolute && Math.abs(delta) / Math.max(Math.abs(baselineRecord.score), 1e-12) > relative\n ) {\n nondeterminism.push({\n case: observation.case,\n scorer: observation.scorer,\n baseline: baselineRecord,\n actual: observation,\n delta\n })\n }\n } else if (\n delta < 0 && -delta > absolute && -delta / Math.max(baselineRecord.score, 1e-12) > relative\n ) {\n regressions.push({\n case: observation.case,\n scorer: observation.scorer,\n baseline: baselineRecord,\n actual: observation,\n drop: -delta\n })\n }\n }\n }\n return Effect.succeed({\n suite: run.suite,\n baseline,\n run,\n regressions,\n nondeterminism,\n missing,\n samples: actual,\n inconclusive\n })\n}\n"]}
|