@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.
- package/CHANGELOG.md +20 -0
- package/README.md +3 -3
- package/dist/loaders/locomo.d.ts.map +1 -1
- package/dist/loaders/locomo.js +24 -2
- package/dist/loaders/locomo.js.map +1 -1
- package/dist/loaders/memory-eval.d.ts +8 -0
- package/dist/loaders/memory-eval.d.ts.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +10 -9
- package/src/cli/index.ts +124 -0
- package/src/index.ts +136 -0
- package/src/loaders/csv.ts +147 -0
- package/src/loaders/dmr.ts +137 -0
- package/src/loaders/from-traces.ts +92 -0
- package/src/loaders/index.ts +46 -0
- package/src/loaders/iterable.ts +22 -0
- package/src/loaders/jsonl.ts +94 -0
- package/src/loaders/locomo.ts +217 -0
- package/src/loaders/longmemeval.ts +164 -0
- package/src/loaders/memory-eval.ts +80 -0
- package/src/regression.ts +133 -0
- package/src/reporters/html.ts +83 -0
- package/src/reporters/index.ts +14 -0
- package/src/reporters/json.ts +18 -0
- package/src/reporters/junit.ts +53 -0
- package/src/reporters/markdown.ts +65 -0
- package/src/reporters/terminal.ts +46 -0
- package/src/runner.ts +233 -0
- package/src/scorers/code/exact-match.ts +95 -0
- package/src/scorers/code/index.ts +10 -0
- package/src/scorers/code/json-path.ts +101 -0
- package/src/scorers/code/predicate.ts +32 -0
- package/src/scorers/code/regex.ts +55 -0
- package/src/scorers/index.ts +15 -0
- package/src/scorers/llm/index.ts +13 -0
- package/src/scorers/llm/judge.ts +170 -0
- package/src/scorers/prebuilt/index.ts +95 -0
- package/src/scorers/trajectory/argument-validity.ts +63 -0
- package/src/scorers/trajectory/correct-tool-selected.ts +61 -0
- package/src/scorers/trajectory/final-state-correct.ts +62 -0
- package/src/scorers/trajectory/index.ts +25 -0
- package/src/scorers/trajectory/recovery-after-error.ts +55 -0
- package/src/scorers/trajectory/redundant-call-detection.ts +58 -0
- package/src/scorers/trajectory/types.ts +44 -0
- package/src/scorers/trajectory/util.ts +81 -0
- package/src/stats.ts +196 -0
- 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, '&')
|
|
79
|
+
.replace(/</g, '<')
|
|
80
|
+
.replace(/>/g, '>')
|
|
81
|
+
.replace(/"/g, '"')
|
|
82
|
+
.replace(/'/g, ''');
|
|
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';
|