@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,147 @@
1
+ /**
2
+ * CSV dataset loader. Implements RFC 4180 strict subset:
3
+ *
4
+ * - Comma separator (configurable).
5
+ * - Optional `"`-quoted cells; doubled `""` to embed a literal `"`.
6
+ * - Header row required by default.
7
+ *
8
+ * Columns map to `Case` fields by name: `input`, `expected`, `id`,
9
+ * `metadata` (parsed as JSON when present). Unknown columns are
10
+ * ignored unless a `mapper` is supplied.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+
15
+ import { readFile } from 'node:fs/promises';
16
+
17
+ import type { Case, Dataset } from '@graphorin/observability/eval';
18
+
19
+ /** @stable */
20
+ export interface LoadCsvOptions {
21
+ readonly delimiter?: string;
22
+ readonly name?: string;
23
+ readonly description?: string;
24
+ readonly mapper?: (row: Record<string, string>, index: number) => Case<unknown, unknown>;
25
+ }
26
+
27
+ /** @stable */
28
+ export async function loadCsvDataset(
29
+ path: string,
30
+ options: LoadCsvOptions = {},
31
+ ): Promise<Dataset<unknown, unknown>> {
32
+ const text = await readFile(path, 'utf8');
33
+ const cases = parseCsv(text, options);
34
+ const meta: Dataset<unknown, unknown>['metadata'] = {
35
+ ...(options.name !== undefined ? { name: options.name } : {}),
36
+ ...(options.description !== undefined ? { description: options.description } : {}),
37
+ createdAt: new Date(),
38
+ };
39
+ return { cases, metadata: meta };
40
+ }
41
+
42
+ /**
43
+ * Pure parser. Exported separately so tests can exercise the
44
+ * column-mapping behaviour without touching the filesystem.
45
+ *
46
+ * @stable
47
+ */
48
+ export function parseCsv(
49
+ text: string,
50
+ options: LoadCsvOptions = {},
51
+ ): ReadonlyArray<Case<unknown, unknown>> {
52
+ const delimiter = options.delimiter ?? ',';
53
+ const rows = parseCsvRows(text, delimiter);
54
+ if (rows.length === 0) return [];
55
+ const header = rows[0];
56
+ if (header === undefined) return [];
57
+ const dataRows = rows.slice(1);
58
+ const out: Case<unknown, unknown>[] = [];
59
+ for (let i = 0; i < dataRows.length; i++) {
60
+ const row = dataRows[i] ?? [];
61
+ if (row.every((cell) => cell.length === 0)) continue;
62
+ const record: Record<string, string> = {};
63
+ for (let c = 0; c < header.length; c++) {
64
+ const key = header[c] ?? '';
65
+ const value = row[c] ?? '';
66
+ record[key] = value;
67
+ }
68
+ if (options.mapper !== undefined) {
69
+ out.push(options.mapper(record, i));
70
+ continue;
71
+ }
72
+ if (record.input === undefined) {
73
+ throw new Error(`[graphorin/evals] CSV row ${i + 1} missing required 'input' column.`);
74
+ }
75
+ const sampleCase: Case<unknown, unknown> = {
76
+ ...(record.id !== undefined && record.id.length > 0 ? { id: record.id } : {}),
77
+ input: record.input,
78
+ ...('expected' in record && record.expected !== undefined && record.expected.length > 0
79
+ ? { expected: record.expected }
80
+ : {}),
81
+ ...(record.metadata !== undefined && record.metadata.length > 0
82
+ ? { metadata: parseMetadata(record.metadata) }
83
+ : {}),
84
+ };
85
+ out.push(sampleCase);
86
+ }
87
+ return out;
88
+ }
89
+
90
+ function parseMetadata(raw: string): Readonly<Record<string, unknown>> {
91
+ try {
92
+ const parsed = JSON.parse(raw);
93
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
94
+ return parsed as Readonly<Record<string, unknown>>;
95
+ }
96
+ return { value: parsed } as Readonly<Record<string, unknown>>;
97
+ } catch {
98
+ return { raw } as Readonly<Record<string, unknown>>;
99
+ }
100
+ }
101
+
102
+ function parseCsvRows(text: string, delimiter: string): string[][] {
103
+ const rows: string[][] = [];
104
+ let current: string[] = [];
105
+ let cell = '';
106
+ let inQuotes = false;
107
+ for (let i = 0; i < text.length; i++) {
108
+ const ch = text[i];
109
+ if (inQuotes) {
110
+ if (ch === '"') {
111
+ const next = text[i + 1];
112
+ if (next === '"') {
113
+ cell += '"';
114
+ i += 1;
115
+ } else {
116
+ inQuotes = false;
117
+ }
118
+ } else {
119
+ cell += ch;
120
+ }
121
+ continue;
122
+ }
123
+ if (ch === '"') {
124
+ inQuotes = true;
125
+ continue;
126
+ }
127
+ if (ch === delimiter) {
128
+ current.push(cell);
129
+ cell = '';
130
+ continue;
131
+ }
132
+ if (ch === '\n' || ch === '\r') {
133
+ if (ch === '\r' && text[i + 1] === '\n') i += 1;
134
+ current.push(cell);
135
+ rows.push(current);
136
+ current = [];
137
+ cell = '';
138
+ continue;
139
+ }
140
+ cell += ch;
141
+ }
142
+ if (cell.length > 0 || current.length > 0) {
143
+ current.push(cell);
144
+ rows.push(current);
145
+ }
146
+ return rows;
147
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * DMR (Deep Memory Retrieval) loader - the MemGPT/Letta continuity set
3
+ * derived from Multi-Session Chat (arXiv:2310.08560). DMR's published
4
+ * shape is less standardised than LongMemEval/LOCOMO, so this loader is
5
+ * deliberately tolerant: it accepts a JSON **array** of items, each
6
+ * exposing a `question`, an `answer`, and the prior conversation under
7
+ * either `sessions` or `haystack_sessions`. Sessions may be a bare
8
+ * array of turns or an object wrapping the turns under
9
+ * `turns` / `messages` / `dialog`; a turn may use `role` or `speaker`
10
+ * and `content` or `text`.
11
+ *
12
+ * ```jsonc
13
+ * {
14
+ * "id": "dmr-1",
15
+ * "question": "What is the user's sister's name?",
16
+ * "answer": "Anna",
17
+ * "sessions": [
18
+ * { "id": "s1", "turns": [ { "role": "user", "content": "..." } ] },
19
+ * [ { "speaker": "assistant", "text": "..." } ]
20
+ * ]
21
+ * }
22
+ * ```
23
+ *
24
+ * Every DMR case is treated as `multi-session` (its defining ability).
25
+ * The exact field names should be reconciled against the downloaded
26
+ * file the first time `scripts/fetch-eval-datasets.mjs` is run.
27
+ *
28
+ * @packageDocumentation
29
+ */
30
+
31
+ import { readFile } from 'node:fs/promises';
32
+
33
+ import type { Case, Dataset } from '@graphorin/observability/eval';
34
+
35
+ import type { MemoryEvalInput, MemoryEvalSession, MemoryEvalTurn } from './memory-eval.js';
36
+
37
+ /** @stable */
38
+ export interface LoadDmrOptions {
39
+ /** Local path to the DMR JSON (under `benchmarks/.datasets/`). */
40
+ readonly path: string;
41
+ /** Optional dataset name surfaced in `Dataset.metadata.name`. */
42
+ readonly name?: string;
43
+ /** Optional description surfaced in `Dataset.metadata.description`. */
44
+ readonly description?: string;
45
+ }
46
+
47
+ /**
48
+ * Read a DMR JSON file and return a fully-materialised {@link Dataset}
49
+ * of multi-session retrieval cases scored against the reference answer.
50
+ *
51
+ * @stable
52
+ */
53
+ export async function loadDmrDataset(
54
+ options: LoadDmrOptions,
55
+ ): Promise<Dataset<MemoryEvalInput, string>> {
56
+ const text = await readFile(options.path, 'utf8');
57
+ const cases = parseDmr(text);
58
+ return {
59
+ cases,
60
+ metadata: {
61
+ name: options.name ?? 'dmr',
62
+ ...(options.description !== undefined ? { description: options.description } : {}),
63
+ createdAt: new Date(),
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Pure parser. Exported so tests can exercise the mapping without
70
+ * touching the filesystem.
71
+ *
72
+ * @stable
73
+ */
74
+ export function parseDmr(text: string): ReadonlyArray<Case<MemoryEvalInput, string>> {
75
+ const parsed: unknown = JSON.parse(text);
76
+ if (!Array.isArray(parsed)) {
77
+ throw new Error('[graphorin/evals] DMR dataset must be a JSON array of items.');
78
+ }
79
+ const items = parsed as ReadonlyArray<unknown>;
80
+ const out: Case<MemoryEvalInput, string>[] = [];
81
+ for (let i = 0; i < items.length; i++) {
82
+ const item = items[i];
83
+ if (!isRecord(item)) {
84
+ throw new Error(`[graphorin/evals] DMR item ${i} is not an object.`);
85
+ }
86
+ const question = asString(item.question);
87
+ if (question === undefined) {
88
+ throw new Error(`[graphorin/evals] DMR item ${i} is missing a 'question'.`);
89
+ }
90
+ const sessions = coerceSessions(item.sessions ?? item.haystack_sessions);
91
+ out.push({
92
+ id: asString(item.id) ?? `dmr-${i}`,
93
+ input: { haystackSessions: sessions, question, ability: 'multi-session' },
94
+ expected: asString(item.answer) ?? '',
95
+ metadata: { datasetName: 'dmr', ability: 'multi-session' },
96
+ });
97
+ }
98
+ return out;
99
+ }
100
+
101
+ function coerceSessions(value: unknown): MemoryEvalSession[] {
102
+ const rawSessions = asArray(value);
103
+ const sessions: MemoryEvalSession[] = [];
104
+ for (let s = 0; s < rawSessions.length; s++) {
105
+ const raw = rawSessions[s];
106
+ const rawTurns = Array.isArray(raw)
107
+ ? (raw as ReadonlyArray<unknown>)
108
+ : isRecord(raw)
109
+ ? asArray(raw.turns ?? raw.messages ?? raw.dialog)
110
+ : [];
111
+ const id = isRecord(raw) ? (asString(raw.id) ?? `session-${s}`) : `session-${s}`;
112
+ const turns: MemoryEvalTurn[] = [];
113
+ for (const t of rawTurns) {
114
+ if (!isRecord(t)) continue;
115
+ const content = asString(t.content) ?? asString(t.text);
116
+ if (content === undefined) continue;
117
+ const speaker = asString(t.role) ?? asString(t.speaker);
118
+ const role = speaker === 'assistant' ? 'assistant' : 'user';
119
+ const timestamp = asString(t.timestamp);
120
+ turns.push({ role, content, ...(timestamp !== undefined ? { timestamp } : {}) });
121
+ }
122
+ sessions.push({ id, turns });
123
+ }
124
+ return sessions;
125
+ }
126
+
127
+ function asString(value: unknown): string | undefined {
128
+ return typeof value === 'string' ? value : undefined;
129
+ }
130
+
131
+ function asArray(value: unknown): ReadonlyArray<unknown> {
132
+ return Array.isArray(value) ? (value as ReadonlyArray<unknown>) : [];
133
+ }
134
+
135
+ function isRecord(value: unknown): value is Record<string, unknown> {
136
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
137
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `loadDatasetFromTraces` - distil a dataset from the framework's
3
+ * replay log. The caller supplies the JSONL replay file and a small
4
+ * extraction function that pulls the `(input, output)` pair out of
5
+ * each event group; the loader handles the JSONL parsing + grouping
6
+ * by `runId`.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ import { readFile } from 'node:fs/promises';
12
+
13
+ import type { Case, Dataset } from '@graphorin/observability/eval';
14
+
15
+ /** @stable */
16
+ export interface TraceEvent {
17
+ readonly runId: string;
18
+ readonly type: string;
19
+ readonly payload: Readonly<Record<string, unknown>>;
20
+ readonly timestamp?: string;
21
+ }
22
+
23
+ /** @stable */
24
+ export interface FromTracesOptions<I, O> {
25
+ /** Distil one `Case<I, O>` from every group of events sharing a `runId`. */
26
+ readonly extract: (events: ReadonlyArray<TraceEvent>) => Case<I, O> | null;
27
+ /** Optional name surfaced in `Dataset.metadata.name`. */
28
+ readonly name?: string;
29
+ /** Optional description surfaced in `Dataset.metadata.description`. */
30
+ readonly description?: string;
31
+ }
32
+
33
+ /** @stable */
34
+ export async function loadDatasetFromTraces<I, O>(
35
+ path: string,
36
+ options: FromTracesOptions<I, O>,
37
+ ): Promise<Dataset<I, O>> {
38
+ const text = await readFile(path, 'utf8');
39
+ return groupAndExtract<I, O>(text, options);
40
+ }
41
+
42
+ /**
43
+ * Pure parser for the trace JSONL format. Exported so tests can
44
+ * exercise the extraction without touching the filesystem.
45
+ *
46
+ * @stable
47
+ */
48
+ export function groupAndExtract<I, O>(
49
+ text: string,
50
+ options: FromTracesOptions<I, O>,
51
+ ): Dataset<I, O> {
52
+ const lines = text.split(/\r?\n/);
53
+ const groups = new Map<string, TraceEvent[]>();
54
+ for (const raw of lines) {
55
+ if (raw.trim().length === 0) continue;
56
+ let parsed: unknown;
57
+ try {
58
+ parsed = JSON.parse(raw);
59
+ } catch {
60
+ continue;
61
+ }
62
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
63
+ const obj = parsed as Record<string, unknown>;
64
+ const runId = typeof obj.runId === 'string' ? obj.runId : null;
65
+ const type = typeof obj.type === 'string' ? obj.type : null;
66
+ if (runId === null || type === null) continue;
67
+ const payload =
68
+ obj.payload !== null && typeof obj.payload === 'object' && !Array.isArray(obj.payload)
69
+ ? (obj.payload as Readonly<Record<string, unknown>>)
70
+ : ({} as Readonly<Record<string, unknown>>);
71
+ const event: TraceEvent = {
72
+ runId,
73
+ type,
74
+ payload,
75
+ ...(typeof obj.timestamp === 'string' ? { timestamp: obj.timestamp } : {}),
76
+ };
77
+ const existing = groups.get(runId);
78
+ if (existing === undefined) groups.set(runId, [event]);
79
+ else existing.push(event);
80
+ }
81
+ const cases: Case<I, O>[] = [];
82
+ for (const events of groups.values()) {
83
+ const sample = options.extract(events);
84
+ if (sample !== null) cases.push(sample);
85
+ }
86
+ const meta: Dataset<I, O>['metadata'] = {
87
+ ...(options.name !== undefined ? { name: options.name } : {}),
88
+ ...(options.description !== undefined ? { description: options.description } : {}),
89
+ createdAt: new Date(),
90
+ };
91
+ return { cases, metadata: meta };
92
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Dataset loaders. Every loader returns a fully-materialised
3
+ * `Dataset` that the runner can iterate over without further
4
+ * I/O. Streaming loaders are a post-MVP follow-up.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ export {
10
+ type LoadCsvOptions,
11
+ loadCsvDataset,
12
+ parseCsv,
13
+ } from './csv.js';
14
+ export {
15
+ type LoadDmrOptions,
16
+ loadDmrDataset,
17
+ parseDmr,
18
+ } from './dmr.js';
19
+ export {
20
+ type FromTracesOptions,
21
+ groupAndExtract,
22
+ loadDatasetFromTraces,
23
+ type TraceEvent,
24
+ } from './from-traces.js';
25
+ export { fromIterable } from './iterable.js';
26
+ export {
27
+ type LoadJsonlOptions,
28
+ loadJsonlDataset,
29
+ parseJsonl,
30
+ } from './jsonl.js';
31
+ export {
32
+ type LoadLocomoOptions,
33
+ loadLocomoDataset,
34
+ parseLocomo,
35
+ } from './locomo.js';
36
+ export {
37
+ type LoadLongMemEvalOptions,
38
+ loadLongMemEvalDataset,
39
+ parseLongMemEval,
40
+ } from './longmemeval.js';
41
+ export type {
42
+ MemoryEvalAbility,
43
+ MemoryEvalInput,
44
+ MemoryEvalSession,
45
+ MemoryEvalTurn,
46
+ } from './memory-eval.js';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `fromIterable` - wrap an in-memory array / iterable as a
3
+ * {@link Dataset}. Useful for tests + ad-hoc data.
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+
8
+ import type { Case, Dataset } from '@graphorin/observability/eval';
9
+
10
+ /** @stable */
11
+ export function fromIterable<I, O = unknown>(
12
+ cases: Iterable<Case<I, O>> | ReadonlyArray<Case<I, O>>,
13
+ options: { readonly name?: string; readonly description?: string } = {},
14
+ ): Dataset<I, O> {
15
+ const arr = Array.isArray(cases) ? (cases as ReadonlyArray<Case<I, O>>) : Array.from(cases);
16
+ const meta: Dataset<I, O>['metadata'] = {
17
+ ...(options.name !== undefined ? { name: options.name } : {}),
18
+ ...(options.description !== undefined ? { description: options.description } : {}),
19
+ createdAt: new Date(),
20
+ };
21
+ return { cases: arr, metadata: meta };
22
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * JSONL dataset loader. Each line is a JSON object with `input` and
3
+ * optional `expected` / `metadata` / `id` fields.
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+
8
+ import { readFile } from 'node:fs/promises';
9
+
10
+ import type { Case, Dataset } from '@graphorin/observability/eval';
11
+
12
+ /** @stable */
13
+ export interface LoadJsonlOptions {
14
+ /** Optional dataset name surfaced in `Dataset.metadata.name`. */
15
+ readonly name?: string;
16
+ /** Optional description surfaced in `Dataset.metadata.description`. */
17
+ readonly description?: string;
18
+ /**
19
+ * Map a parsed line into a `Case`. Default forwards the line
20
+ * verbatim. Override to translate column names or coerce types.
21
+ */
22
+ readonly mapper?: (line: Record<string, unknown>, index: number) => Case<unknown, unknown>;
23
+ }
24
+
25
+ /**
26
+ * Read a JSONL file and return a fully-materialised {@link Dataset}.
27
+ * Empty lines are skipped; malformed lines throw with the line
28
+ * number.
29
+ *
30
+ * @stable
31
+ */
32
+ export async function loadJsonlDataset(
33
+ path: string,
34
+ options: LoadJsonlOptions = {},
35
+ ): Promise<Dataset<unknown, unknown>> {
36
+ const text = await readFile(path, 'utf8');
37
+ const cases = parseJsonl(text, options.mapper);
38
+ const meta: Dataset<unknown, unknown>['metadata'] = {
39
+ ...(options.name !== undefined ? { name: options.name } : {}),
40
+ ...(options.description !== undefined ? { description: options.description } : {}),
41
+ createdAt: new Date(),
42
+ };
43
+ return { cases, metadata: meta };
44
+ }
45
+
46
+ /**
47
+ * Pure parser. Exported separately so tests can exercise the line-by-
48
+ * line behaviour without touching the filesystem.
49
+ *
50
+ * @stable
51
+ */
52
+ export function parseJsonl(
53
+ text: string,
54
+ mapper?: LoadJsonlOptions['mapper'],
55
+ ): ReadonlyArray<Case<unknown, unknown>> {
56
+ const out: Case<unknown, unknown>[] = [];
57
+ const lines = text.split(/\r?\n/);
58
+ for (let i = 0; i < lines.length; i++) {
59
+ const raw = lines[i] ?? '';
60
+ if (raw.trim().length === 0) continue;
61
+ let parsed: unknown;
62
+ try {
63
+ parsed = JSON.parse(raw);
64
+ } catch (err) {
65
+ throw new Error(
66
+ `[graphorin/evals] failed to parse JSONL at line ${i + 1}: ${(err as Error).message}`,
67
+ { cause: err },
68
+ );
69
+ }
70
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
71
+ throw new Error(
72
+ `[graphorin/evals] JSONL line ${i + 1} must be a JSON object, got ${typeof parsed}.`,
73
+ );
74
+ }
75
+ const obj = parsed as Record<string, unknown>;
76
+ const mapped = mapper !== undefined ? mapper(obj, i) : defaultMapper(obj, i);
77
+ out.push(mapped);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ function defaultMapper(line: Record<string, unknown>, _index: number): Case<unknown, unknown> {
83
+ if (!('input' in line)) {
84
+ throw new Error(`[graphorin/evals] JSONL row missing required 'input' field.`);
85
+ }
86
+ return {
87
+ ...(typeof line.id === 'string' ? { id: line.id } : {}),
88
+ input: line.input,
89
+ ...('expected' in line ? { expected: line.expected } : {}),
90
+ ...(line.metadata !== undefined && typeof line.metadata === 'object' && line.metadata !== null
91
+ ? { metadata: line.metadata as Readonly<Record<string, unknown>> }
92
+ : {}),
93
+ };
94
+ }