@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,18 @@
1
+ /**
2
+ * JSON reporter. Returns the canonical JSON-serialised representation
3
+ * of an {@link EvalReport} so downstream tooling (dashboards,
4
+ * spreadsheets, regression checkers) can consume it deterministically.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ import type { EvalReport } from '@graphorin/observability/eval';
10
+
11
+ /** @stable */
12
+ export function renderJsonReport<I, O>(
13
+ report: EvalReport<I, O>,
14
+ options: { readonly pretty?: boolean } = {},
15
+ ): string {
16
+ const indent = options.pretty === true ? 2 : 0;
17
+ return JSON.stringify(report, null, indent);
18
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * JUnit XML reporter. Renders an {@link EvalReport} as a JUnit
3
+ * `<testsuite>` document so CI systems (GitHub Actions, GitLab,
4
+ * CircleCI, Jenkins) can render pass / fail counts in the standard
5
+ * test-report widget.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import type { EvalReport } from '@graphorin/observability/eval';
11
+
12
+ /** @stable */
13
+ export function renderJunitReport<I, O>(
14
+ report: EvalReport<I, O>,
15
+ options: { readonly suiteName?: string } = {},
16
+ ): string {
17
+ const suiteName = options.suiteName ?? 'graphorin-evals';
18
+ const totalTests = report.results.reduce((acc, r) => acc + r.scores.length, 0);
19
+ const failures = report.results.reduce(
20
+ (acc, r) => acc + r.scores.filter((s) => !s.result.pass).length,
21
+ 0,
22
+ );
23
+ const totalTime = (report.summary.avgDurationMs * report.summary.total) / 1000;
24
+ const lines: string[] = [];
25
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
26
+ lines.push(
27
+ `<testsuite name="${escapeXml(suiteName)}" tests="${totalTests}" failures="${failures}" time="${totalTime.toFixed(3)}">`,
28
+ );
29
+ for (const r of report.results) {
30
+ for (const { scorer, result } of r.scores) {
31
+ const time = (r.durationMs / 1000).toFixed(3);
32
+ lines.push(
33
+ ` <testcase classname="${escapeXml(scorer)}" name="${escapeXml(r.caseId)}" time="${time}">`,
34
+ );
35
+ if (!result.pass) {
36
+ const message = escapeXml(result.reason ?? 'failed');
37
+ lines.push(` <failure message="${message}">${message}</failure>`);
38
+ }
39
+ lines.push(' </testcase>');
40
+ }
41
+ }
42
+ lines.push('</testsuite>');
43
+ return lines.join('\n');
44
+ }
45
+
46
+ function escapeXml(value: string): string {
47
+ return value
48
+ .replace(/&/g, '&amp;')
49
+ .replace(/</g, '&lt;')
50
+ .replace(/>/g, '&gt;')
51
+ .replace(/"/g, '&quot;')
52
+ .replace(/'/g, '&apos;');
53
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Markdown reporter. Renders an {@link EvalReport} as a markdown
3
+ * document suitable for embedding in pull-request descriptions or
4
+ * documentation sites.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ import type { EvalReport } from '@graphorin/observability/eval';
10
+
11
+ /** @stable */
12
+ export function renderMarkdownReport<I, O>(report: EvalReport<I, O>): string {
13
+ const lines: string[] = [];
14
+ lines.push('# graphorin/evals report');
15
+ lines.push('');
16
+ lines.push('## Summary');
17
+ lines.push('');
18
+ lines.push('| Metric | Value |');
19
+ lines.push('| --- | --- |');
20
+ lines.push(`| Total cases | ${report.summary.total} |`);
21
+ lines.push(`| Passed | ${report.summary.passed} |`);
22
+ lines.push(`| Failed | ${report.summary.failed} |`);
23
+ lines.push(`| Avg duration (ms) | ${report.summary.avgDurationMs.toFixed(2)} |`);
24
+ lines.push('');
25
+ lines.push('## Per-scorer');
26
+ lines.push('');
27
+ lines.push('| Scorer | Pass | Fail | Avg score |');
28
+ lines.push('| --- | ---: | ---: | ---: |');
29
+ for (const [scorer, row] of Object.entries(report.summary.byScorer)) {
30
+ const avg = row.avgScore === null ? 'n/a' : row.avgScore.toFixed(4);
31
+ lines.push(`| \`${scorer}\` | ${row.passed} | ${row.failed} | ${avg} |`);
32
+ }
33
+ lines.push('');
34
+ lines.push('## Failures');
35
+ lines.push('');
36
+ let any = false;
37
+ for (const r of report.results) {
38
+ const failures = r.scores.filter((s) => !s.result.pass);
39
+ if (failures.length === 0) continue;
40
+ any = true;
41
+ lines.push(`### \`${r.caseId}\` _(\`${r.durationMs}\` ms)_`);
42
+ lines.push('');
43
+ for (const f of failures) {
44
+ const reason = escapeMarkdownInline(f.result.reason ?? '_no reason_');
45
+ lines.push(`- **${f.scorer}** - ${reason}`);
46
+ }
47
+ lines.push('');
48
+ }
49
+ if (!any) {
50
+ lines.push('_No failures._');
51
+ lines.push('');
52
+ }
53
+ return lines.join('\n');
54
+ }
55
+
56
+ /**
57
+ * Escape characters that would break a Markdown table row when rendered
58
+ * inline. Backslashes must be escaped first so that an existing trailing
59
+ * `\` in the input cannot combine with our inserted `\|` to form a
60
+ * literal pipe. Newlines are flattened to spaces because table cells
61
+ * cannot contain hard line breaks.
62
+ */
63
+ function escapeMarkdownInline(value: string): string {
64
+ return value.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
65
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Terminal reporter. Renders an {@link EvalReport} as plain ANSI-free
3
+ * text suitable for stdout / CI logs.
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+
8
+ import type { EvalReport } from '@graphorin/observability/eval';
9
+
10
+ /** @stable */
11
+ export function renderTerminalReport<I, O>(report: EvalReport<I, O>): string {
12
+ const lines: string[] = [];
13
+ const { summary } = report;
14
+ lines.push('graphorin/evals - terminal report');
15
+ lines.push('=================================');
16
+ lines.push(`total: ${summary.total}`);
17
+ lines.push(`passed: ${summary.passed}`);
18
+ lines.push(`failed: ${summary.failed}`);
19
+ lines.push(`avg ms: ${summary.avgDurationMs.toFixed(2)}`);
20
+ lines.push('');
21
+ lines.push('per-scorer:');
22
+ for (const [scorer, row] of Object.entries(summary.byScorer)) {
23
+ const avg = row.avgScore === null ? 'n/a' : row.avgScore.toFixed(4);
24
+ lines.push(
25
+ ` ${scorer.padEnd(24)} pass=${row.passed.toString().padStart(4)} fail=${row.failed.toString().padStart(4)} avg=${avg}`,
26
+ );
27
+ }
28
+ lines.push('');
29
+ lines.push('failures (first 10):');
30
+ let failuresShown = 0;
31
+ for (const r of report.results) {
32
+ if (failuresShown >= 10) break;
33
+ const failures = r.scores.filter((s) => !s.result.pass);
34
+ if (failures.length === 0) continue;
35
+ failuresShown += 1;
36
+ lines.push(` • ${r.caseId} (${r.durationMs} ms)`);
37
+ for (const f of failures) {
38
+ const reason = f.result.reason ?? '<no reason>';
39
+ lines.push(` ${f.scorer}: ${reason}`);
40
+ }
41
+ }
42
+ if (failuresShown === 0) {
43
+ lines.push(' (none)');
44
+ }
45
+ return lines.join('\n');
46
+ }
package/src/runner.ts ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Parallel eval runner. Walks every case in the dataset (across N
3
+ * iterations), executes them with bounded concurrency, applies every
4
+ * scorer to each output, and returns an aggregated {@link EvalReport}.
5
+ *
6
+ * The runner respects:
7
+ *
8
+ * - `concurrency` - bounded worker pool. Default `1` (sequential).
9
+ * - `signal` - propagated to `agent.run(...)` if the agent accepts a
10
+ * second `{ signal }` argument; the runner stops issuing new work
11
+ * on the next loop iteration.
12
+ * - `onProgress` - invoked after every case with a heartbeat so
13
+ * long-running terminals can paint a progress bar.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+
18
+ import { passHatK, wilsonInterval } from './stats.js';
19
+ import type { Case, EvalCaseResult, EvalReport, RunOptions, ScoreResult, Scorer } from './types.js';
20
+
21
+ /**
22
+ * @stable
23
+ */
24
+ export async function runEvals<I, O>(opts: RunOptions<I, O>): Promise<EvalReport<I, O>> {
25
+ const iterations = Math.max(1, opts.iterations ?? 1);
26
+ const concurrency = Math.max(1, opts.concurrency ?? 1);
27
+ const signal = opts.signal;
28
+ const cases = opts.dataset.cases;
29
+ const total = cases.length * iterations;
30
+
31
+ type WorkItem = { idx: number; iter: number; sample: Case<I, O> };
32
+ const queue: WorkItem[] = [];
33
+ for (let iter = 0; iter < iterations; iter++) {
34
+ for (let idx = 0; idx < cases.length; idx++) {
35
+ const sample = cases[idx];
36
+ if (sample === undefined) continue;
37
+ queue.push({ idx, iter, sample });
38
+ }
39
+ }
40
+
41
+ const results: EvalCaseResult<I, O>[] = new Array(total);
42
+ let nextWorkIndex = 0;
43
+ let completed = 0;
44
+
45
+ async function worker(): Promise<void> {
46
+ while (true) {
47
+ // EB-14: stop dispatching new work on abort, but don't throw - whatever
48
+ // already completed must survive into a partial report.
49
+ if (isAborted(signal)) return;
50
+ const myIndex = nextWorkIndex++;
51
+ if (myIndex >= queue.length) return;
52
+ const item = queue[myIndex];
53
+ if (item === undefined) return;
54
+ // EB-6: a caller-provided id must still be disambiguated per iteration -
55
+ // otherwise iterations>1 emits multiple results under one caseId and
56
+ // JUnit/HTML reporters render indistinguishable testcases.
57
+ const baseId = item.sample.id ?? `case-${item.idx}`;
58
+ const caseId = iterations === 1 ? baseId : `${baseId}-iter-${item.iter}`;
59
+ const startedAt = Date.now();
60
+ let output: O;
61
+ try {
62
+ output = await opts.agent.run(
63
+ item.sample.input,
64
+ signal !== undefined ? { signal } : undefined,
65
+ );
66
+ } catch (err) {
67
+ // An abort-induced agent failure is not a real scorer failure - drop it
68
+ // so it doesn't pollute the partial report with spurious fails (EB-14).
69
+ if (isAborted(signal)) return;
70
+ const durationMs = Date.now() - startedAt;
71
+ const failResult: EvalCaseResult<I, O> = {
72
+ caseId,
73
+ input: item.sample.input,
74
+ output: undefined as unknown as O,
75
+ durationMs,
76
+ scores: opts.scorers.map((s) => ({
77
+ scorer: s.name,
78
+ result: {
79
+ pass: false,
80
+ reason: `agent.run threw: ${err instanceof Error ? err.message : String(err)}`,
81
+ },
82
+ })),
83
+ };
84
+ results[myIndex] = failResult;
85
+ completed += 1;
86
+ opts.onProgress?.({
87
+ index: completed,
88
+ total,
89
+ caseId,
90
+ durationMs,
91
+ passed: false,
92
+ });
93
+ continue;
94
+ }
95
+ const durationMs = Date.now() - startedAt;
96
+ const scores: EvalCaseResult<I, O>['scores'][number][] = [];
97
+ let allPassed = true;
98
+ for (const scorer of opts.scorers) {
99
+ // No mid-case abort check: once agent.run completes, finish scoring so
100
+ // the case lands intact in the partial report rather than half-done.
101
+ const result = await safeScore(scorer, item.sample, output, durationMs);
102
+ scores.push({ scorer: scorer.name, result });
103
+ if (!result.pass) allPassed = false;
104
+ }
105
+ results[myIndex] = {
106
+ caseId,
107
+ input: item.sample.input,
108
+ output,
109
+ durationMs,
110
+ scores,
111
+ };
112
+ completed += 1;
113
+ opts.onProgress?.({
114
+ index: completed,
115
+ total,
116
+ caseId,
117
+ durationMs,
118
+ passed: allPassed,
119
+ });
120
+ }
121
+ }
122
+
123
+ const workerCount = Math.min(concurrency, queue.length);
124
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
125
+
126
+ // EB-14: an aborted run resolves with a PARTIAL report (only the cases that
127
+ // finished) marked `aborted: true`, instead of discarding everything - a
128
+ // long judged run shouldn't lose all completed work to a Ctrl+C. Callers who
129
+ // prefer the old throw-on-abort opt in with `throwOnAbort`.
130
+ if (isAborted(signal)) {
131
+ if (opts.throwOnAbort === true) throwIfAborted(signal);
132
+ const done = results.filter((r): r is EvalCaseResult<I, O> => r !== undefined);
133
+ return { ...summarize(done, opts.scorers), aborted: true };
134
+ }
135
+ return summarize(results, opts.scorers);
136
+ }
137
+
138
+ async function safeScore<I, O>(
139
+ scorer: Scorer<I, O>,
140
+ sampleCase: Case<I, O>,
141
+ output: O,
142
+ durationMs: number,
143
+ ): Promise<ScoreResult> {
144
+ try {
145
+ return await scorer.score({ case: sampleCase, output, durationMs });
146
+ } catch (err) {
147
+ return {
148
+ pass: false,
149
+ reason: `Scorer "${scorer.name}" threw: ${err instanceof Error ? err.message : String(err)}`,
150
+ };
151
+ }
152
+ }
153
+
154
+ function summarize<I, O>(
155
+ results: ReadonlyArray<EvalCaseResult<I, O>>,
156
+ scorers: ReadonlyArray<Scorer<I, O>>,
157
+ ): EvalReport<I, O> {
158
+ const total = results.length;
159
+ let passed = 0;
160
+ let failed = 0;
161
+ let durationSum = 0;
162
+ const byScorer: Record<
163
+ string,
164
+ { passed: number; failed: number; scoreSum: number; scoreCount: number }
165
+ > = {};
166
+ for (const scorer of scorers) {
167
+ byScorer[scorer.name] = { passed: 0, failed: 0, scoreSum: 0, scoreCount: 0 };
168
+ }
169
+ for (const r of results) {
170
+ durationSum += r.durationMs;
171
+ let passEntire = true;
172
+ for (const { scorer, result } of r.scores) {
173
+ const bucket = byScorer[scorer] ?? { passed: 0, failed: 0, scoreSum: 0, scoreCount: 0 };
174
+ if (result.pass) bucket.passed += 1;
175
+ else {
176
+ bucket.failed += 1;
177
+ passEntire = false;
178
+ }
179
+ if (typeof result.score === 'number' && Number.isFinite(result.score)) {
180
+ bucket.scoreSum += result.score;
181
+ bucket.scoreCount += 1;
182
+ }
183
+ byScorer[scorer] = bucket;
184
+ }
185
+ if (passEntire) passed += 1;
186
+ else failed += 1;
187
+ }
188
+ const summaryByScorer: Record<
189
+ string,
190
+ { passed: number; failed: number; avgScore: number | null }
191
+ > = {};
192
+ for (const [scorer, b] of Object.entries(byScorer)) {
193
+ summaryByScorer[scorer] = {
194
+ passed: b.passed,
195
+ failed: b.failed,
196
+ avgScore: b.scoreCount === 0 ? null : b.scoreSum / b.scoreCount,
197
+ };
198
+ }
199
+ // E8 (evals-05): a bare pass count over a small suite communicates false
200
+ // precision - attach the 95% Wilson interval, and under `iterations > 1`
201
+ // the pass^k stability metric (mean pass rate hides a flaky case).
202
+ const outcomes = results.map((r) => ({
203
+ caseId: r.caseId,
204
+ pass: r.scores.every((s) => s.result.pass),
205
+ }));
206
+ const stability = passHatK(outcomes);
207
+ return {
208
+ results,
209
+ summary: {
210
+ total,
211
+ passed,
212
+ failed,
213
+ avgDurationMs: total === 0 ? 0 : durationSum / total,
214
+ byScorer: Object.freeze(summaryByScorer),
215
+ passRateCi: wilsonInterval(passed, total),
216
+ ...(stability.k > 1 ? { passHatK: stability } : {}),
217
+ },
218
+ };
219
+ }
220
+
221
+ // A plain function call so TypeScript re-reads `signal.aborted` each time -
222
+ // the flag flips during an `await` (external mutation) and inline
223
+ // `signal?.aborted === true` checks would otherwise be narrowed away.
224
+ function isAborted(signal: AbortSignal | undefined): boolean {
225
+ return signal?.aborted === true;
226
+ }
227
+
228
+ function throwIfAborted(signal: AbortSignal | undefined): void {
229
+ if (signal?.aborted === true) {
230
+ const reason = signal.reason;
231
+ throw reason instanceof Error ? reason : new Error('Eval run aborted');
232
+ }
233
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `exactMatch` - passes when `output` deeply equals `case.expected`.
3
+ * Useful for deterministic outputs (numeric / parsed JSON / classifier
4
+ * label).
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ import type { Scorer } from '@graphorin/observability/eval';
10
+
11
+ /** @stable */
12
+ export interface ExactMatchOptions {
13
+ /** Optional name override. Default `'exact-match'`. */
14
+ readonly name?: string;
15
+ /** When `true`, treat strings case-insensitively. Default `false`. */
16
+ readonly caseInsensitive?: boolean;
17
+ /** When `true`, trim whitespace before comparing strings. Default `false`. */
18
+ readonly trim?: boolean;
19
+ }
20
+
21
+ /**
22
+ * Build an exact-match scorer.
23
+ *
24
+ * @stable
25
+ */
26
+ export function exactMatch<I = unknown, O = unknown>(
27
+ options: ExactMatchOptions = {},
28
+ ): Scorer<I, O> {
29
+ const name = options.name ?? 'exact-match';
30
+ return {
31
+ name,
32
+ async score({ case: c, output }) {
33
+ const expected = c.expected;
34
+ if (expected === undefined) {
35
+ return {
36
+ pass: false,
37
+ reason: 'exactMatch requires a `case.expected` value.',
38
+ };
39
+ }
40
+ const a = normalize(output, options);
41
+ const b = normalize(expected, options);
42
+ const pass = deepEqual(a, b);
43
+ if (pass) return { pass, score: 1 };
44
+ return {
45
+ pass,
46
+ score: 0,
47
+ reason: `expected ${truncate(JSON.stringify(b))}, received ${truncate(JSON.stringify(a))}`,
48
+ };
49
+ },
50
+ };
51
+ }
52
+
53
+ function normalize(value: unknown, opts: ExactMatchOptions): unknown {
54
+ if (typeof value === 'string') {
55
+ let v = value;
56
+ if (opts.trim === true) v = v.trim();
57
+ if (opts.caseInsensitive === true) v = v.toLowerCase();
58
+ return v;
59
+ }
60
+ return value;
61
+ }
62
+
63
+ function deepEqual(a: unknown, b: unknown): boolean {
64
+ if (a === b) return true;
65
+ if (typeof a !== typeof b) return false;
66
+ if (a === null || b === null) return a === b;
67
+ if (Array.isArray(a) && Array.isArray(b)) {
68
+ if (a.length !== b.length) return false;
69
+ for (let i = 0; i < a.length; i++) {
70
+ if (!deepEqual(a[i], b[i])) return false;
71
+ }
72
+ return true;
73
+ }
74
+ if (typeof a === 'object' && typeof b === 'object') {
75
+ const aKeys = Object.keys(a as Record<string, unknown>).sort();
76
+ const bKeys = Object.keys(b as Record<string, unknown>).sort();
77
+ if (aKeys.length !== bKeys.length) return false;
78
+ for (let i = 0; i < aKeys.length; i++) {
79
+ if (aKeys[i] !== bKeys[i]) return false;
80
+ if (
81
+ !deepEqual(
82
+ (a as Record<string, unknown>)[aKeys[i] as string],
83
+ (b as Record<string, unknown>)[bKeys[i] as string],
84
+ )
85
+ )
86
+ return false;
87
+ }
88
+ return true;
89
+ }
90
+ return false;
91
+ }
92
+
93
+ function truncate(s: string, max: number = 80): string {
94
+ return s.length > max ? `${s.slice(0, max)}…` : s;
95
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Pure-code scorers: exact-match, regex, json-path, predicate.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ export { type ExactMatchOptions, exactMatch } from './exact-match.js';
8
+ export { type JsonPathOptions, jsonPath } from './json-path.js';
9
+ export { type PredicateOptions, predicate } from './predicate.js';
10
+ export { type RegexMatchOptions, regexMatch } from './regex.js';
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `jsonPath` - passes when the value at a JSON-pointer-shaped path
3
+ * deep-equals the caller-supplied target. Supports the dot-notation
4
+ * subset (`a.b.c`) and array indices (`a.0.name`).
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ import type { Scorer } from '@graphorin/observability/eval';
10
+
11
+ /** @stable */
12
+ export interface JsonPathOptions {
13
+ /** Dot-separated path (`'data.user.id'`). */
14
+ readonly path: string;
15
+ /** Expected value. Compared by deep-equality. */
16
+ readonly equals: unknown;
17
+ /** Optional name override. */
18
+ readonly name?: string;
19
+ }
20
+
21
+ /** @stable */
22
+ export function jsonPath<I = unknown>(options: JsonPathOptions): Scorer<I, unknown> {
23
+ const name = options.name ?? `json-path:${options.path}`;
24
+ return {
25
+ name,
26
+ async score({ output }) {
27
+ const actual = readPath(output, options.path);
28
+ const pass = deepEqual(actual, options.equals);
29
+ if (pass) return { pass, score: 1 };
30
+ return {
31
+ pass,
32
+ score: 0,
33
+ reason:
34
+ `at '${options.path}' expected ${truncate(stringifySafe(options.equals))}, ` +
35
+ `received ${truncate(stringifySafe(actual))}.`,
36
+ };
37
+ },
38
+ };
39
+ }
40
+
41
+ function readPath(root: unknown, path: string): unknown {
42
+ if (path.length === 0) return root;
43
+ const segments = path.split('.');
44
+ let cursor: unknown = root;
45
+ for (const segment of segments) {
46
+ if (cursor === null || cursor === undefined) return undefined;
47
+ if (Array.isArray(cursor)) {
48
+ const idx = Number.parseInt(segment, 10);
49
+ if (!Number.isFinite(idx)) return undefined;
50
+ cursor = cursor[idx];
51
+ } else if (typeof cursor === 'object') {
52
+ cursor = (cursor as Record<string, unknown>)[segment];
53
+ } else {
54
+ return undefined;
55
+ }
56
+ }
57
+ return cursor;
58
+ }
59
+
60
+ function deepEqual(a: unknown, b: unknown): boolean {
61
+ if (a === b) return true;
62
+ if (typeof a !== typeof b) return false;
63
+ if (a === null || b === null) return a === b;
64
+ if (Array.isArray(a) && Array.isArray(b)) {
65
+ if (a.length !== b.length) return false;
66
+ for (let i = 0; i < a.length; i++) {
67
+ if (!deepEqual(a[i], b[i])) return false;
68
+ }
69
+ return true;
70
+ }
71
+ if (typeof a === 'object' && typeof b === 'object') {
72
+ const aKeys = Object.keys(a as Record<string, unknown>).sort();
73
+ const bKeys = Object.keys(b as Record<string, unknown>).sort();
74
+ if (aKeys.length !== bKeys.length) return false;
75
+ for (let i = 0; i < aKeys.length; i++) {
76
+ if (aKeys[i] !== bKeys[i]) return false;
77
+ if (
78
+ !deepEqual(
79
+ (a as Record<string, unknown>)[aKeys[i] as string],
80
+ (b as Record<string, unknown>)[bKeys[i] as string],
81
+ )
82
+ )
83
+ return false;
84
+ }
85
+ return true;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ function truncate(s: string, max: number = 80): string {
91
+ return s.length > max ? `${s.slice(0, max)}…` : s;
92
+ }
93
+
94
+ function stringifySafe(value: unknown): string {
95
+ if (value === undefined) return 'undefined';
96
+ try {
97
+ return JSON.stringify(value) ?? String(value);
98
+ } catch {
99
+ return String(value);
100
+ }
101
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `predicate` - passes when the caller-supplied predicate returns
3
+ * truthy. The escape hatch for ad-hoc, project-specific scoring rules.
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+
8
+ import type { Case, ScoreResult, Scorer } from '@graphorin/observability/eval';
9
+
10
+ /** @stable */
11
+ export interface PredicateOptions<I, O> {
12
+ readonly name: string;
13
+ readonly check: (args: {
14
+ readonly case: Case<I, O>;
15
+ readonly output: O;
16
+ readonly durationMs: number;
17
+ }) => Promise<boolean | ScoreResult> | boolean | ScoreResult;
18
+ }
19
+
20
+ /** @stable */
21
+ export function predicate<I = unknown, O = unknown>(options: PredicateOptions<I, O>): Scorer<I, O> {
22
+ return {
23
+ name: options.name,
24
+ async score(args) {
25
+ const result = await options.check(args);
26
+ if (typeof result === 'boolean') {
27
+ return { pass: result, score: result ? 1 : 0 };
28
+ }
29
+ return result;
30
+ },
31
+ };
32
+ }