@pharmatools/opengate 0.1.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 (44) hide show
  1. package/ADAPTERS.md +145 -0
  2. package/LICENSE +21 -0
  3. package/README.md +200 -0
  4. package/datasets/LABELING-GUIDE.md +65 -0
  5. package/datasets/SCHEMA.md +17 -0
  6. package/datasets/cases/_template.json +27 -0
  7. package/datasets/cases/case-cardio-bp.json +23 -0
  8. package/datasets/cases/case-cardio-hf.json +21 -0
  9. package/datasets/cases/case-cardio-lipids.json +41 -0
  10. package/datasets/cases/case-derm-psoriasis.json +22 -0
  11. package/datasets/cases/case-diabetes-glp1.json +33 -0
  12. package/datasets/cases/case-endo-thyroid.json +18 -0
  13. package/datasets/cases/case-gastro-ibd.json +21 -0
  14. package/datasets/cases/case-hema-anemia.json +19 -0
  15. package/datasets/cases/case-hep-nash.json +19 -0
  16. package/datasets/cases/case-hiv-prep.json +18 -0
  17. package/datasets/cases/case-id-antibiotic.json +18 -0
  18. package/datasets/cases/case-nephro-ckd.json +21 -0
  19. package/datasets/cases/case-neuro-migraine.json +39 -0
  20. package/datasets/cases/case-neuro-ms.json +21 -0
  21. package/datasets/cases/case-onco-pdl1.json +21 -0
  22. package/datasets/cases/case-oncology-pembro.json +31 -0
  23. package/datasets/cases/case-osteo-fracture.json +19 -0
  24. package/datasets/cases/case-pain-oa.json +21 -0
  25. package/datasets/cases/case-psych-depression.json +19 -0
  26. package/datasets/cases/case-pulm-ipf.json +18 -0
  27. package/datasets/cases/case-resp-copd.json +35 -0
  28. package/datasets/cases/case-rheum-jak.json +19 -0
  29. package/datasets/cases/case-vaccine.json +19 -0
  30. package/datasets/cases/case-womens-pcos.json +21 -0
  31. package/datasets/cases/seed-teprotumumab.json +37 -0
  32. package/datasets/fixtures/citation-styles.json +22 -0
  33. package/opengate.http.example.json +12 -0
  34. package/package.json +55 -0
  35. package/src/adapters/http.mjs +132 -0
  36. package/src/adapters/refcheckr.mjs +109 -0
  37. package/src/index.mjs +29 -0
  38. package/src/lib/adapter.mjs +78 -0
  39. package/src/lib/citations.mjs +109 -0
  40. package/src/lib/metrics.mjs +104 -0
  41. package/src/runner.mjs +223 -0
  42. package/src/scorers/citation-detection.mjs +69 -0
  43. package/src/scorers/claim-extraction.mjs +75 -0
  44. package/src/scorers/verdict-accuracy.mjs +163 -0
package/src/runner.mjs ADDED
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ // OpenGATE eval runner.
3
+ //
4
+ // node src/runner.mjs # offline scorers only
5
+ // node src/runner.mjs --online # also run scorers that hit the API
6
+ // node src/runner.mjs --ci # exit non-zero on failure or regression
7
+ // node src/runner.mjs --baseline # save this run as results/baseline.json
8
+ //
9
+ // Discovers gold cases (datasets/cases/*.json) and fixtures (datasets/fixtures/*.json),
10
+ // runs each scorer, prints a summary, writes a timestamped results file, and
11
+ // compares headline metrics against results/baseline.json to catch regressions.
12
+
13
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
14
+ import { existsSync } from 'node:fs';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { execSync } from 'node:child_process';
18
+ import { loadAdapter } from './lib/adapter.mjs';
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ const EVAL_ROOT = join(__dirname, '..');
22
+
23
+ const argv = process.argv.slice(2);
24
+ const args = new Set(argv.filter(a => a.startsWith('-')));
25
+ const ONLINE = args.has('--online');
26
+ const CI = args.has('--ci');
27
+ const SAVE_BASELINE = args.has('--baseline');
28
+
29
+ // Value-taking flag: `--name <value>`, falling back to an env var.
30
+ function argVal(flag, envName) {
31
+ const i = argv.indexOf(flag);
32
+ if (i < 0) return process.env[envName] || undefined;
33
+ const v = argv[i + 1];
34
+ if (!v || v.startsWith('-')) {
35
+ console.error(`${flag} requires a value, e.g. ${flag} <path>`);
36
+ process.exit(2);
37
+ }
38
+ return v;
39
+ }
40
+
41
+ // --adapter <path> overrides the OPENGATE_ADAPTER environment variable.
42
+ const ADAPTER_SPEC = argVal('--adapter', 'OPENGATE_ADAPTER');
43
+
44
+ // --datasets <dir> points at a directory containing cases/ and fixtures/ —
45
+ // so third-party repos can keep their gold sets in their own tree.
46
+ // --results <dir> relocates snapshots and baseline.json for the same reason.
47
+ const DATASETS_SPEC = argVal('--datasets', 'OPENGATE_DATASETS');
48
+ const RESULTS_SPEC = argVal('--results', 'OPENGATE_RESULTS');
49
+ const DATA_ROOT = DATASETS_SPEC ? resolve(process.cwd(), DATASETS_SPEC) : join(EVAL_ROOT, 'datasets');
50
+ const CASES_DIR = join(DATA_ROOT, 'cases');
51
+ const FIX_DIR = join(DATA_ROOT, 'fixtures');
52
+ // When OpenGATE runs from an installed package (npx / node_modules), snapshots
53
+ // must not be written inside the package — default to ./opengate-results in
54
+ // the caller's working directory instead.
55
+ const RESULTS_DIR = RESULTS_SPEC
56
+ ? resolve(process.cwd(), RESULTS_SPEC)
57
+ : (EVAL_ROOT.includes('node_modules')
58
+ ? join(process.cwd(), 'opengate-results')
59
+ : join(EVAL_ROOT, 'results'));
60
+
61
+ if (args.has('--help') || args.has('-h')) {
62
+ console.log(`OpenGATE — Open-source evaluation for evidence-grounded AI
63
+
64
+ Usage: opengate [options] (or: node src/runner.mjs [options])
65
+
66
+ Options:
67
+ --online also run scorers that call the live system
68
+ --baseline save this run as results/baseline.json
69
+ --ci exit non-zero on any failure or metric regression
70
+ --adapter <path> adapter module (overrides OPENGATE_ADAPTER;
71
+ default: bundled RefCheckr reference adapter)
72
+ --datasets <dir> directory containing cases/ and fixtures/
73
+ (overrides OPENGATE_DATASETS; default: bundled datasets/)
74
+ --results <dir> where to write snapshots and baseline.json
75
+ (overrides OPENGATE_RESULTS; default: bundled results/)
76
+ -h, --help show this help
77
+ -v, --version show version
78
+
79
+ Environment:
80
+ OPENGATE_ADAPTER adapter module path
81
+ OPENGATE_DATASETS datasets directory (cases/ + fixtures/)
82
+ OPENGATE_RESULTS results directory
83
+ OPENGATE_HTTP_CONFIG config path for the generic HTTP adapter
84
+ OPENGATE_EVAL_REPEATS run each verdict pair N times (consistency)
85
+ OPENGATE_EVAL_MODEL label the deployment's model in the scorecard
86
+
87
+ Docs: README.md · ADAPTERS.md`);
88
+ process.exit(0);
89
+ }
90
+
91
+ if (args.has('--version') || args.has('-v')) {
92
+ const pkg = JSON.parse(await readFile(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
93
+ console.log(pkg.version);
94
+ process.exit(0);
95
+ }
96
+
97
+ const SCORERS = [
98
+ './scorers/citation-detection.mjs',
99
+ './scorers/claim-extraction.mjs',
100
+ './scorers/verdict-accuracy.mjs',
101
+ ];
102
+
103
+ async function loadJsonDir(dir, { skipPrefix } = {}) {
104
+ if (!existsSync(dir)) return {};
105
+ const out = {};
106
+ for (const f of await readdir(dir)) {
107
+ if (!f.endsWith('.json')) continue;
108
+ if (skipPrefix && f.startsWith(skipPrefix)) continue;
109
+ const key = f.replace(/\.json$/, '');
110
+ out[key] = JSON.parse(await readFile(join(dir, f), 'utf8'));
111
+ }
112
+ return out;
113
+ }
114
+
115
+ function gitSha() {
116
+ try { return execSync('git rev-parse --short HEAD', { cwd: EVAL_ROOT }).toString().trim(); }
117
+ catch { return 'unknown'; }
118
+ }
119
+
120
+ function fmtPct(x) { return (x * 100).toFixed(1) + '%'; }
121
+ // Percent-format rates/scores; show counts (n_pairs, totalLeaked, repeats, …) raw.
122
+ function isRateKey(k) { return /(rate|accuracy|precision|recall|f1|jaccard|consistency|adjacency|exact|share|score)/i.test(k); }
123
+
124
+ async function main() {
125
+ const casesMap = await loadJsonDir(CASES_DIR, { skipPrefix: '_' });
126
+ const cases = Object.values(casesMap);
127
+ const fixtures = await loadJsonDir(FIX_DIR);
128
+
129
+ if (cases.length === 0) {
130
+ console.error('No gold cases found in datasets/cases/. Add one (copy _template.json).');
131
+ process.exit(2);
132
+ }
133
+
134
+ // Load and validate the adapter up front — fail fast on a malformed one,
135
+ // even for offline runs, so a broken config never produces a partial scorecard.
136
+ let adapter;
137
+ try {
138
+ adapter = await loadAdapter(ADAPTER_SPEC);
139
+ } catch (err) {
140
+ console.error(err.message);
141
+ process.exit(2);
142
+ }
143
+
144
+ console.log(`\nOpenGATE — ${cases.length} case(s), online=${ONLINE}, adapter=${adapter.name}, sha=${gitSha()}\n`);
145
+
146
+ const results = [];
147
+ for (const path of SCORERS) {
148
+ const mod = await import(path);
149
+ const isOnline = mod.meta?.mode === 'online';
150
+ if (isOnline && !ONLINE) {
151
+ results.push({ id: mod.meta.id, skipped: true, reason: 'online scorer (pass --online)' });
152
+ continue;
153
+ }
154
+ const r = await mod.run({ cases, fixtures, adapter });
155
+ results.push({ id: mod.meta.id, ...r });
156
+ }
157
+
158
+ // ── Summary table ──
159
+ for (const r of results) {
160
+ if (r.skipped) {
161
+ console.log(` ⊘ ${r.id.padEnd(20)} SKIPPED — ${r.reason}`);
162
+ continue;
163
+ }
164
+ const status = r.passed ? '✓' : '✗';
165
+ console.log(` ${status} ${r.id.padEnd(20)} ${r.passed ? 'PASS' : 'FAIL'}`);
166
+ for (const [k, v] of Object.entries(r.metrics || {})) {
167
+ const val = typeof v === 'number' ? (isRateKey(k) ? fmtPct(v) : String(v)) : JSON.stringify(v);
168
+ console.log(` ${k.padEnd(26)} ${val}`);
169
+ }
170
+ for (const f of r.failures || []) console.log(` ✗ ${f}`);
171
+ }
172
+
173
+ // ── Persist ──
174
+ await mkdir(RESULTS_DIR, { recursive: true });
175
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
176
+ const snapshot = {
177
+ timestamp: new Date().toISOString(),
178
+ sha: gitSha(),
179
+ online: ONLINE,
180
+ adapter: adapter.name,
181
+ results: results.map(({ detail, ...rest }) => rest), // drop bulky detail from snapshot
182
+ };
183
+ await writeFile(join(RESULTS_DIR, `${stamp}.json`), JSON.stringify({ ...snapshot, results }, null, 2));
184
+ if (SAVE_BASELINE) {
185
+ await writeFile(join(RESULTS_DIR, 'baseline.json'), JSON.stringify(snapshot, null, 2));
186
+ console.log('\n saved baseline.json');
187
+ }
188
+
189
+ // ── Regression check vs baseline ──
190
+ let regressed = false;
191
+ const baselinePath = join(RESULTS_DIR, 'baseline.json');
192
+ if (existsSync(baselinePath) && !SAVE_BASELINE) {
193
+ const base = JSON.parse(await readFile(baselinePath, 'utf8'));
194
+ const baseById = Object.fromEntries(base.results.map(r => [r.id, r]));
195
+ console.log('\n vs baseline:');
196
+ for (const r of results) {
197
+ const b = baseById[r.id];
198
+ if (!b || !r.metrics || !b.metrics) continue;
199
+ for (const [k, v] of Object.entries(r.metrics)) {
200
+ if (typeof v !== 'number' || typeof b.metrics[k] !== 'number') continue;
201
+ const delta = v - b.metrics[k];
202
+ if (Math.abs(delta) < 1e-9) continue;
203
+ const arrow = delta > 0 ? '▲' : '▼';
204
+ // Only rate metrics gate regressions (higher = better); counts shown for info.
205
+ if (isRateKey(k)) {
206
+ if (delta < -1e-9) regressed = true;
207
+ console.log(` ${r.id}.${k}: ${arrow} ${delta > 0 ? '+' : ''}${(delta * 100).toFixed(1)}pp`);
208
+ } else {
209
+ console.log(` ${r.id}.${k}: ${arrow} ${delta > 0 ? '+' : ''}${delta}`);
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ const anyFail = results.some(r => !r.skipped && r.passed === false);
216
+ console.log('');
217
+ if (CI && (anyFail || regressed)) {
218
+ console.error(`CI gate failed (failures=${anyFail}, regression=${regressed}).`);
219
+ process.exit(1);
220
+ }
221
+ }
222
+
223
+ main().catch(err => { console.error(err); process.exit(2); });
@@ -0,0 +1,69 @@
1
+ // OFFLINE scorer — runs with no API key.
2
+ // Measures the deterministic citation pipeline two ways:
3
+ // 1. Per-claim detection: feed each gold claim's originalText (with markers)
4
+ // through normalize+parse, compare detected citation set to gold.
5
+ // 2. Style coverage: run the citation-styles fixture, report supported-style
6
+ // accuracy and surface known-gap styles (e.g. author-year) as a tracked
7
+ // target rather than a failure.
8
+
9
+ import { detectCitations } from '../lib/citations.mjs';
10
+ import { exactSetMatch, jaccard, mean } from '../lib/metrics.mjs';
11
+
12
+ export const meta = { id: 'citation-detection', mode: 'offline' };
13
+
14
+ export function run({ cases, fixtures }) {
15
+ // ── 1. Per-claim citation detection across all cases ──
16
+ const perClaim = [];
17
+ for (const c of cases) {
18
+ for (const claim of c.goldClaims || []) {
19
+ const detected = detectCitations(claim.originalText);
20
+ perClaim.push({
21
+ case: c.id,
22
+ claim: claim.text.slice(0, 60),
23
+ gold: claim.citations,
24
+ detected,
25
+ exact: exactSetMatch(detected, claim.citations),
26
+ jaccard: jaccard(detected, claim.citations),
27
+ });
28
+ }
29
+ }
30
+ const exactRate = perClaim.length ? perClaim.filter(p => p.exact).length / perClaim.length : 1;
31
+ const jaccardMean = mean(perClaim.map(p => p.jaccard));
32
+
33
+ // ── 2. Style coverage fixture ──
34
+ const styles = (fixtures['citation-styles']?.items) || [];
35
+ const supported = styles.filter(s => !s.knownGap);
36
+ const knownGaps = styles.filter(s => s.knownGap);
37
+ const supportedResults = supported.map(s => ({
38
+ style: s.style,
39
+ pass: exactSetMatch(detectCitations(s.text), s.expected),
40
+ }));
41
+ const supportedAccuracy = supportedResults.length
42
+ ? supportedResults.filter(r => r.pass).length / supportedResults.length : 1;
43
+ // For known-gap styles, "still a gap" = we detect nothing where a target exists.
44
+ const gapResults = knownGaps.map(s => ({
45
+ style: s.style,
46
+ stillUnsupported: detectCitations(s.text).length === 0,
47
+ target: s.targetExpected || [],
48
+ }));
49
+
50
+ const failures = [
51
+ ...perClaim.filter(p => !p.exact).map(p => `claim "${p.claim}…" expected [${p.gold}] got [${p.detected}]`),
52
+ ...supportedResults.filter(r => !r.pass).map(r => `style ${r.style} failed`),
53
+ ];
54
+
55
+ return {
56
+ meta,
57
+ metrics: {
58
+ perClaim_exactSetRate: round(exactRate),
59
+ perClaim_jaccardMean: round(jaccardMean),
60
+ supportedStyle_accuracy: round(supportedAccuracy),
61
+ knownGap_styles: knownGaps.map(s => s.style),
62
+ },
63
+ detail: { perClaim, supportedResults, gapResults },
64
+ failures,
65
+ passed: failures.length === 0,
66
+ };
67
+ }
68
+
69
+ function round(x) { return Math.round(x * 1000) / 1000; }
@@ -0,0 +1,75 @@
1
+ // ONLINE scorer — exercises the adapter's splitClaims().
2
+ // Measures whether the splitter extracts the right verifiable claims:
3
+ // • recall — did it find the gold claims?
4
+ // • precision — did it avoid pulling in non-claims (background/aims)?
5
+ // • citation agreement on the claims it did extract
6
+ // • fidelity — is each extracted claim verbatim-ish from the manuscript?
7
+
8
+ import { precisionRecallF1, claimMatch, jaccard, normText, mean } from '../lib/metrics.mjs';
9
+
10
+ export const meta = { id: 'claim-extraction', mode: 'online' };
11
+
12
+ export async function run({ cases, adapter }) {
13
+ if (!adapter.onlineAvailable()) {
14
+ return { meta, skipped: true, reason: adapter.onlineConfigHint() };
15
+ }
16
+
17
+ const perCase = [];
18
+ const splitErrors = [];
19
+ for (const c of cases) {
20
+ try {
21
+ const resp = await adapter.splitClaims(c.manuscript);
22
+ const extracted = (resp.claims || []).map(x => (typeof x === 'string' ? { text: x, citations: [] } : x));
23
+ const goldTexts = (c.goldClaims || []).map(g => g.text);
24
+
25
+ // Precision / recall / F1 of extracted claims vs gold claims.
26
+ const prf = precisionRecallF1(extracted.map(e => e.text), goldTexts, claimMatch);
27
+
28
+ // Leakage: any extracted claim that matches a known non-claim is a false positive.
29
+ const leaked = extracted.filter(e => (c.goldNonClaims || []).some(nc => claimMatch(e.text, nc)));
30
+
31
+ // Citation agreement on matched claims.
32
+ const citJaccards = [];
33
+ for (const g of c.goldClaims || []) {
34
+ const m = extracted.find(e => claimMatch(e.text, g.text));
35
+ if (m) citJaccards.push(jaccard(m.citations || [], g.citations || []));
36
+ }
37
+
38
+ // Fidelity: extracted claim text should appear (normalised) in the manuscript.
39
+ const src = normText(c.manuscript);
40
+ const infidelities = extracted.filter(e => {
41
+ const t = normText(e.text);
42
+ return t.length >= 12 && !src.includes(t);
43
+ });
44
+
45
+ perCase.push({
46
+ case: c.id,
47
+ precision: prf.precision, recall: prf.recall, f1: prf.f1,
48
+ leakedCount: leaked.length,
49
+ citationJaccardMean: mean(citJaccards),
50
+ infidelityCount: infidelities.length,
51
+ infidelities: infidelities.map(e => e.text.slice(0, 80)),
52
+ });
53
+ } catch (err) {
54
+ splitErrors.push(`case ${c.id}: ${err.message}`);
55
+ }
56
+ }
57
+
58
+ const agg = {
59
+ f1: round(mean(perCase.map(p => p.f1))),
60
+ precision: round(mean(perCase.map(p => p.precision))),
61
+ recall: round(mean(perCase.map(p => p.recall))),
62
+ citationJaccardMean: round(mean(perCase.map(p => p.citationJaccardMean))),
63
+ totalLeaked: perCase.reduce((a, p) => a + p.leakedCount, 0),
64
+ totalInfidelities: perCase.reduce((a, p) => a + p.infidelityCount, 0),
65
+ };
66
+ const failures = [...splitErrors];
67
+ if (agg.totalInfidelities > 0) failures.push(`${agg.totalInfidelities} extracted claim(s) not verbatim from source`);
68
+ if (agg.totalLeaked > 0) failures.push(`${agg.totalLeaked} non-claim(s) leaked into extraction`);
69
+ agg.casesEvaluated = perCase.length;
70
+ agg.splitErrors = splitErrors.length;
71
+
72
+ return { meta, metrics: agg, detail: { perCase }, failures, passed: failures.length === 0 };
73
+ }
74
+
75
+ function round(x) { return Math.round(x * 1000) / 1000; }
@@ -0,0 +1,163 @@
1
+ // ONLINE scorer — verdict accuracy + passage hallucination + consistency.
2
+ //
3
+ // Verifies ONE claim per call against its single cited reference (sent as a text
4
+ // document) via the adapter's analyzeBatch(). One-claim-per-call deliberately
5
+ // sidesteps multi-claim reassembly bugs in batch endpoints, so this measures the
6
+ // model's verdict QUALITY, not batch-matching behaviour.
7
+ //
8
+ // Metrics:
9
+ // • exact / adjacency verdict accuracy on the six-point support scale
10
+ // • confusion matrix (gold vs predicted)
11
+ // • passage hallucination rate (cited quotes not found verbatim in the source)
12
+ // • guard_downgrades (verdicts the integrity guard downgraded)
13
+ // • consistency (when OPENGATE_EVAL_REPEATS > 1): verdict stability across repeats
14
+
15
+ import { verdictAccuracy, confusionMatrix, normText, mean, percentile } from '../lib/metrics.mjs';
16
+
17
+ export const meta = { id: 'verdict-accuracy', mode: 'online' };
18
+
19
+ const REPEATS = Math.max(1, parseInt(process.env.OPENGATE_EVAL_REPEATS || process.env.REFCHECKR_EVAL_REPEATS || '1', 10));
20
+
21
+ // One claim against its one cited reference.
22
+ function buildPairPayload(caseId, claimText, citation, ref) {
23
+ return {
24
+ claims: [claimText],
25
+ documents: [{ name: ref.name, type: 'text', content: ref.text }],
26
+ citationMapping: { [citation]: { refId: 'upload:' + ref.name } },
27
+ claimCitations: [{ citations: [citation] }],
28
+ document_name: `eval-${caseId}`,
29
+ };
30
+ }
31
+
32
+ function readVerdict(resp, refName) {
33
+ const r = (resp.claims || [])[0];
34
+ if (!r) return { verdict: undefined, analysis: null };
35
+ const a = (r.individual_analyses || []).find(x => x.document_name === refName)
36
+ || (r.individual_analyses || [])[0];
37
+ return { verdict: a?.verdict, analysis: a || null };
38
+ }
39
+
40
+ // Quote text from a passage object, tolerant of field naming.
41
+ function passageQuote(p) {
42
+ return p?.quote ?? p?.text ?? p?.passage ?? p?.snippet ?? '';
43
+ }
44
+
45
+ // enforceVerdictIntegrity() prepends a fixed reason to the summary when it
46
+ // downgrades a verdict; detecting it tells us the guard fired vs the model judged.
47
+ function isGuardDowngrade(summary) {
48
+ if (!summary) return false;
49
+ const s = String(summary).toLowerCase();
50
+ return s.includes('could not be verified against the document')
51
+ || s.includes('no explicit textual statement was found')
52
+ || s.includes('no explicit textual support found');
53
+ }
54
+
55
+ export async function run({ cases, adapter }) {
56
+ if (!adapter.onlineAvailable()) {
57
+ return { meta, skipped: true, reason: adapter.onlineConfigHint() };
58
+ }
59
+ const goldCases = cases.filter(c => (c.goldVerdicts || []).length > 0 && c.references);
60
+ if (goldCases.length === 0) {
61
+ return { meta, skipped: true, reason: 'No cases include goldVerdicts + references.' };
62
+ }
63
+
64
+ adapter.resetTiming(); // start latency capture for the verify calls below
65
+ adapter.resetTokens(); // start token-usage capture for real cost/claim
66
+
67
+ const pairs = [];
68
+ const consistencyShares = [];
69
+ let passageTotal = 0, passageInfidel = 0;
70
+ const failures = [];
71
+
72
+ for (const c of goldCases) {
73
+ for (const g of c.goldVerdicts) {
74
+ const ref = c.references[g.citation];
75
+ if (!ref) { failures.push(`missing reference ${g.citation} in ${c.id}`); continue; }
76
+ const payload = buildPairPayload(c.id, g.claimText, g.citation, ref);
77
+
78
+ const runVerdicts = [];
79
+ let firstAnalysis = null, firstVerdict;
80
+ for (let run = 0; run < REPEATS; run++) {
81
+ let resp;
82
+ try {
83
+ resp = await adapter.analyzeBatch(payload);
84
+ } catch (err) {
85
+ failures.push(`${c.id} "${g.claimText.slice(0, 30)}…" run ${run + 1}: ${err.message}`);
86
+ break;
87
+ }
88
+ const { verdict, analysis } = readVerdict(resp, ref.name);
89
+ runVerdicts.push(verdict);
90
+ if (run === 0) { firstAnalysis = analysis; firstVerdict = verdict; }
91
+ }
92
+
93
+ if (firstVerdict) {
94
+ pairs.push({
95
+ case: c.id,
96
+ claim: g.claimText.slice(0, 45),
97
+ predicted: firstVerdict,
98
+ gold: g.verdict,
99
+ downgraded: isGuardDowngrade(firstAnalysis?.summary),
100
+ summary: String(firstAnalysis?.summary || '').slice(0, 200),
101
+ passageCount: (firstAnalysis?.passages || []).length,
102
+ });
103
+ } else {
104
+ failures.push(`no prediction for "${g.claimText.slice(0, 40)}…" in ${c.id}`);
105
+ }
106
+
107
+ // Passage hallucination (first run): cited quote should appear in the source.
108
+ const refText = normText(ref.text || '');
109
+ for (const p of (firstAnalysis?.passages || [])) {
110
+ const q = normText(passageQuote(p));
111
+ if (q.length < 12) continue;
112
+ passageTotal++;
113
+ if (!refText.includes(q.slice(0, Math.min(q.length, 60)))) passageInfidel++;
114
+ }
115
+
116
+ // Consistency across repeats for this pair (modal-verdict share).
117
+ if (REPEATS > 1 && runVerdicts.length) {
118
+ const counts = {};
119
+ for (const v of runVerdicts) counts[v] = (counts[v] || 0) + 1;
120
+ consistencyShares.push(Math.max(...Object.values(counts)) / runVerdicts.length);
121
+ }
122
+ }
123
+ }
124
+
125
+ const acc = verdictAccuracy(pairs);
126
+ const cm = confusionMatrix(pairs);
127
+ const hallucinationRate = passageTotal ? passageInfidel / passageTotal : 0;
128
+ const consistency = consistencyShares.length ? mean(consistencyShares) : null;
129
+ const downgradedPairs = pairs.filter(p => p.downgraded);
130
+
131
+ // Per-verify-call latency (one call verifies one claim against its reference,
132
+ // so this is p50/p95 latency per claim). Counts as info in the snapshot — not
133
+ // a pass/fail gate.
134
+ const verifyMs = adapter.callLatencies();
135
+ const tok = adapter.tokenTotals();
136
+ const model = adapter.runModel();
137
+
138
+ const metrics = {
139
+ n_pairs: pairs.length,
140
+ verdict_exact: round(acc.exact),
141
+ verdict_adjacency: round(acc.adjacent),
142
+ hallucination_rate: round(hallucinationRate),
143
+ guard_downgrades: downgradedPairs.length,
144
+ ...(consistency != null ? { consistency: round(consistency), repeats: REPEATS } : {}),
145
+ ...(verifyMs.length ? {
146
+ latency_p50_ms: Math.round(percentile(verifyMs, 50)),
147
+ latency_p95_ms: Math.round(percentile(verifyMs, 95)),
148
+ latency_calls: verifyMs.length,
149
+ } : {}),
150
+ ...(tok.calls ? {
151
+ avg_input_tokens: Math.round(tok.prompt_tokens / tok.calls),
152
+ avg_output_tokens: Math.round(tok.completion_tokens / tok.calls),
153
+ avg_reasoning_tokens: Math.round(tok.reasoning_tokens / tok.calls),
154
+ avg_total_tokens: Math.round(tok.total_tokens / tok.calls),
155
+ token_calls: tok.calls,
156
+ } : {}),
157
+ ...(model ? { run_model: model } : {}),
158
+ };
159
+
160
+ return { meta, metrics, detail: { confusion: cm, pairs }, failures, passed: failures.length === 0 };
161
+ }
162
+
163
+ function round(x) { return Math.round(x * 1000) / 1000; }