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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +62 -2
  4. package/dist/cjs/Baseline.d.ts +118 -0
  5. package/dist/cjs/Baseline.d.ts.map +1 -0
  6. package/dist/cjs/Baseline.js +158 -0
  7. package/dist/cjs/Baseline.js.map +7 -0
  8. package/dist/cjs/CaseExecutor.d.ts +85 -0
  9. package/dist/cjs/CaseExecutor.d.ts.map +1 -0
  10. package/dist/cjs/CaseExecutor.js +59 -0
  11. package/dist/cjs/CaseExecutor.js.map +7 -0
  12. package/dist/cjs/EvalError.d.ts +64 -0
  13. package/dist/cjs/EvalError.d.ts.map +1 -0
  14. package/dist/cjs/EvalError.js +54 -0
  15. package/dist/cjs/EvalError.js.map +7 -0
  16. package/dist/cjs/Gate.d.ts +55 -0
  17. package/dist/cjs/Gate.d.ts.map +1 -0
  18. package/dist/cjs/Gate.js +88 -0
  19. package/dist/cjs/Gate.js.map +7 -0
  20. package/dist/cjs/Regression.d.ts +116 -0
  21. package/dist/cjs/Regression.d.ts.map +1 -0
  22. package/dist/cjs/Regression.js +191 -0
  23. package/dist/cjs/Regression.js.map +7 -0
  24. package/dist/cjs/Report.d.ts +114 -0
  25. package/dist/cjs/Report.d.ts.map +1 -0
  26. package/dist/cjs/Report.js +161 -0
  27. package/dist/cjs/Report.js.map +7 -0
  28. package/dist/cjs/Runner.d.ts +229 -0
  29. package/dist/cjs/Runner.d.ts.map +1 -0
  30. package/dist/cjs/Runner.js +430 -0
  31. package/dist/cjs/Runner.js.map +7 -0
  32. package/dist/cjs/Suite.d.ts +139 -0
  33. package/dist/cjs/Suite.d.ts.map +1 -0
  34. package/dist/cjs/Suite.js +221 -0
  35. package/dist/cjs/Suite.js.map +7 -0
  36. package/dist/cjs/Trials.d.ts +93 -0
  37. package/dist/cjs/Trials.d.ts.map +1 -0
  38. package/dist/cjs/Trials.js +82 -0
  39. package/dist/cjs/Trials.js.map +7 -0
  40. package/dist/cjs/index.d.ts +76 -0
  41. package/dist/cjs/index.d.ts.map +1 -0
  42. package/dist/cjs/index.js +51 -0
  43. package/dist/cjs/index.js.map +7 -0
  44. package/dist/cjs/internal/CanonicalJson.d.ts +44 -0
  45. package/dist/cjs/internal/CanonicalJson.d.ts.map +1 -0
  46. package/dist/cjs/internal/CanonicalJson.js +114 -0
  47. package/dist/cjs/internal/CanonicalJson.js.map +7 -0
  48. package/dist/cjs/internal/controlCharacters.d.ts +44 -0
  49. package/dist/cjs/internal/controlCharacters.d.ts.map +1 -0
  50. package/dist/cjs/internal/controlCharacters.js +42 -0
  51. package/dist/cjs/internal/controlCharacters.js.map +7 -0
  52. package/dist/cjs/internal/scorerLabel.d.ts +33 -0
  53. package/dist/cjs/internal/scorerLabel.d.ts.map +1 -0
  54. package/dist/cjs/internal/scorerLabel.js +28 -0
  55. package/dist/cjs/internal/scorerLabel.js.map +7 -0
  56. package/dist/cjs/internal/tupleKey.d.ts +20 -0
  57. package/dist/cjs/internal/tupleKey.d.ts.map +1 -0
  58. package/dist/cjs/internal/tupleKey.js +25 -0
  59. package/dist/cjs/internal/tupleKey.js.map +7 -0
  60. package/dist/cjs/package.json +1 -0
  61. package/dist/esm/Baseline.d.ts +118 -0
  62. package/dist/esm/Baseline.d.ts.map +1 -0
  63. package/dist/esm/Baseline.js +203 -0
  64. package/dist/esm/Baseline.js.map +1 -0
  65. package/dist/esm/CaseExecutor.d.ts +85 -0
  66. package/dist/esm/CaseExecutor.d.ts.map +1 -0
  67. package/dist/esm/CaseExecutor.js +60 -0
  68. package/dist/esm/CaseExecutor.js.map +1 -0
  69. package/dist/esm/EvalError.d.ts +64 -0
  70. package/dist/esm/EvalError.d.ts.map +1 -0
  71. package/dist/esm/EvalError.js +65 -0
  72. package/dist/esm/EvalError.js.map +1 -0
  73. package/dist/esm/Gate.d.ts +55 -0
  74. package/dist/esm/Gate.d.ts.map +1 -0
  75. package/dist/esm/Gate.js +90 -0
  76. package/dist/esm/Gate.js.map +1 -0
  77. package/dist/esm/Regression.d.ts +116 -0
  78. package/dist/esm/Regression.d.ts.map +1 -0
  79. package/dist/esm/Regression.js +195 -0
  80. package/dist/esm/Regression.js.map +1 -0
  81. package/dist/esm/Report.d.ts +114 -0
  82. package/dist/esm/Report.d.ts.map +1 -0
  83. package/dist/esm/Report.js +188 -0
  84. package/dist/esm/Report.js.map +1 -0
  85. package/dist/esm/Runner.d.ts +229 -0
  86. package/dist/esm/Runner.d.ts.map +1 -0
  87. package/dist/esm/Runner.js +430 -0
  88. package/dist/esm/Runner.js.map +1 -0
  89. package/dist/esm/Suite.d.ts +139 -0
  90. package/dist/esm/Suite.d.ts.map +1 -0
  91. package/dist/esm/Suite.js +255 -0
  92. package/dist/esm/Suite.js.map +1 -0
  93. package/dist/esm/Trials.d.ts +93 -0
  94. package/dist/esm/Trials.d.ts.map +1 -0
  95. package/dist/esm/Trials.js +116 -0
  96. package/dist/esm/Trials.js.map +1 -0
  97. package/dist/esm/index.d.ts +76 -0
  98. package/dist/esm/index.d.ts.map +1 -0
  99. package/dist/esm/index.js +76 -0
  100. package/dist/esm/index.js.map +1 -0
  101. package/dist/esm/internal/CanonicalJson.d.ts +44 -0
  102. package/dist/esm/internal/CanonicalJson.d.ts.map +1 -0
  103. package/dist/esm/internal/CanonicalJson.js +202 -0
  104. package/dist/esm/internal/CanonicalJson.js.map +1 -0
  105. package/dist/esm/internal/controlCharacters.d.ts +44 -0
  106. package/dist/esm/internal/controlCharacters.d.ts.map +1 -0
  107. package/dist/esm/internal/controlCharacters.js +65 -0
  108. package/dist/esm/internal/controlCharacters.js.map +1 -0
  109. package/dist/esm/internal/scorerLabel.d.ts +33 -0
  110. package/dist/esm/internal/scorerLabel.d.ts.map +1 -0
  111. package/dist/esm/internal/scorerLabel.js +33 -0
  112. package/dist/esm/internal/scorerLabel.js.map +1 -0
  113. package/dist/esm/internal/tupleKey.d.ts +20 -0
  114. package/dist/esm/internal/tupleKey.d.ts.map +1 -0
  115. package/dist/esm/internal/tupleKey.js +20 -0
  116. package/dist/esm/internal/tupleKey.js.map +1 -0
  117. package/docs/README.md +130 -0
  118. package/docs/api.md +626 -0
  119. package/docs/concepts/determinism.md +64 -0
  120. package/docs/concepts/fixed-suites.md +71 -0
  121. package/docs/concepts/step-keys.md +60 -0
  122. package/docs/guides/author-a-suite.md +115 -0
  123. package/docs/guides/gate-a-run-in-ci.md +129 -0
  124. package/docs/guides/maintain-a-baseline.md +91 -0
  125. package/docs/guides/run-a-suite.md +150 -0
  126. package/docs/installation.md +46 -0
  127. package/docs/quickstart.md +238 -0
  128. package/docs/troubleshooting.md +148 -0
  129. package/package.json +198 -3
  130. package/src/Baseline.ts +259 -0
  131. package/src/CaseExecutor.ts +104 -0
  132. package/src/EvalError.ts +74 -0
  133. package/src/Gate.ts +128 -0
  134. package/src/Regression.ts +314 -0
  135. package/src/Report.ts +268 -0
  136. package/src/Runner.ts +759 -0
  137. package/src/Suite.ts +358 -0
  138. package/src/Trials.ts +154 -0
  139. package/src/index.ts +76 -0
  140. package/src/internal/CanonicalJson.ts +234 -0
  141. package/src/internal/controlCharacters.ts +66 -0
  142. package/src/internal/scorerLabel.ts +35 -0
  143. package/src/internal/tupleKey.ts +20 -0
@@ -0,0 +1,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"}
@@ -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"]}