@graphorin/evals 0.6.1 → 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 (48) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +3 -3
  3. package/dist/loaders/locomo.d.ts.map +1 -1
  4. package/dist/loaders/locomo.js +24 -2
  5. package/dist/loaders/locomo.js.map +1 -1
  6. package/dist/loaders/memory-eval.d.ts +8 -0
  7. package/dist/loaders/memory-eval.d.ts.map +1 -1
  8. package/dist/package.js +1 -1
  9. package/dist/package.js.map +1 -1
  10. package/package.json +10 -9
  11. package/src/cli/index.ts +124 -0
  12. package/src/index.ts +136 -0
  13. package/src/loaders/csv.ts +147 -0
  14. package/src/loaders/dmr.ts +137 -0
  15. package/src/loaders/from-traces.ts +92 -0
  16. package/src/loaders/index.ts +46 -0
  17. package/src/loaders/iterable.ts +22 -0
  18. package/src/loaders/jsonl.ts +94 -0
  19. package/src/loaders/locomo.ts +217 -0
  20. package/src/loaders/longmemeval.ts +164 -0
  21. package/src/loaders/memory-eval.ts +80 -0
  22. package/src/regression.ts +133 -0
  23. package/src/reporters/html.ts +83 -0
  24. package/src/reporters/index.ts +14 -0
  25. package/src/reporters/json.ts +18 -0
  26. package/src/reporters/junit.ts +53 -0
  27. package/src/reporters/markdown.ts +65 -0
  28. package/src/reporters/terminal.ts +46 -0
  29. package/src/runner.ts +233 -0
  30. package/src/scorers/code/exact-match.ts +95 -0
  31. package/src/scorers/code/index.ts +10 -0
  32. package/src/scorers/code/json-path.ts +101 -0
  33. package/src/scorers/code/predicate.ts +32 -0
  34. package/src/scorers/code/regex.ts +55 -0
  35. package/src/scorers/index.ts +15 -0
  36. package/src/scorers/llm/index.ts +13 -0
  37. package/src/scorers/llm/judge.ts +170 -0
  38. package/src/scorers/prebuilt/index.ts +95 -0
  39. package/src/scorers/trajectory/argument-validity.ts +63 -0
  40. package/src/scorers/trajectory/correct-tool-selected.ts +61 -0
  41. package/src/scorers/trajectory/final-state-correct.ts +62 -0
  42. package/src/scorers/trajectory/index.ts +25 -0
  43. package/src/scorers/trajectory/recovery-after-error.ts +55 -0
  44. package/src/scorers/trajectory/redundant-call-detection.ts +58 -0
  45. package/src/scorers/trajectory/types.ts +44 -0
  46. package/src/scorers/trajectory/util.ts +81 -0
  47. package/src/stats.ts +196 -0
  48. package/src/types.ts +135 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Real LOCOMO dataset loader (arXiv:2402.17753 -
3
+ * https://snap-research.github.io/locomo/). LOCOMO ships as a JSON
4
+ * **array** of samples; each sample bundles one multi-session
5
+ * conversation with many QA pairs over it:
6
+ *
7
+ * ```jsonc
8
+ * {
9
+ * "sample_id": "conv-26",
10
+ * "conversation": {
11
+ * "speaker_a": "Caroline",
12
+ * "speaker_b": "Melanie",
13
+ * "session_1_date_time": "1:56 pm on 8 May, 2023",
14
+ * "session_1": [ { "speaker": "Caroline", "dia_id": "D1:1", "text": "..." }, ... ],
15
+ * "session_2_date_time": "...",
16
+ * "session_2": [ ... ]
17
+ * },
18
+ * "qa": [
19
+ * { "question": "...", "answer": "...", "evidence": ["D1:1"], "category": 1 },
20
+ * { "question": "...", "category": 5, "adversarial_answer": "Not mentioned ..." }
21
+ * ]
22
+ * }
23
+ * ```
24
+ *
25
+ * Each QA pair becomes one {@link MemoryEvalInput} case that shares the
26
+ * sample's sessions. LOCOMO categories map to abilities as:
27
+ * `1`→multi-session (multi-hop), `2`→temporal, `5`→abstention
28
+ * (adversarial), everything else (`3` open-domain, `4` single-hop)
29
+ * →info-extraction.
30
+ *
31
+ * Answer handling (W-022): LOCOMO answers may be numbers (e.g. `2022`)
32
+ * - they are stringified, never silently coerced to `''`. A QA pair
33
+ * with NO answer at all (neither `answer` nor `adversarial_answer`) is
34
+ * SKIPPED rather than emitted with an empty `expected`: the LLM judge
35
+ * grading against an empty reference produces meaningless verdicts, so
36
+ * a missing reference must reduce the case count, not poison scores.
37
+ * Turns carry the dataset-native `speaker` name alongside the
38
+ * two-role mapping (~99 percent of LOCOMO questions reference speakers
39
+ * by name).
40
+ *
41
+ * @packageDocumentation
42
+ */
43
+
44
+ import { readFile } from 'node:fs/promises';
45
+
46
+ import type { Case, Dataset } from '@graphorin/observability/eval';
47
+
48
+ import type {
49
+ MemoryEvalAbility,
50
+ MemoryEvalInput,
51
+ MemoryEvalSession,
52
+ MemoryEvalTurn,
53
+ } from './memory-eval.js';
54
+
55
+ /** @stable */
56
+ export interface LoadLocomoOptions {
57
+ /** Local path to the LOCOMO JSON (under `benchmarks/.datasets/`). */
58
+ readonly path: string;
59
+ /** Optional dataset name surfaced in `Dataset.metadata.name`. */
60
+ readonly name?: string;
61
+ /** Optional description surfaced in `Dataset.metadata.description`. */
62
+ readonly description?: string;
63
+ }
64
+
65
+ /**
66
+ * Read a LOCOMO JSON file and return a fully-materialised
67
+ * {@link Dataset} - one case per QA pair, scored against the reference
68
+ * answer string (LLM-judge "J").
69
+ *
70
+ * @stable
71
+ */
72
+ export async function loadLocomoDataset(
73
+ options: LoadLocomoOptions,
74
+ ): Promise<Dataset<MemoryEvalInput, string>> {
75
+ const text = await readFile(options.path, 'utf8');
76
+ const cases = parseLocomo(text);
77
+ return {
78
+ cases,
79
+ metadata: {
80
+ name: options.name ?? 'locomo',
81
+ ...(options.description !== undefined ? { description: options.description } : {}),
82
+ createdAt: new Date(),
83
+ },
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Pure parser. Exported so tests can exercise the mapping without
89
+ * touching the filesystem.
90
+ *
91
+ * @stable
92
+ */
93
+ export function parseLocomo(text: string): ReadonlyArray<Case<MemoryEvalInput, string>> {
94
+ const parsed: unknown = JSON.parse(text);
95
+ if (!Array.isArray(parsed)) {
96
+ throw new Error('[graphorin/evals] LOCOMO dataset must be a JSON array of samples.');
97
+ }
98
+ const samples = parsed as ReadonlyArray<unknown>;
99
+ const out: Case<MemoryEvalInput, string>[] = [];
100
+ for (let i = 0; i < samples.length; i++) {
101
+ const sample = samples[i];
102
+ if (sample === null || typeof sample !== 'object') {
103
+ throw new Error(`[graphorin/evals] LOCOMO sample ${i} is not an object.`);
104
+ }
105
+ appendSampleCases(sample as Record<string, unknown>, i, out);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function appendSampleCases(
111
+ sample: Record<string, unknown>,
112
+ index: number,
113
+ out: Case<MemoryEvalInput, string>[],
114
+ ): void {
115
+ const sampleId = asString(sample.sample_id) ?? `locomo-${index}`;
116
+ const conversation = isRecord(sample.conversation) ? sample.conversation : {};
117
+ const sessions = extractSessions(conversation);
118
+ const qa = asArray(sample.qa);
119
+ for (let q = 0; q < qa.length; q++) {
120
+ const item = qa[q];
121
+ if (!isRecord(item)) continue;
122
+ const question = asString(item.question);
123
+ if (question === undefined) continue;
124
+ const category = typeof item.category === 'number' ? item.category : undefined;
125
+ const ability = mapCategory(category);
126
+ const answer = answerToText(item.answer) ?? answerToText(item.adversarial_answer);
127
+ // No reference answer at all: skip the pair (see module docs, W-022).
128
+ if (answer === undefined) continue;
129
+ const evidence = asArray(item.evidence).filter((e): e is string => typeof e === 'string');
130
+ out.push({
131
+ id: `${sampleId}-q${q}`,
132
+ input: { haystackSessions: sessions, question, ability },
133
+ expected: answer,
134
+ metadata: {
135
+ datasetName: 'locomo',
136
+ sampleId,
137
+ ability,
138
+ ...(category !== undefined ? { category } : {}),
139
+ evidence,
140
+ },
141
+ });
142
+ }
143
+ }
144
+
145
+ function extractSessions(conversation: Record<string, unknown>): MemoryEvalSession[] {
146
+ const speakerA = asString(conversation.speaker_a);
147
+ const indices: number[] = [];
148
+ for (const key of Object.keys(conversation)) {
149
+ const m = /^session_(\d+)$/.exec(key);
150
+ if (m?.[1] !== undefined) indices.push(Number.parseInt(m[1], 10));
151
+ }
152
+ indices.sort((a, b) => a - b);
153
+ const sessions: MemoryEvalSession[] = [];
154
+ for (const n of indices) {
155
+ const raw = asArray(conversation[`session_${n}`]);
156
+ const date = asString(conversation[`session_${n}_date_time`]);
157
+ const turns: MemoryEvalTurn[] = [];
158
+ for (const t of raw) {
159
+ if (!isRecord(t)) continue;
160
+ const content = asString(t.text) ?? asString(t.content);
161
+ if (content === undefined) continue;
162
+ const speaker = asString(t.speaker);
163
+ const role: 'user' | 'assistant' =
164
+ speakerA !== undefined && speaker !== undefined && speaker !== speakerA
165
+ ? 'assistant'
166
+ : 'user';
167
+ turns.push({
168
+ role,
169
+ content,
170
+ ...(date !== undefined ? { timestamp: date } : {}),
171
+ // W-022: keep the dataset-native name; the role mapping stays
172
+ // for cross-dataset machinery compatibility.
173
+ ...(speaker !== undefined ? { speaker } : {}),
174
+ });
175
+ }
176
+ sessions.push({ id: `session_${n}`, turns });
177
+ }
178
+ return sessions;
179
+ }
180
+
181
+ /** Maps a LOCOMO numeric `category` onto a {@link MemoryEvalAbility}. */
182
+ function mapCategory(category: number | undefined): MemoryEvalAbility {
183
+ switch (category) {
184
+ case 1:
185
+ return 'multi-session';
186
+ case 2:
187
+ return 'temporal';
188
+ case 5:
189
+ return 'abstention';
190
+ default:
191
+ return 'info-extraction';
192
+ }
193
+ }
194
+
195
+ function asString(value: unknown): string | undefined {
196
+ return typeof value === 'string' ? value : undefined;
197
+ }
198
+
199
+ /**
200
+ * LOCOMO reference answers may be strings OR bare numbers (6 of the
201
+ * 1986 QA pairs ship e.g. `2022`). Stringify finite numbers instead of
202
+ * dropping them to `''` (W-022); a blank string counts as "no answer"
203
+ * so the judge never grades against an empty reference.
204
+ */
205
+ function answerToText(value: unknown): string | undefined {
206
+ if (typeof value === 'string') return value.trim().length > 0 ? value : undefined;
207
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
208
+ return undefined;
209
+ }
210
+
211
+ function asArray(value: unknown): ReadonlyArray<unknown> {
212
+ return Array.isArray(value) ? (value as ReadonlyArray<unknown>) : [];
213
+ }
214
+
215
+ function isRecord(value: unknown): value is Record<string, unknown> {
216
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
217
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * LongMemEval dataset loader (ICLR 2025, arXiv:2410.10813 -
3
+ * https://github.com/xiaowu0162/LongMemEval). LongMemEval ships as a
4
+ * JSON **array** of question objects:
5
+ *
6
+ * ```jsonc
7
+ * {
8
+ * "question_id": "abc_abs", // `_abs` suffix marks abstention
9
+ * "question_type": "temporal-reasoning",
10
+ * "question": "Where did I live in 2021?",
11
+ * "answer": "Berlin",
12
+ * "question_date": "2023/05/20 (Sat) 14:00",
13
+ * "haystack_session_ids": ["s1", "s2"],
14
+ * "haystack_dates": ["2021/03/01 ...", "2023/01/05 ..."],
15
+ * "haystack_sessions": [
16
+ * [ { "role": "user", "content": "..." }, { "role": "assistant", "content": "...", "has_answer": true } ],
17
+ * [ ... ]
18
+ * ]
19
+ * }
20
+ * ```
21
+ *
22
+ * The three parallel arrays (`haystack_session_ids` / `haystack_dates`
23
+ * / `haystack_sessions`) are zipped by index; one date applies to every
24
+ * turn in its session.
25
+ *
26
+ * @packageDocumentation
27
+ */
28
+
29
+ import { readFile } from 'node:fs/promises';
30
+
31
+ import type { Case, Dataset } from '@graphorin/observability/eval';
32
+
33
+ import type {
34
+ MemoryEvalAbility,
35
+ MemoryEvalInput,
36
+ MemoryEvalSession,
37
+ MemoryEvalTurn,
38
+ } from './memory-eval.js';
39
+
40
+ /** @stable */
41
+ export interface LoadLongMemEvalOptions {
42
+ /** Local path to the dataset JSON (under `benchmarks/.datasets/`). */
43
+ readonly path: string;
44
+ /** Which release this file is. `'S'` (~115 sessions) is the default target. */
45
+ readonly variant?: 'S' | 'M';
46
+ /** When set, keep only cases whose mapped ability is in this list. */
47
+ readonly abilities?: ReadonlyArray<MemoryEvalAbility>;
48
+ /** Optional dataset name surfaced in `Dataset.metadata.name`. */
49
+ readonly name?: string;
50
+ /** Optional description surfaced in `Dataset.metadata.description`. */
51
+ readonly description?: string;
52
+ }
53
+
54
+ /**
55
+ * Read a LongMemEval JSON file and return a fully-materialised
56
+ * {@link Dataset} of {@link MemoryEvalInput} cases scored against the
57
+ * reference `answer` string.
58
+ *
59
+ * @stable
60
+ */
61
+ export async function loadLongMemEvalDataset(
62
+ options: LoadLongMemEvalOptions,
63
+ ): Promise<Dataset<MemoryEvalInput, string>> {
64
+ const text = await readFile(options.path, 'utf8');
65
+ const cases = parseLongMemEval(text, options.abilities);
66
+ const variantSuffix = options.variant !== undefined ? `_${options.variant.toLowerCase()}` : '';
67
+ return {
68
+ cases,
69
+ metadata: {
70
+ name: options.name ?? `longmemeval${variantSuffix}`,
71
+ ...(options.description !== undefined ? { description: options.description } : {}),
72
+ createdAt: new Date(),
73
+ },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Pure parser. Exported so tests can exercise the mapping without
79
+ * touching the filesystem.
80
+ *
81
+ * @stable
82
+ */
83
+ export function parseLongMemEval(
84
+ text: string,
85
+ abilities?: ReadonlyArray<MemoryEvalAbility>,
86
+ ): ReadonlyArray<Case<MemoryEvalInput, string>> {
87
+ const parsed: unknown = JSON.parse(text);
88
+ if (!Array.isArray(parsed)) {
89
+ throw new Error('[graphorin/evals] LongMemEval dataset must be a JSON array of questions.');
90
+ }
91
+ const rows = parsed as ReadonlyArray<unknown>;
92
+ const out: Case<MemoryEvalInput, string>[] = [];
93
+ for (let i = 0; i < rows.length; i++) {
94
+ const row = rows[i];
95
+ if (row === null || typeof row !== 'object') {
96
+ throw new Error(`[graphorin/evals] LongMemEval row ${i} is not an object.`);
97
+ }
98
+ const mapped = mapRow(row as Record<string, unknown>, i);
99
+ if (abilities !== undefined && !abilities.includes(mapped.input.ability ?? 'info-extraction')) {
100
+ continue;
101
+ }
102
+ out.push(mapped);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ function mapRow(row: Record<string, unknown>, index: number): Case<MemoryEvalInput, string> {
108
+ const question = asString(row.question);
109
+ if (question === undefined) {
110
+ throw new Error(`[graphorin/evals] LongMemEval row ${index} is missing a 'question'.`);
111
+ }
112
+ const questionId = asString(row.question_id) ?? `longmemeval-${index}`;
113
+ const questionType = asString(row.question_type) ?? '';
114
+ const askedAt = asString(row.question_date);
115
+ const ability = mapAbility(questionType, questionId);
116
+
117
+ const rawSessions = asArray(row.haystack_sessions);
118
+ const sessionIds = asArray(row.haystack_session_ids);
119
+ const sessionDates = asArray(row.haystack_dates);
120
+ const haystackSessions: MemoryEvalSession[] = [];
121
+ for (let s = 0; s < rawSessions.length; s++) {
122
+ const rawTurns = asArray(rawSessions[s]);
123
+ const date = asString(sessionDates[s]);
124
+ const turns: MemoryEvalTurn[] = [];
125
+ for (const t of rawTurns) {
126
+ if (t === null || typeof t !== 'object') continue;
127
+ const content = asString((t as Record<string, unknown>).content);
128
+ if (content === undefined) continue;
129
+ const role = (t as Record<string, unknown>).role === 'assistant' ? 'assistant' : 'user';
130
+ turns.push({ role, content, ...(date !== undefined ? { timestamp: date } : {}) });
131
+ }
132
+ haystackSessions.push({ id: asString(sessionIds[s]) ?? `session-${s}`, turns });
133
+ }
134
+
135
+ return {
136
+ id: questionId,
137
+ input: {
138
+ haystackSessions,
139
+ question,
140
+ ...(askedAt !== undefined ? { askedAt } : {}),
141
+ ability,
142
+ },
143
+ expected: asString(row.answer) ?? '',
144
+ metadata: { datasetName: 'longmemeval', ability, questionType },
145
+ };
146
+ }
147
+
148
+ /** Maps a LongMemEval `question_type` / `_abs` suffix onto a {@link MemoryEvalAbility}. */
149
+ function mapAbility(questionType: string, questionId: string): MemoryEvalAbility {
150
+ const qt = questionType.toLowerCase();
151
+ if (questionId.endsWith('_abs') || qt.includes('abstention')) return 'abstention';
152
+ if (qt.includes('temporal')) return 'temporal';
153
+ if (qt.includes('knowledge-update') || qt.includes('knowledge_update')) return 'knowledge-update';
154
+ if (qt.includes('multi-session') || qt.includes('multi_session')) return 'multi-session';
155
+ return 'info-extraction';
156
+ }
157
+
158
+ function asString(value: unknown): string | undefined {
159
+ return typeof value === 'string' ? value : undefined;
160
+ }
161
+
162
+ function asArray(value: unknown): ReadonlyArray<unknown> {
163
+ return Array.isArray(value) ? (value as ReadonlyArray<unknown>) : [];
164
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Shared "memory system under test" contract used by the long-term
3
+ * memory eval loaders ({@link loadLongMemEvalDataset},
4
+ * {@link loadLocomoDataset}, {@link loadDmrDataset}) and the benchmark
5
+ * runner. A loader turns a dataset's native JSON into
6
+ * `Case<MemoryEvalInput, string>`: the input carries the haystack of
7
+ * prior sessions plus the question; the expected output is the
8
+ * reference answer string, graded by the `llmJudge` "J" scorer.
9
+ *
10
+ * This module is intentionally **type-only** and carries no dependency
11
+ * on `@graphorin/memory` - the concrete system-under-test agent lives
12
+ * in the benchmark package so `@graphorin/evals` stays a generic
13
+ * harness.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+
18
+ /**
19
+ * One conversational turn inside a haystack session.
20
+ *
21
+ * @stable
22
+ */
23
+ export interface MemoryEvalTurn {
24
+ readonly role: 'user' | 'assistant';
25
+ readonly content: string;
26
+ /** Dataset-native (often ISO-8601) timestamp, when the dataset provides one. */
27
+ readonly timestamp?: string;
28
+ /**
29
+ * Dataset-native speaker NAME (e.g. LOCOMO's `"Melanie"`), when the
30
+ * dataset provides one (W-022). Distinct from `role`: two-speaker
31
+ * datasets map onto user/assistant for machinery compatibility, but
32
+ * most LOCOMO questions reference the speakers by name, so the
33
+ * system under test must see the names in the ingested text.
34
+ */
35
+ readonly speaker?: string;
36
+ }
37
+
38
+ /**
39
+ * One prior session in the haystack the memory system must ingest
40
+ * before the question is asked.
41
+ *
42
+ * @stable
43
+ */
44
+ export interface MemoryEvalSession {
45
+ readonly id: string;
46
+ readonly turns: ReadonlyArray<MemoryEvalTurn>;
47
+ }
48
+
49
+ /**
50
+ * The five LongMemEval abilities. Every loader maps its dataset-native
51
+ * category onto one of these so per-ability scoring is comparable
52
+ * across datasets; the raw category is preserved in `Case.metadata`.
53
+ *
54
+ * @stable
55
+ */
56
+ export type MemoryEvalAbility =
57
+ | 'info-extraction'
58
+ | 'multi-session'
59
+ | 'temporal'
60
+ | 'knowledge-update'
61
+ | 'abstention';
62
+
63
+ /**
64
+ * Input handed to the memory system under test for one eval case.
65
+ *
66
+ * @stable
67
+ */
68
+ export interface MemoryEvalInput {
69
+ /** Prior sessions to ingest before the question is asked. */
70
+ readonly haystackSessions: ReadonlyArray<MemoryEvalSession>;
71
+ /** The question to answer from memory. */
72
+ readonly question: string;
73
+ /**
74
+ * When the question is asked. Drives temporal / knowledge-update
75
+ * reasoning; dataset-native string (not necessarily ISO-8601).
76
+ */
77
+ readonly askedAt?: string;
78
+ /** Mapped ability ({@link MemoryEvalAbility}); the raw category lives in metadata. */
79
+ readonly ability?: MemoryEvalAbility;
80
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Compares two `EvalReport`s - typically the freshly-produced report
3
+ * vs a stored baseline - and lists every metric that regressed beyond
4
+ * the configured tolerance.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ import { pairedPassSignificance, passByBaseCase } from './stats.js';
10
+ import type {
11
+ EvalReport,
12
+ RegressionFinding,
13
+ RegressionOptions,
14
+ RegressionReport,
15
+ } from './types.js';
16
+
17
+ const DEFAULT_PASS_RATE_DROP_PCT = 5;
18
+ const DEFAULT_AVG_SCORE_DROP = 0.05;
19
+ // EB-4: the avg-duration gate is OPT-IN (default off). Absolute wall-clock
20
+ // budgets are environment-sensitive - a baseline recorded on a fast
21
+ // workstation vs a loaded CI runner, or real LLM-latency jitter of whole
22
+ // seconds, would flag spurious "regressions". Callers that genuinely want a
23
+ // duration budget pass an explicit finite `maxAvgDurationIncreaseMs`.
24
+ const DEFAULT_AVG_DURATION_INCREASE_MS = Number.POSITIVE_INFINITY;
25
+
26
+ /**
27
+ * Detect regressions between `current` and `baseline` reports.
28
+ *
29
+ * @stable
30
+ */
31
+ export function detectRegressions<I, O>(
32
+ current: EvalReport<I, O>,
33
+ baseline: EvalReport<I, O>,
34
+ options: RegressionOptions = {},
35
+ ): RegressionReport<I, O> {
36
+ const maxPassRateDropPct = options.maxPassRateDropPct ?? DEFAULT_PASS_RATE_DROP_PCT;
37
+ const maxAvgScoreDrop = options.maxAvgScoreDrop ?? DEFAULT_AVG_SCORE_DROP;
38
+ const maxAvgDurationIncreaseMs =
39
+ options.maxAvgDurationIncreaseMs ?? DEFAULT_AVG_DURATION_INCREASE_MS;
40
+ const findings: RegressionFinding[] = [];
41
+
42
+ const currentPassRate = total(current) === 0 ? 0 : current.summary.passed / total(current);
43
+ const baselinePassRate = total(baseline) === 0 ? 0 : baseline.summary.passed / total(baseline);
44
+ const passRateDropPct = (baselinePassRate - currentPassRate) * 100;
45
+ if (passRateDropPct > maxPassRateDropPct) {
46
+ // E8 (evals-05/08): the fixed tolerance is sample-size blind, so pair the
47
+ // shared cases and run McNemar's test. The p-value is always attached for
48
+ // the report; it VETOES the finding only under opt-in requireSignificance.
49
+ const significance = pairedPassSignificance(
50
+ passByBaseCase(caseOutcomes(current)),
51
+ passByBaseCase(caseOutcomes(baseline)),
52
+ );
53
+ const havePairs = significance.pairs > 0;
54
+ const alpha = options.significanceAlpha ?? 0.05;
55
+ const vetoed =
56
+ options.requireSignificance === true && havePairs && significance.pValue >= alpha;
57
+ if (!vetoed) {
58
+ findings.push({
59
+ kind: 'pass-rate-drop',
60
+ message:
61
+ `pass rate dropped by ${passRateDropPct.toFixed(2)}% ` +
62
+ `(${(currentPassRate * 100).toFixed(2)}% < baseline ${(baselinePassRate * 100).toFixed(2)}% ` +
63
+ `- tolerance ${maxPassRateDropPct.toFixed(2)}%)` +
64
+ (havePairs
65
+ ? ` [paired: ${significance.regressed} regressed / ${significance.improved} improved over ${significance.pairs} shared case(s), McNemar p=${significance.pValue.toFixed(4)}]`
66
+ : '') +
67
+ '.',
68
+ delta: -passRateDropPct,
69
+ ...(havePairs ? { pValue: significance.pValue } : {}),
70
+ });
71
+ }
72
+ }
73
+
74
+ const durationDelta = current.summary.avgDurationMs - baseline.summary.avgDurationMs;
75
+ if (durationDelta > maxAvgDurationIncreaseMs) {
76
+ findings.push({
77
+ kind: 'avg-duration-increase',
78
+ message:
79
+ `avg duration increased by ${durationDelta.toFixed(2)} ms ` +
80
+ `(${current.summary.avgDurationMs.toFixed(2)} ms > baseline ` +
81
+ `${baseline.summary.avgDurationMs.toFixed(2)} ms - tolerance ${maxAvgDurationIncreaseMs} ms).`,
82
+ delta: durationDelta,
83
+ });
84
+ }
85
+
86
+ for (const [scorer, baselineRow] of Object.entries(baseline.summary.byScorer)) {
87
+ const currentRow = current.summary.byScorer[scorer];
88
+ if (currentRow === undefined) {
89
+ findings.push({
90
+ kind: 'scorer-removed',
91
+ scorer,
92
+ message: `scorer '${scorer}' is in the baseline but missing from the current run.`,
93
+ delta: Number.NaN,
94
+ });
95
+ continue;
96
+ }
97
+ if (typeof baselineRow.avgScore === 'number' && typeof currentRow.avgScore === 'number') {
98
+ const drop = baselineRow.avgScore - currentRow.avgScore;
99
+ if (drop > maxAvgScoreDrop) {
100
+ findings.push({
101
+ kind: 'avg-score-drop',
102
+ scorer,
103
+ message:
104
+ `'${scorer}' avg score dropped by ${drop.toFixed(4)} ` +
105
+ `(${currentRow.avgScore.toFixed(4)} < baseline ${baselineRow.avgScore.toFixed(4)} ` +
106
+ `- tolerance ${maxAvgScoreDrop}).`,
107
+ delta: -drop,
108
+ });
109
+ }
110
+ }
111
+ }
112
+
113
+ return {
114
+ hasRegressions: findings.length > 0,
115
+ findings,
116
+ current,
117
+ baseline,
118
+ };
119
+ }
120
+
121
+ function total<I, O>(r: EvalReport<I, O>): number {
122
+ return r.summary.total;
123
+ }
124
+
125
+ /** Per-iteration case outcomes (a case passes when every scorer passed). */
126
+ function caseOutcomes<I, O>(
127
+ r: EvalReport<I, O>,
128
+ ): ReadonlyArray<{ readonly caseId: string; readonly pass: boolean }> {
129
+ return r.results.map((c) => ({
130
+ caseId: c.caseId,
131
+ pass: c.scores.every((s) => s.result.pass),
132
+ }));
133
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * HTML reporter. Renders an {@link EvalReport} as a self-contained
3
+ * HTML document - no external CSS / JS - so artifact viewers can
4
+ * render it directly. Designed to fit on a single page; for richer
5
+ * dashboards consume the JSON reporter instead.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import type { EvalReport } from '@graphorin/observability/eval';
11
+
12
+ /** @stable */
13
+ export function renderHtmlReport<I, O>(
14
+ report: EvalReport<I, O>,
15
+ options: { readonly title?: string } = {},
16
+ ): string {
17
+ const title = escapeHtml(options.title ?? 'graphorin/evals report');
18
+ const summary = report.summary;
19
+ const passRate = summary.total === 0 ? 0 : (summary.passed / summary.total) * 100;
20
+ const scorerRows = Object.entries(summary.byScorer)
21
+ .map(([scorer, row]) => {
22
+ const avg = row.avgScore === null ? 'n/a' : row.avgScore.toFixed(4);
23
+ return `<tr><td><code>${escapeHtml(scorer)}</code></td><td>${row.passed}</td><td>${row.failed}</td><td>${avg}</td></tr>`;
24
+ })
25
+ .join('');
26
+ const failureRows = report.results
27
+ .flatMap((r) => {
28
+ const failures = r.scores.filter((s) => !s.result.pass);
29
+ return failures.map(
30
+ (f) =>
31
+ `<tr><td><code>${escapeHtml(r.caseId)}</code></td><td><code>${escapeHtml(f.scorer)}</code></td><td>${escapeHtml(f.result.reason ?? '')}</td></tr>`,
32
+ );
33
+ })
34
+ .join('');
35
+ return `<!DOCTYPE html>
36
+ <html lang="en">
37
+ <head>
38
+ <meta charset="utf-8">
39
+ <title>${title}</title>
40
+ <style>
41
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 2rem; max-width: 64rem; color: #1a1a1a; }
42
+ h1 { margin-top: 0; }
43
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
44
+ th, td { padding: 0.4rem 0.6rem; text-align: left; border-bottom: 1px solid #e5e5e5; }
45
+ th { background: #f5f5f5; }
46
+ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: 0.6rem; }
47
+ .summary-card { background: #f5f5f5; padding: 0.8rem; border-radius: 6px; }
48
+ .summary-card .label { font-size: 0.8rem; text-transform: uppercase; color: #666; letter-spacing: 0.04em; }
49
+ .summary-card .value { font-size: 1.4rem; font-weight: 600; }
50
+ code { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 0.9rem; }
51
+ </style>
52
+ </head>
53
+ <body>
54
+ <h1>${title}</h1>
55
+ <div class="summary-grid">
56
+ <div class="summary-card"><div class="label">Total</div><div class="value">${summary.total}</div></div>
57
+ <div class="summary-card"><div class="label">Passed</div><div class="value">${summary.passed}</div></div>
58
+ <div class="summary-card"><div class="label">Failed</div><div class="value">${summary.failed}</div></div>
59
+ <div class="summary-card"><div class="label">Pass rate</div><div class="value">${passRate.toFixed(1)}%</div></div>
60
+ <div class="summary-card"><div class="label">Avg ms</div><div class="value">${summary.avgDurationMs.toFixed(0)}</div></div>
61
+ </div>
62
+ <h2>Per-scorer</h2>
63
+ <table>
64
+ <thead><tr><th>Scorer</th><th>Pass</th><th>Fail</th><th>Avg score</th></tr></thead>
65
+ <tbody>${scorerRows || '<tr><td colspan="4">No scorers.</td></tr>'}</tbody>
66
+ </table>
67
+ <h2>Failures</h2>
68
+ <table>
69
+ <thead><tr><th>Case</th><th>Scorer</th><th>Reason</th></tr></thead>
70
+ <tbody>${failureRows || '<tr><td colspan="3">No failures.</td></tr>'}</tbody>
71
+ </table>
72
+ </body>
73
+ </html>`;
74
+ }
75
+
76
+ function escapeHtml(value: string): string {
77
+ return value
78
+ .replace(/&/g, '&amp;')
79
+ .replace(/</g, '&lt;')
80
+ .replace(/>/g, '&gt;')
81
+ .replace(/"/g, '&quot;')
82
+ .replace(/'/g, '&#39;');
83
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Barrel export for every shipped reporter. Each renderer takes an
3
+ * `EvalReport` and returns the canonical text representation; the
4
+ * caller decides where to write it (`writeFile`, `process.stdout`,
5
+ * GitHub Actions step summary, etc.).
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ export { renderHtmlReport } from './html.js';
11
+ export { renderJsonReport } from './json.js';
12
+ export { renderJunitReport } from './junit.js';
13
+ export { renderMarkdownReport } from './markdown.js';
14
+ export { renderTerminalReport } from './terminal.js';