@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
@@ -0,0 +1,132 @@
1
+ // Generic HTTP adapter — evaluate any REST-backed system without writing code.
2
+ //
3
+ // Point OpenGATE at a JSON config describing your endpoints:
4
+ //
5
+ // OPENGATE_ADAPTER=./src/adapters/http.mjs npm run eval:online
6
+ //
7
+ // Config is read from OPENGATE_HTTP_CONFIG (default: ./opengate.http.json,
8
+ // resolved from the current working directory):
9
+ //
10
+ // {
11
+ // "name": "my-system",
12
+ // "baseUrl": "${MY_SYSTEM_URL}",
13
+ // "headers": { "Authorization": "Bearer ${MY_SYSTEM_TOKEN}" },
14
+ // "endpoints": {
15
+ // "splitClaims": "/api/claims/split",
16
+ // "analyzeBatch": "/api/verify/batch"
17
+ // },
18
+ // "modelEnv": "MY_SYSTEM_MODEL"
19
+ // }
20
+ //
21
+ // ${VAR} placeholders are interpolated from the environment at load time.
22
+ // The endpoints must speak the OpenGATE contract shapes (see ADAPTERS.md):
23
+ // if your API uses different shapes, write a thin code adapter instead —
24
+ // this adapter maps transport, not payloads.
25
+ //
26
+ // Latency and token capture are built in: every call's wall-clock time is
27
+ // recorded, and any `usage` block in a response is aggregated for cost/claim.
28
+
29
+ import { readFileSync } from 'node:fs';
30
+ import { resolve } from 'node:path';
31
+
32
+ // ── Config ──────────────────────────────────────────────────────────────
33
+ const CONFIG_PATH = resolve(process.cwd(), process.env.OPENGATE_HTTP_CONFIG || 'opengate.http.json');
34
+
35
+ function interpolate(value, missing) {
36
+ if (typeof value === 'string') {
37
+ return value.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
38
+ const v = process.env[name];
39
+ if (v == null || v === '') { missing.add(name); return ''; }
40
+ return v;
41
+ });
42
+ }
43
+ if (Array.isArray(value)) return value.map(v => interpolate(v, missing));
44
+ if (value && typeof value === 'object') {
45
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, interpolate(v, missing)]));
46
+ }
47
+ return value;
48
+ }
49
+
50
+ let config = null;
51
+ let configError = null;
52
+ const missingEnv = new Set();
53
+ try {
54
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
55
+ config = interpolate(raw, missingEnv);
56
+ if (!config.baseUrl) configError = 'config has no baseUrl';
57
+ else if (!config.endpoints?.splitClaims || !config.endpoints?.analyzeBatch) {
58
+ configError = 'config.endpoints must define splitClaims and analyzeBatch paths';
59
+ }
60
+ } catch (err) {
61
+ configError = err.code === 'ENOENT'
62
+ ? `no config found at ${CONFIG_PATH}`
63
+ : `could not parse ${CONFIG_PATH}: ${err.message}`;
64
+ }
65
+
66
+ export const meta = { name: config?.name || 'http' };
67
+
68
+ export function onlineAvailable() {
69
+ return Boolean(config && !configError && missingEnv.size === 0);
70
+ }
71
+
72
+ export function onlineConfigHint() {
73
+ if (missingEnv.size) return `HTTP adapter: unset environment variable(s): ${[...missingEnv].join(', ')}.`;
74
+ if (configError) return `HTTP adapter: ${configError}. Set OPENGATE_HTTP_CONFIG or create opengate.http.json (see ADAPTERS.md).`;
75
+ return 'HTTP adapter: configuration incomplete.';
76
+ }
77
+
78
+ export function runModel() {
79
+ const envName = config?.modelEnv;
80
+ return (envName && process.env[envName]) || process.env.OPENGATE_EVAL_MODEL || null;
81
+ }
82
+
83
+ // ── Timing & token capture (same pattern as the reference adapter) ──────
84
+ const _calls = [];
85
+ export function resetTiming() { _calls.length = 0; }
86
+ export function callLatencies() { return _calls.map(c => c.ms); }
87
+
88
+ const _tokens = [];
89
+ export function resetTokens() { _tokens.length = 0; }
90
+ export function tokenTotals() {
91
+ const sum = (k) => _tokens.reduce((a, t) => a + (t[k] || 0), 0);
92
+ return {
93
+ calls: _tokens.length,
94
+ prompt_tokens: sum('prompt_tokens'),
95
+ completion_tokens: sum('completion_tokens'),
96
+ reasoning_tokens: sum('reasoning_tokens'),
97
+ total_tokens: sum('total_tokens'),
98
+ };
99
+ }
100
+
101
+ // ── Transport ───────────────────────────────────────────────────────────
102
+ async function post(path, body) {
103
+ const t0 = performance.now();
104
+ try {
105
+ const res = await fetch(`${config.baseUrl}${path}`, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json', ...(config.headers || {}) },
108
+ body: JSON.stringify(body),
109
+ });
110
+ const data = await res.json().catch(() => ({}));
111
+ if (!res.ok) throw new Error(data.error || `${path} → HTTP ${res.status}`);
112
+ if (data && data.usage) {
113
+ _tokens.push({
114
+ prompt_tokens: data.usage.prompt_tokens || 0,
115
+ completion_tokens: data.usage.completion_tokens || 0,
116
+ reasoning_tokens: data.usage.reasoning_tokens || 0,
117
+ total_tokens: data.usage.total_tokens || 0,
118
+ });
119
+ }
120
+ return data;
121
+ } finally {
122
+ _calls.push({ path, ms: performance.now() - t0 });
123
+ }
124
+ }
125
+
126
+ export function splitClaims(text) {
127
+ return post(config.endpoints.splitClaims, { text });
128
+ }
129
+
130
+ export function analyzeBatch(payload) {
131
+ return post(config.endpoints.analyzeBatch, payload);
132
+ }
@@ -0,0 +1,109 @@
1
+ // RefCheckr adapter — the reference implementation of the OpenGATE adapter
2
+ // contract (see ADAPTERS.md). Selected by default; to evaluate a different
3
+ // system, write an adapter with the same exports and select it with
4
+ // OPENGATE_ADAPTER=./path/to/adapter.mjs.
5
+ //
6
+ // OFFLINE scorers don't touch this. ONLINE scorers call it to exercise the real
7
+ // RefCheckr endpoints. Configure via env:
8
+ // REFCHECKR_BASE_URL e.g. http://localhost:3848 or https://refcheckr.pharmatools.ai
9
+ // REFCHECKR_TOKEN a valid auth token (endpoints are behind checkAuth)
10
+ //
11
+ // Kept deliberately thin: each method maps to one endpoint and returns parsed
12
+ // JSON, so scorers stay focused on measurement, not transport.
13
+
14
+ export const meta = { name: 'refcheckr' };
15
+
16
+ const BASE = process.env.REFCHECKR_BASE_URL;
17
+ const TOKEN = process.env.REFCHECKR_TOKEN;
18
+
19
+ export function onlineAvailable() {
20
+ return Boolean(BASE && TOKEN);
21
+ }
22
+
23
+ export function onlineConfigHint() {
24
+ return 'Set REFCHECKR_BASE_URL and REFCHECKR_TOKEN to run online scorers.';
25
+ }
26
+
27
+ // Optional label for which model the deployment was running during this eval.
28
+ // The adapter can't infer the server's model, so the operator names it, e.g.
29
+ // export OPENGATE_EVAL_MODEL=claude-haiku-4-5
30
+ // It's recorded in the scorecard so per-model runs stay comparable.
31
+ // (REFCHECKR_EVAL_MODEL is accepted as a legacy fallback.)
32
+ export function runModel() {
33
+ return process.env.OPENGATE_EVAL_MODEL || process.env.REFCHECKR_EVAL_MODEL || null;
34
+ }
35
+
36
+ // ── Timing ──────────────────────────────────────────────────────────────
37
+ // Every request through post() records its wall-clock latency here so scorers
38
+ // can report p50/p95. Call resetTiming() at the start of a scorer, then read
39
+ // callLatencies(pathFilter) after the calls complete.
40
+ const _calls = [];
41
+ export function resetTiming() { _calls.length = 0; }
42
+ export function callLatencies(pathIncludes) {
43
+ const rows = pathIncludes ? _calls.filter(c => c.path.includes(pathIncludes)) : _calls.slice();
44
+ return rows.map(c => c.ms);
45
+ }
46
+
47
+ /** p-th percentile (0–100) of a numeric array; null if empty. */
48
+ export function percentile(values, p) {
49
+ const xs = values.filter(v => Number.isFinite(v)).sort((a, b) => a - b);
50
+ if (!xs.length) return null;
51
+ const idx = Math.min(xs.length - 1, Math.floor((p / 100) * xs.length));
52
+ return xs[idx];
53
+ }
54
+
55
+ // ── Token usage ─────────────────────────────────────────────────────────
56
+ // When the server surfaces a `usage` block (model token counts), post() records
57
+ // it here so scorers can compute real cost per claim. Call resetTokens() at the
58
+ // start of a scorer, then tokenTotals(pathFilter) after the calls complete.
59
+ const _tokens = [];
60
+ export function resetTokens() { _tokens.length = 0; }
61
+ export function tokenTotals(pathIncludes) {
62
+ const rows = pathIncludes ? _tokens.filter(t => t.path.includes(pathIncludes)) : _tokens.slice();
63
+ const sum = (k) => rows.reduce((a, t) => a + (t[k] || 0), 0);
64
+ return {
65
+ calls: rows.length,
66
+ prompt_tokens: sum('prompt_tokens'),
67
+ completion_tokens: sum('completion_tokens'),
68
+ reasoning_tokens: sum('reasoning_tokens'),
69
+ total_tokens: sum('total_tokens'),
70
+ };
71
+ }
72
+
73
+ async function post(path, body) {
74
+ const t0 = performance.now();
75
+ try {
76
+ const res = await fetch(`${BASE}${path}`, {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` },
79
+ body: JSON.stringify(body),
80
+ });
81
+ const data = await res.json().catch(() => ({}));
82
+ if (!res.ok) throw new Error(data.error || `${path} → HTTP ${res.status}`);
83
+ if (data && data.usage) {
84
+ _tokens.push({
85
+ path,
86
+ prompt_tokens: data.usage.prompt_tokens || 0,
87
+ completion_tokens: data.usage.completion_tokens || 0,
88
+ reasoning_tokens: data.usage.reasoning_tokens || 0,
89
+ total_tokens: data.usage.total_tokens || 0,
90
+ });
91
+ }
92
+ return data;
93
+ } finally {
94
+ _calls.push({ path, ms: performance.now() - t0 });
95
+ }
96
+ }
97
+
98
+ /** POST /api/claims/split → { claims: [{ text, originalText, citations }] } */
99
+ export function splitClaims(text) {
100
+ return post('/api/claims/split', { text });
101
+ }
102
+
103
+ /**
104
+ * Batch verify — POST /api/verify/batch.
105
+ * payload: { claims[], documents[{name,type,content}], citationMapping, claimCitations, document_name }
106
+ */
107
+ export function analyzeBatch(payload) {
108
+ return post('/api/verify/batch', payload);
109
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,29 @@
1
+ // Public programmatic API for @pharmatools/opengate.
2
+ //
3
+ // The CLI (bin: opengate → src/runner.mjs) is the primary interface; these
4
+ // exports let implementations reuse the framework's pure logic directly —
5
+ // e.g. scoring inside an existing test suite, or building custom tooling on
6
+ // the metric primitives.
7
+
8
+ export {
9
+ jaccard,
10
+ exactSetMatch,
11
+ precisionRecallF1,
12
+ VERDICT_SCALE,
13
+ verdictAccuracy,
14
+ confusionMatrix,
15
+ mean,
16
+ stdev,
17
+ percentile,
18
+ normText,
19
+ claimMatch,
20
+ } from './lib/metrics.mjs';
21
+
22
+ export {
23
+ normalizeCitations,
24
+ parseClaimCitations,
25
+ detectAuthorYear,
26
+ detectCitations,
27
+ } from './lib/citations.mjs';
28
+
29
+ export { loadAdapter, validateAdapter } from './lib/adapter.mjs';
@@ -0,0 +1,78 @@
1
+ // Adapter loading + validation.
2
+ //
3
+ // An adapter is the boundary between OpenGATE's scorers and the system under
4
+ // test. The bundled RefCheckr adapter (src/adapters/refcheckr.mjs) is the
5
+ // reference implementation; see ADAPTERS.md for the full contract.
6
+ //
7
+ // Select an adapter with:
8
+ // OPENGATE_ADAPTER=./path/to/my-adapter.mjs npm run eval:online
9
+ //
10
+ // Relative paths resolve from the current working directory. When the variable
11
+ // is unset, the bundled RefCheckr adapter is used.
12
+
13
+ import { resolve, basename, dirname, join } from 'node:path';
14
+ import { pathToFileURL, fileURLToPath } from 'node:url';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const DEFAULT_ADAPTER = join(__dirname, '..', 'adapters', 'refcheckr.mjs');
18
+
19
+ // Required: online scorers cannot run without these.
20
+ const REQUIRED = ['splitClaims', 'analyzeBatch', 'onlineAvailable', 'onlineConfigHint'];
21
+
22
+ // Optional: validated if present; no-op defaults are supplied if absent, so
23
+ // scorers can call them unconditionally.
24
+ const OPTIONAL = ['runModel', 'resetTiming', 'callLatencies', 'resetTokens', 'tokenTotals'];
25
+
26
+ const DEFAULTS = {
27
+ runModel: () => null,
28
+ resetTiming: () => {},
29
+ callLatencies: () => [],
30
+ resetTokens: () => {},
31
+ tokenTotals: () => ({
32
+ calls: 0, prompt_tokens: 0, completion_tokens: 0, reasoning_tokens: 0, total_tokens: 0,
33
+ }),
34
+ };
35
+
36
+ /** Throws with a readable message listing every problem, or returns silently. */
37
+ export function validateAdapter(mod, source = 'adapter') {
38
+ const problems = [];
39
+ for (const fn of REQUIRED) {
40
+ if (typeof mod[fn] !== 'function') problems.push(`missing required export: ${fn}()`);
41
+ }
42
+ for (const fn of OPTIONAL) {
43
+ if (fn in mod && typeof mod[fn] !== 'function') problems.push(`optional export ${fn} is not a function`);
44
+ }
45
+ if (problems.length) {
46
+ throw new Error(
47
+ `Invalid adapter (${source}):\n - ${problems.join('\n - ')}\n` +
48
+ 'See ADAPTERS.md for the required surface.'
49
+ );
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Load, validate, and normalise an adapter.
55
+ * Precedence: explicit argument (--adapter flag) > OPENGATE_ADAPTER env >
56
+ * bundled RefCheckr reference adapter.
57
+ */
58
+ export async function loadAdapter(specOverride) {
59
+ const spec = specOverride || process.env.OPENGATE_ADAPTER;
60
+ const path = spec ? resolve(process.cwd(), spec) : DEFAULT_ADAPTER;
61
+ let mod;
62
+ try {
63
+ mod = await import(pathToFileURL(path).href);
64
+ } catch (err) {
65
+ throw new Error(`Could not load adapter from ${path}: ${err.message}`);
66
+ }
67
+ validateAdapter(mod, path);
68
+ const methods = Object.fromEntries(
69
+ [...REQUIRED, ...OPTIONAL]
70
+ .filter(f => typeof mod[f] === 'function')
71
+ .map(f => [f, mod[f]])
72
+ );
73
+ return {
74
+ name: mod.meta?.name || basename(path, '.mjs'),
75
+ ...DEFAULTS,
76
+ ...methods,
77
+ };
78
+ }
@@ -0,0 +1,109 @@
1
+ // Reference implementation of the deterministic citation-detection logic
2
+ // (originally developed for RefCheckr, OpenGATE's first implementation).
3
+ //
4
+ // This MIRRORS RefCheckr production's lib/citations.js, which routes/verify.js
5
+ // imports — the inline duplication in verify.js was promoted to a shared
6
+ // module, so the citation-detection scorer is a true regression test of the
7
+ // production algorithm. If you change behaviour in either copy, port it to the
8
+ // other; the citation-styles fixture catches drift (verified: 104 inputs,
9
+ // byte-identical outputs across both modules).
10
+
11
+ export function normalizeCitations(text) {
12
+ // Pass 1: "word.1,2" / "word1,2" → "word.[1,2]" (letter/period/paren/quote + digits)
13
+ let result = text.replace(/(?<!\d)([a-zA-Z.)"])(\d{1,3}(?:[,\-‐‑‒–]\d{1,3})*)\s/g, (match, prefix, nums) => {
14
+ if (/\d$/.test(prefix)) return match;
15
+ const numParts = nums.split(/[,\-‐‑‒–]/);
16
+ if (numParts.some(p => parseInt(p) > 200)) return match; // guards years / large numbers
17
+ // Avoid false positives on endpoint/identifier names glued to a single number
18
+ // (ACR20, PASI90, EASI75, CD4, type2, grade3): only convert when the prefix is
19
+ // punctuation (e.g. "placebo.1") or it is a list/range (e.g. "outcomes1,2").
20
+ if (/[a-zA-Z]/.test(prefix) && numParts.length === 1) return match;
21
+ return `${prefix}[${nums}] `;
22
+ });
23
+ // Pass 2: "word,12 " → "word,[12] "
24
+ result = result.replace(/([a-zA-Z]),(\d{1,3}(?:[\-‐‑‒–]\d{1,3})?)\s/g, (match, prefix, nums) => {
25
+ if (parseInt(nums) > 200) return match;
26
+ return `${prefix},[${nums}] `;
27
+ });
28
+ // Pass 3: parenthetical "(1,2)" / "(3-9)" → "[1,2]" / "[3-9]"
29
+ result = result.replace(/\((\d{1,3}(?:[,\-‐‑‒–]\d{1,3})*)\)/g, (match, nums) => {
30
+ const numParts = nums.split(/[,\-‐‑‒–]/);
31
+ if (numParts.some(p => parseInt(p) > 200)) return match;
32
+ return `[${nums}]`;
33
+ });
34
+ return result;
35
+ }
36
+
37
+ /** Parse the set of citation numbers from a (already-normalised) claim string. */
38
+ export function parseClaimCitations(claim) {
39
+ const citationPattern = /\[(\d[\d,\-‐‑‒–\s]*)\]/g;
40
+ const citations = new Set();
41
+ let match;
42
+ while ((match = citationPattern.exec(claim)) !== null) {
43
+ match[1].split(',').forEach(part => {
44
+ part = part.trim();
45
+ const range = part.match(/^(\d+)\s*[-‐‑‒–]\s*(\d+)$/);
46
+ if (range) {
47
+ for (let i = parseInt(range[1]); i <= parseInt(range[2]); i++) citations.add(i);
48
+ } else if (/^\d+$/.test(part)) {
49
+ citations.add(parseInt(part));
50
+ }
51
+ });
52
+ }
53
+ return [...citations].sort((a, b) => a - b);
54
+ }
55
+
56
+ // ── Author-year detection ───────────────────────────────────────────────
57
+ // Framework capability, additive to the numeric passes above. The numeric
58
+ // functions (normalizeCitations / parseClaimCitations) still mirror RefCheckr
59
+ // production (routes/verify.js) exactly; author-year keys are not yet consumed
60
+ // by RefCheckr's downstream citation mapping, which is numeric-keyed.
61
+ //
62
+ // A detected author-year citation is keyed "Surname YYYY" (first author only),
63
+ // e.g. "(Smith et al., 2020)" → "Smith 2020".
64
+
65
+ const NAME = String.raw`[A-Z][A-Za-z'’\-]+`;
66
+ const YEAR = String.raw`(?:1[89]|20)\d{2}`;
67
+ const ETAL = String.raw`(?:et al\.?|and colleagues|and coworkers)`;
68
+
69
+ /** Detect author-year citations in raw text; returns sorted unique keys. */
70
+ export function detectAuthorYear(text) {
71
+ const found = new Set();
72
+ const add = (name, year) => found.add(`${name} ${year.slice(0, 4)}`);
73
+
74
+ // 1. Narrative with parenthetical year: "Smith et al. (2020)",
75
+ // "Smith and Jones (2019)", "Smith (2020)".
76
+ const narrative = new RegExp(
77
+ String.raw`\b(${NAME})(?:\s+(?:${ETAL}|(?:and|&)\s+${NAME}))?\s*\((${YEAR}[a-z]?)\)`, 'g');
78
+ for (const m of text.matchAll(narrative)) add(m[1], m[2]);
79
+
80
+ // 2. Parenthetical: "(Smith et al., 2020)", "(Smith & Jones, 2019)",
81
+ // "(Kim, 2023)", multiples split on ";": "(Brown 2018; Lee et al., 2022)".
82
+ const inner = new RegExp(
83
+ String.raw`^\s*(${NAME})(?:\s+${ETAL}|,?\s+(?:and|&)\s+${NAME})?,?\s+(${YEAR}[a-z]?)\s*$`);
84
+ for (const m of text.matchAll(/\(([^()]+)\)/g)) {
85
+ for (const part of m[1].split(';')) {
86
+ const hit = part.match(inner);
87
+ if (hit) add(hit[1], hit[2]);
88
+ }
89
+ }
90
+
91
+ // 3. Narrative prose year: "Jones and colleagues in 2019". Requires an
92
+ // et-al-style marker so plain prose years ("began in 2019") never match.
93
+ const prose = new RegExp(
94
+ String.raw`\b(${NAME})\s+${ETAL}[^.;()]*?\bin\s+(${YEAR})\b`, 'g');
95
+ for (const m of text.matchAll(prose)) add(m[1], m[2]);
96
+
97
+ return [...found].sort();
98
+ }
99
+
100
+ /**
101
+ * Convenience: detect citations directly from raw claim text.
102
+ * Returns numeric citations (numbers) followed by author-year keys (strings).
103
+ */
104
+ export function detectCitations(rawClaim) {
105
+ return [
106
+ ...parseClaimCitations(normalizeCitations(rawClaim + ' ')),
107
+ ...detectAuthorYear(rawClaim),
108
+ ];
109
+ }
@@ -0,0 +1,104 @@
1
+ // Generic scoring primitives used by the eval scorers.
2
+ // Pure functions, no I/O — easy to unit-test and reason about.
3
+
4
+ /** Set intersection-over-union for two arrays of primitives. */
5
+ export function jaccard(a, b) {
6
+ const A = new Set(a), B = new Set(b);
7
+ if (A.size === 0 && B.size === 0) return 1;
8
+ let inter = 0;
9
+ for (const x of A) if (B.has(x)) inter++;
10
+ const union = A.size + B.size - inter;
11
+ return union === 0 ? 1 : inter / union;
12
+ }
13
+
14
+ /** True if two arrays contain exactly the same set of values. */
15
+ export function exactSetMatch(a, b) {
16
+ const A = new Set(a), B = new Set(b);
17
+ if (A.size !== B.size) return false;
18
+ for (const x of A) if (!B.has(x)) return false;
19
+ return true;
20
+ }
21
+
22
+ /**
23
+ * Precision / recall / F1 for predicted vs gold items.
24
+ * `matchFn(pred, gold) => bool` decides whether two items are "the same"
25
+ * (e.g. fuzzy text match for claims). Greedy one-to-one matching.
26
+ */
27
+ export function precisionRecallF1(predicted, gold, matchFn = (a, b) => a === b) {
28
+ const usedGold = new Set();
29
+ let tp = 0;
30
+ for (const p of predicted) {
31
+ const gi = gold.findIndex((g, i) => !usedGold.has(i) && matchFn(p, g));
32
+ if (gi >= 0) { tp++; usedGold.add(gi); }
33
+ }
34
+ const fp = predicted.length - tp;
35
+ const fn = gold.length - tp;
36
+ const precision = predicted.length ? tp / predicted.length : (gold.length ? 0 : 1);
37
+ const recall = gold.length ? tp / gold.length : 1;
38
+ const f1 = (precision + recall) ? (2 * precision * recall) / (precision + recall) : 0;
39
+ return { tp, fp, fn, precision, recall, f1 };
40
+ }
41
+
42
+ // Verdict spectrum, ordered by how well the claim (as written) is borne out by
43
+ // the reference — see datasets/LABELING-GUIDE.md. Used for exact accuracy and
44
+ // "off-by-one" adjacency accuracy.
45
+ export const VERDICT_SCALE = [
46
+ 'strong_support',
47
+ 'partial_support',
48
+ 'implied_by_data',
49
+ 'overclaim',
50
+ 'not_supported',
51
+ 'contradicted',
52
+ ];
53
+
54
+ export function verdictAccuracy(pairs) {
55
+ // pairs: [{ predicted, gold }]
56
+ let exact = 0, adjacent = 0;
57
+ for (const { predicted, gold } of pairs) {
58
+ if (predicted === gold) { exact++; adjacent++; continue; }
59
+ const pi = VERDICT_SCALE.indexOf(predicted);
60
+ const gi = VERDICT_SCALE.indexOf(gold);
61
+ if (pi >= 0 && gi >= 0 && Math.abs(pi - gi) === 1) adjacent++;
62
+ }
63
+ const n = pairs.length || 1;
64
+ return { n: pairs.length, exact: exact / n, adjacent: adjacent / n };
65
+ }
66
+
67
+ export function confusionMatrix(pairs, labels = VERDICT_SCALE) {
68
+ const idx = Object.fromEntries(labels.map((l, i) => [l, i]));
69
+ const m = labels.map(() => labels.map(() => 0));
70
+ for (const { predicted, gold } of pairs) {
71
+ if (idx[gold] != null && idx[predicted] != null) m[idx[gold]][idx[predicted]]++;
72
+ }
73
+ return { labels, matrix: m };
74
+ }
75
+
76
+ export function mean(xs) { return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0; }
77
+
78
+ /** p-th percentile (0–100) of a numeric array; null if empty. */
79
+ export function percentile(values, p) {
80
+ const xs = values.filter(v => Number.isFinite(v)).sort((a, b) => a - b);
81
+ if (!xs.length) return null;
82
+ const idx = Math.min(xs.length - 1, Math.floor((p / 100) * xs.length));
83
+ return xs[idx];
84
+ }
85
+ export function stdev(xs) {
86
+ if (xs.length < 2) return 0;
87
+ const m = mean(xs);
88
+ return Math.sqrt(mean(xs.map(x => (x - m) ** 2)));
89
+ }
90
+
91
+ /** Normalise claim text for fuzzy matching (lowercase, alnum-only). */
92
+ export function normText(s) {
93
+ return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '');
94
+ }
95
+
96
+ /** Fuzzy claim match: containment either direction after normalisation. */
97
+ export function claimMatch(a, b) {
98
+ const x = normText(a), y = normText(b);
99
+ if (!x || !y) return false;
100
+ if (x === y) return true;
101
+ const [short, long] = x.length <= y.length ? [x, y] : [y, x];
102
+ // Require the shorter to be a substantial substring of the longer.
103
+ return short.length >= 12 && long.includes(short);
104
+ }