@graphorin/evals 0.6.0 → 0.7.0

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 (52) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +29 -3
  3. package/dist/index.d.ts +1 -2
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +3 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/loaders/locomo.d.ts.map +1 -1
  8. package/dist/loaders/locomo.js +24 -2
  9. package/dist/loaders/locomo.js.map +1 -1
  10. package/dist/loaders/memory-eval.d.ts +8 -0
  11. package/dist/loaders/memory-eval.d.ts.map +1 -1
  12. package/dist/package.js +6 -0
  13. package/dist/package.js.map +1 -0
  14. package/package.json +10 -9
  15. package/src/cli/index.ts +124 -0
  16. package/src/index.ts +136 -0
  17. package/src/loaders/csv.ts +147 -0
  18. package/src/loaders/dmr.ts +137 -0
  19. package/src/loaders/from-traces.ts +92 -0
  20. package/src/loaders/index.ts +46 -0
  21. package/src/loaders/iterable.ts +22 -0
  22. package/src/loaders/jsonl.ts +94 -0
  23. package/src/loaders/locomo.ts +217 -0
  24. package/src/loaders/longmemeval.ts +164 -0
  25. package/src/loaders/memory-eval.ts +80 -0
  26. package/src/regression.ts +133 -0
  27. package/src/reporters/html.ts +83 -0
  28. package/src/reporters/index.ts +14 -0
  29. package/src/reporters/json.ts +18 -0
  30. package/src/reporters/junit.ts +53 -0
  31. package/src/reporters/markdown.ts +65 -0
  32. package/src/reporters/terminal.ts +46 -0
  33. package/src/runner.ts +233 -0
  34. package/src/scorers/code/exact-match.ts +95 -0
  35. package/src/scorers/code/index.ts +10 -0
  36. package/src/scorers/code/json-path.ts +101 -0
  37. package/src/scorers/code/predicate.ts +32 -0
  38. package/src/scorers/code/regex.ts +55 -0
  39. package/src/scorers/index.ts +15 -0
  40. package/src/scorers/llm/index.ts +13 -0
  41. package/src/scorers/llm/judge.ts +170 -0
  42. package/src/scorers/prebuilt/index.ts +95 -0
  43. package/src/scorers/trajectory/argument-validity.ts +63 -0
  44. package/src/scorers/trajectory/correct-tool-selected.ts +61 -0
  45. package/src/scorers/trajectory/final-state-correct.ts +62 -0
  46. package/src/scorers/trajectory/index.ts +25 -0
  47. package/src/scorers/trajectory/recovery-after-error.ts +55 -0
  48. package/src/scorers/trajectory/redundant-call-detection.ts +58 -0
  49. package/src/scorers/trajectory/types.ts +44 -0
  50. package/src/scorers/trajectory/util.ts +81 -0
  51. package/src/stats.ts +196 -0
  52. package/src/types.ts +135 -0
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared types for the trajectory scorer family. A {@link Trajectory} is
3
+ * the recorded sequence of tool calls a harness made while attempting a
4
+ * task, plus an optional goal-state snapshot and the final text output.
5
+ *
6
+ * The scorers in this folder are pure functions over a `Trajectory` - no
7
+ * network, no model - so they can gate harness reliability in CI. Build a
8
+ * `Trajectory` by folding an agent's `AgentEvent` stream (correlate
9
+ * `tool.call.start` / `tool.call.end` with `tool.execute.end` /
10
+ * `tool.execute.error` by `toolCallId`).
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+
15
+ /**
16
+ * One executed tool call as observed on the `AgentEvent` stream.
17
+ *
18
+ * @stable
19
+ */
20
+ export interface TrajectoryToolCall {
21
+ readonly toolCallId: string;
22
+ readonly toolName: string;
23
+ /** The arguments the model emitted (the resolved `tool.call.end.finalArgs`). */
24
+ readonly args: unknown;
25
+ /** `'ok'` when the call returned; `'error'` when the executor surfaced a `ToolError`. */
26
+ readonly status: 'ok' | 'error';
27
+ /** The tool output, present when `status === 'ok'`. */
28
+ readonly result?: unknown;
29
+ /** The surfaced error, present when `status === 'error'`. */
30
+ readonly error?: { readonly kind?: string; readonly message?: string };
31
+ }
32
+
33
+ /**
34
+ * The full record of a single harness attempt at a task.
35
+ *
36
+ * @stable
37
+ */
38
+ export interface Trajectory {
39
+ readonly calls: ReadonlyArray<TrajectoryToolCall>;
40
+ /** Goal-state snapshot compared by {@link finalStateCorrect}. */
41
+ readonly finalState?: unknown;
42
+ /** The assistant's final text, when the run completed. */
43
+ readonly finalOutput?: string;
44
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Internal helpers shared by the trajectory scorers: structural deep
3
+ * equality, dot-path reads, and an order-insensitive canonical key used to
4
+ * de-duplicate tool calls by `(name, args)`. Private to the family - not
5
+ * exported from the package surface.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ /** Structural deep-equality with order-insensitive object key comparison. */
11
+ export function deepEqual(a: unknown, b: unknown): boolean {
12
+ if (a === b) return true;
13
+ if (typeof a !== typeof b) return false;
14
+ if (a === null || b === null) return a === b;
15
+ if (Array.isArray(a) && Array.isArray(b)) {
16
+ if (a.length !== b.length) return false;
17
+ for (let i = 0; i < a.length; i++) {
18
+ if (!deepEqual(a[i], b[i])) return false;
19
+ }
20
+ return true;
21
+ }
22
+ if (typeof a === 'object' && typeof b === 'object') {
23
+ const aObj = a as Record<string, unknown>;
24
+ const bObj = b as Record<string, unknown>;
25
+ const aKeys = Object.keys(aObj).sort();
26
+ const bKeys = Object.keys(bObj).sort();
27
+ if (aKeys.length !== bKeys.length) return false;
28
+ for (let i = 0; i < aKeys.length; i++) {
29
+ const key = aKeys[i];
30
+ if (key === undefined || key !== bKeys[i]) return false;
31
+ if (!deepEqual(aObj[key], bObj[key])) return false;
32
+ }
33
+ return true;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ /** Read a dot-separated path (`'orders.0.status'`) out of a value. */
39
+ export function readPath(root: unknown, path: string): unknown {
40
+ if (path.length === 0) return root;
41
+ let cursor: unknown = root;
42
+ for (const segment of path.split('.')) {
43
+ if (cursor === null || cursor === undefined) return undefined;
44
+ if (Array.isArray(cursor)) {
45
+ const idx = Number.parseInt(segment, 10);
46
+ if (!Number.isFinite(idx)) return undefined;
47
+ cursor = cursor[idx];
48
+ } else if (typeof cursor === 'object') {
49
+ cursor = (cursor as Record<string, unknown>)[segment];
50
+ } else {
51
+ return undefined;
52
+ }
53
+ }
54
+ return cursor;
55
+ }
56
+
57
+ /** Order-insensitive canonical JSON, so `{a,b}` and `{b,a}` key alike. */
58
+ export function canonicalize(value: unknown): string {
59
+ if (value === null || typeof value !== 'object') {
60
+ return JSON.stringify(value) ?? 'null';
61
+ }
62
+ if (Array.isArray(value)) {
63
+ return `[${value.map(canonicalize).join(',')}]`;
64
+ }
65
+ const obj = value as Record<string, unknown>;
66
+ const keys = Object.keys(obj).sort();
67
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`).join(',')}}`;
68
+ }
69
+
70
+ export function truncate(s: string, max = 80): string {
71
+ return s.length > max ? `${s.slice(0, max)}…` : s;
72
+ }
73
+
74
+ export function stringifySafe(value: unknown): string {
75
+ if (value === undefined) return 'undefined';
76
+ try {
77
+ return JSON.stringify(value) ?? String(value);
78
+ } catch {
79
+ return String(value);
80
+ }
81
+ }
package/src/stats.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Shared eval statistics (audit 2026-07-04, E8; closes the evals-05 /
3
+ * evals-08 gap): confidence intervals, pass^k, and paired significance
4
+ * for benchmark deltas. Everything here is pure and dependency-free so
5
+ * the runner, the regression gate, and the benchmarks can share one
6
+ * implementation instead of each inlining its own mean/stddev.
7
+ *
8
+ * Statistical choices, briefly:
9
+ * - **Wilson score interval** for pass-rate CIs. Eval suites are small
10
+ * (n in the tens) and pass rates hug 0/1, exactly where the normal
11
+ * approximation interval collapses; Wilson stays sane there.
12
+ * - **pass^k** ("pass-hat-k", the fraction of base cases that pass in
13
+ * ALL k repeat iterations) as the stability metric for `iterations>1`
14
+ * runs - a mean pass rate hides a case that flips every other run.
15
+ * - **McNemar's test** (continuity-corrected normal approximation) for
16
+ * "did the pass rate really change vs the baseline": the same cases
17
+ * ran in both reports, so a PAIRED test on the discordant cases has
18
+ * far more power than comparing two independent proportions - and it
19
+ * is sample-size aware, which the fixed-tolerance regression gate is
20
+ * not.
21
+ *
22
+ * @packageDocumentation
23
+ */
24
+
25
+ /**
26
+ * Arithmetic mean; `0` for an empty list.
27
+ *
28
+ * @stable
29
+ */
30
+ export function mean(values: ReadonlyArray<number>): number {
31
+ if (values.length === 0) return 0;
32
+ let sum = 0;
33
+ for (const v of values) sum += v;
34
+ return sum / values.length;
35
+ }
36
+
37
+ /**
38
+ * Sample standard deviation (n-1 denominator); `0` for n < 2.
39
+ *
40
+ * @stable
41
+ */
42
+ export function sampleStddev(values: ReadonlyArray<number>): number {
43
+ if (values.length < 2) return 0;
44
+ const m = mean(values);
45
+ let acc = 0;
46
+ for (const v of values) acc += (v - m) * (v - m);
47
+ return Math.sqrt(acc / (values.length - 1));
48
+ }
49
+
50
+ /**
51
+ * Wilson score interval for a binomial proportion.
52
+ *
53
+ * @param passed number of successes
54
+ * @param total number of trials
55
+ * @param z normal quantile (default 1.96 = 95% two-sided)
56
+ * @returns `{ lo, hi }` clamped to [0, 1]; `{ lo: 0, hi: 1 }` when `total` is 0
57
+ *
58
+ * @stable
59
+ */
60
+ export function wilsonInterval(
61
+ passed: number,
62
+ total: number,
63
+ z = 1.96,
64
+ ): { readonly lo: number; readonly hi: number } {
65
+ if (total <= 0) return { lo: 0, hi: 1 };
66
+ const p = passed / total;
67
+ const z2 = z * z;
68
+ const denom = 1 + z2 / total;
69
+ const centre = p + z2 / (2 * total);
70
+ const margin = z * Math.sqrt((p * (1 - p)) / total + z2 / (4 * total * total));
71
+ return {
72
+ lo: Math.max(0, (centre - margin) / denom),
73
+ hi: Math.min(1, (centre + margin) / denom),
74
+ };
75
+ }
76
+
77
+ const ITERATION_SUFFIX_RE = /-iter-\d+$/;
78
+
79
+ /**
80
+ * Strip the `-iter-N` disambiguation suffix the runner appends under
81
+ * `iterations > 1` (EB-6), recovering the base case id.
82
+ *
83
+ * @stable
84
+ */
85
+ export function stripIterationSuffix(caseId: string): string {
86
+ return caseId.replace(ITERATION_SUFFIX_RE, '');
87
+ }
88
+
89
+ /**
90
+ * pass^k over per-iteration case outcomes: group by base case id and
91
+ * report the fraction of base cases whose EVERY iteration passed.
92
+ *
93
+ * @param outcomes one entry per executed case iteration
94
+ * @returns `k` = the largest group size observed, `baseCases` = number of
95
+ * distinct base cases, `value` = the pass^k fraction (0 when no cases)
96
+ *
97
+ * @stable
98
+ */
99
+ export function passHatK(
100
+ outcomes: ReadonlyArray<{ readonly caseId: string; readonly pass: boolean }>,
101
+ ): { readonly k: number; readonly baseCases: number; readonly value: number } {
102
+ const groups = new Map<string, { total: number; passed: number }>();
103
+ for (const o of outcomes) {
104
+ const base = stripIterationSuffix(o.caseId);
105
+ const g = groups.get(base) ?? { total: 0, passed: 0 };
106
+ g.total += 1;
107
+ if (o.pass) g.passed += 1;
108
+ groups.set(base, g);
109
+ }
110
+ if (groups.size === 0) return { k: 0, baseCases: 0, value: 0 };
111
+ let k = 0;
112
+ let allPass = 0;
113
+ for (const g of groups.values()) {
114
+ if (g.total > k) k = g.total;
115
+ if (g.passed === g.total) allPass += 1;
116
+ }
117
+ return { k, baseCases: groups.size, value: allPass / groups.size };
118
+ }
119
+
120
+ /**
121
+ * Result of {@link pairedPassSignificance}.
122
+ *
123
+ * @stable
124
+ */
125
+ export interface PairedSignificance {
126
+ /** Cases present in BOTH runs (the paired sample). */
127
+ readonly pairs: number;
128
+ /** baseline-pass -> current-fail count (the regressions). */
129
+ readonly regressed: number;
130
+ /** baseline-fail -> current-pass count (the improvements). */
131
+ readonly improved: number;
132
+ /**
133
+ * Two-sided p-value from McNemar's test (continuity-corrected normal
134
+ * approximation over the discordant pairs). `1` when there are no
135
+ * discordant pairs - identical outcomes are never "significant".
136
+ */
137
+ readonly pValue: number;
138
+ }
139
+
140
+ /**
141
+ * McNemar's paired test on per-case pass outcomes of two eval runs.
142
+ * Only cases present in both maps are compared (by base case id).
143
+ *
144
+ * @stable
145
+ */
146
+ export function pairedPassSignificance(
147
+ current: ReadonlyMap<string, boolean>,
148
+ baseline: ReadonlyMap<string, boolean>,
149
+ ): PairedSignificance {
150
+ let pairs = 0;
151
+ let regressed = 0;
152
+ let improved = 0;
153
+ for (const [caseId, basePass] of baseline) {
154
+ const curPass = current.get(caseId);
155
+ if (curPass === undefined) continue;
156
+ pairs += 1;
157
+ if (basePass && !curPass) regressed += 1;
158
+ else if (!basePass && curPass) improved += 1;
159
+ }
160
+ const discordant = regressed + improved;
161
+ if (discordant === 0) return { pairs, regressed, improved, pValue: 1 };
162
+ // Continuity-corrected normal approximation to the binomial(discordant, 0.5).
163
+ const z = (Math.abs(regressed - improved) - 1) / Math.sqrt(discordant);
164
+ const pValue = z <= 0 ? 1 : Math.min(1, 2 * (1 - normalCdf(z)));
165
+ return { pairs, regressed, improved, pValue };
166
+ }
167
+
168
+ /**
169
+ * Collapse per-iteration outcomes to per-base-case pass (a base case
170
+ * passes when EVERY iteration passed) - the paired unit for
171
+ * {@link pairedPassSignificance}.
172
+ *
173
+ * @stable
174
+ */
175
+ export function passByBaseCase(
176
+ outcomes: ReadonlyArray<{ readonly caseId: string; readonly pass: boolean }>,
177
+ ): ReadonlyMap<string, boolean> {
178
+ const out = new Map<string, boolean>();
179
+ for (const o of outcomes) {
180
+ const base = stripIterationSuffix(o.caseId);
181
+ const prev = out.get(base);
182
+ out.set(base, prev === undefined ? o.pass : prev && o.pass);
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /** Standard normal CDF via the Abramowitz-Stegun 7.1.26 erf approximation. */
188
+ function normalCdf(x: number): number {
189
+ const t = 1 / (1 + 0.3275911 * (Math.abs(x) / Math.SQRT2));
190
+ const erf =
191
+ 1 -
192
+ ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) *
193
+ t *
194
+ Math.exp(-(x * x) / 2);
195
+ return x >= 0 ? 0.5 * (1 + erf) : 0.5 * (1 - erf);
196
+ }
package/src/types.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Public types for the full eval framework. Re-exports the
3
+ * {@link Case} / {@link Dataset} / {@link EvalReport} primitives from
4
+ * `@graphorin/observability/eval` so consumers do not need to depend
5
+ * on both packages, then layers on the framework-specific concepts:
6
+ * agent shape, parallel runner config, regression policy, baseline
7
+ * loaders.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+
12
+ export type {
13
+ Case,
14
+ Dataset,
15
+ EvalCaseResult,
16
+ EvalReport,
17
+ RunEvalOptions,
18
+ ScoreResult,
19
+ Scorer,
20
+ } from '@graphorin/observability/eval';
21
+
22
+ import type { EvalReport, Scorer } from '@graphorin/observability/eval';
23
+
24
+ /**
25
+ * Agent shape consumed by the runner. Anything with a `run(input)`
26
+ * method satisfies the contract - the framework's own `Agent` type
27
+ * matches by structural typing.
28
+ *
29
+ * @stable
30
+ */
31
+ export interface AgentLike<I, O> {
32
+ run(input: I, ctx?: { signal?: AbortSignal }): Promise<O>;
33
+ }
34
+
35
+ /**
36
+ * Options accepted by the parallel runner.
37
+ *
38
+ * @stable
39
+ */
40
+ export interface RunOptions<I, O> {
41
+ readonly agent: AgentLike<I, O>;
42
+ readonly dataset: {
43
+ readonly cases: ReadonlyArray<{
44
+ readonly id?: string;
45
+ readonly input: I;
46
+ readonly expected?: O;
47
+ readonly metadata?: Readonly<Record<string, unknown>>;
48
+ }>;
49
+ };
50
+ readonly scorers: ReadonlyArray<Scorer<I, O>>;
51
+ /** Default `1`. */
52
+ readonly iterations?: number;
53
+ /** Default `1` (sequential). Set higher for parallel evaluation. */
54
+ readonly concurrency?: number;
55
+ readonly signal?: AbortSignal;
56
+ /**
57
+ * How to handle `signal` abort. Default (`false`): stop dispatching and
58
+ * resolve with a **partial** {@link EvalReport} (`aborted: true`) covering
59
+ * the cases that finished - a long judged run shouldn't lose all completed
60
+ * work to a Ctrl+C. Set `true` to throw the abort reason instead.
61
+ */
62
+ readonly throwOnAbort?: boolean;
63
+ /**
64
+ * Optional progress hook invoked after every case. Useful for
65
+ * terminal reporters that want a per-case heartbeat.
66
+ */
67
+ readonly onProgress?: (event: ProgressEvent) => void;
68
+ }
69
+
70
+ /** @stable */
71
+ export interface ProgressEvent {
72
+ readonly index: number;
73
+ readonly total: number;
74
+ readonly caseId: string;
75
+ readonly durationMs: number;
76
+ readonly passed: boolean;
77
+ }
78
+
79
+ /**
80
+ * Regression-detection options.
81
+ *
82
+ * @stable
83
+ */
84
+ export interface RegressionOptions {
85
+ /** Minimum drop in pass-rate (in percentage points) that counts as a regression. */
86
+ readonly maxPassRateDropPct?: number;
87
+ /** Minimum drop in average score per scorer that counts as a regression. */
88
+ readonly maxAvgScoreDrop?: number;
89
+ /**
90
+ * Maximum allowed increase in `avgDurationMs` before it counts as a
91
+ * regression. **Opt-in: defaults to `Infinity` (gate off)** because absolute
92
+ * wall-clock budgets are environment-sensitive (workstation baseline vs CI
93
+ * runner, real LLM-latency jitter). Pass a finite ms budget to enable an
94
+ * absolute duration gate; leave unset to ignore duration entirely.
95
+ */
96
+ readonly maxAvgDurationIncreaseMs?: number;
97
+ /**
98
+ * E8 (evals-05/08): when `true`, a `pass-rate-drop` finding is only kept if
99
+ * McNemar's paired test over the shared cases rejects "no real change" at
100
+ * {@link significanceAlpha} - a fixed percentage tolerance is blind to
101
+ * sample size (a 5pp drop is one case in a 20-case suite). Off by default
102
+ * so existing gates keep their exact behavior; the computed `pValue` is
103
+ * attached to the finding either way whenever both reports carry per-case
104
+ * results.
105
+ */
106
+ readonly requireSignificance?: boolean;
107
+ /** Significance level for {@link requireSignificance}. Default `0.05`. */
108
+ readonly significanceAlpha?: number;
109
+ }
110
+
111
+ /**
112
+ * Result of {@link detectRegressions}.
113
+ *
114
+ * @stable
115
+ */
116
+ export interface RegressionReport<I, O> {
117
+ readonly hasRegressions: boolean;
118
+ readonly findings: ReadonlyArray<RegressionFinding>;
119
+ readonly current: EvalReport<I, O>;
120
+ readonly baseline: EvalReport<I, O>;
121
+ }
122
+
123
+ /** @stable */
124
+ export interface RegressionFinding {
125
+ readonly kind: 'pass-rate-drop' | 'avg-score-drop' | 'avg-duration-increase' | 'scorer-removed';
126
+ readonly scorer?: string;
127
+ readonly message: string;
128
+ readonly delta: number;
129
+ /**
130
+ * Two-sided McNemar p-value over the cases shared by both reports
131
+ * (E8; only on `pass-rate-drop` findings, and only when both reports
132
+ * carry per-case results to pair on).
133
+ */
134
+ readonly pValue?: number;
135
+ }