File size: 10,998 Bytes
4add613 9caddec 4add613 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | /**
* Software evals for the research audit pipeline. Not a clinical benchmark:
* no clinician cases, answer keys or scoring of medical correctness. Every
* expected value here is the app's own server-side mapping, so these numbers
* measure whether the verifier enforces its contract, not whether the science
* is right. Output: evals/results.json (read by the web Evals view).
*/
import { writeFileSync, mkdirSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { type ResearchDraft, type ResearchRequest, type ResearchResult } from '@her2/shared';
import { auditResearchDraft, runResearch } from '../apps/api/src/research.js';
import {
allowedTools, DATASET_SHA256, derivedSourceId, expectedClaim, labelSourceId, readResearchTool, workbookSourceId
} from '../apps/api/src/research-evidence.js';
import { CLAUDE_MODEL } from '../apps/api/src/evidence.js';
const PRODUCTS = ['DRG0CYMEB', 'DRG0ERKBH'] as const;
const QUESTIONS = ['composition', 'linker_release', 'payload_risk_transfer', 'workbook_safety', 'label_identity'] as const;
const POLICIES = ['all', 'workbook_only'] as const;
const DRILLS = ['cross_product_citation', 'derived_as_primary', 'invented_source'] as const;
type Scope = { product_id: ResearchRequest['product_id']; question_id: ResearchRequest['question_id']; evidence_policy: ResearchRequest['evidence_policy'] };
const scopes: Scope[] = PRODUCTS.flatMap(product_id => QUESTIONS.flatMap(question_id => POLICIES.map(evidence_policy => ({ product_id, question_id, evidence_policy }))));
function request(scope: Scope, engine: ResearchRequest['engine'], integrity_drill: ResearchRequest['integrity_drill'] = 'none'): ResearchRequest {
return { ...scope, engine, integrity_drill, synthetic_confirmed: true };
}
function invariantsHold(result: ResearchResult) {
return result.clinical_status === 'draft_pending_pharmacist' && result.needs_human === true && result.eligibility === 'not_assessed'
&& result.guardrail.status === 'blocked' && result.answer_correctness_probability === null && result.omission_probability === null;
}
function traceMs(result: ResearchResult, stage: ResearchResult['trace'][number]['stage']) {
return Math.round(result.trace.filter(step => step.stage === stage).reduce((sum, step) => sum + step.duration_ms, 0));
}
// Grid A: rules-only runs, no drill. Verdict per scope and the shift when the label is withheld.
type VerdictRow = Scope & { verdict: string; source_ids: string[]; receipts_read: string[]; invariants: boolean };
async function gridVerdicts() {
const rows: VerdictRow[] = [];
for (const scope of scopes) {
const result = await runResearch(request(scope, 'evidence'));
rows.push({ ...scope, verdict: result.claims[0]?.verdict ?? 'no_claim', source_ids: result.claims[0]?.source_ids ?? [],
receipts_read: result.receipts.map(r => r.id), invariants: invariantsHold(result) });
}
const shifts = PRODUCTS.flatMap(product_id => QUESTIONS.map(question_id => {
const all = rows.find(r => r.product_id === product_id && r.question_id === question_id && r.evidence_policy === 'all')!;
const wb = rows.find(r => r.product_id === product_id && r.question_id === question_id && r.evidence_policy === 'workbook_only')!;
return { product_id, question_id, with_label: all.verdict, workbook_only: wb.verdict, changed: all.verdict !== wb.verdict };
}));
return { rows, shifts, changed_count: shifts.filter(s => s.changed).length, total: shifts.length };
}
// Grid B: injected bad citations after drafting. The verifier must reject every one.
type DrillRow = Scope & { drill: string; rejected: boolean; caught_by: string[]; invariants: boolean };
async function gridDrills() {
const rows: DrillRow[] = [];
for (const scope of scopes) for (const drill of DRILLS) {
const result = await runResearch(request(scope, 'evidence', drill));
const caught = result.challenges.filter(c => c.outcome === 'caught').map(c => c.code);
rows.push({ ...scope, drill, rejected: result.draft_integrity === 'rejected' && result.claims.length === 0 && result.omitted_claim_ids.length > 0,
caught_by: caught, invariants: invariantsHold(result) });
}
return { rows, rejected_count: rows.filter(r => r.rejected).length, total: rows.length };
}
// Grid C: scripted drafter strategies that would score well under a naive metric.
// These are hand-written drafts, not model behaviour.
const STRATEGIES = {
abstain_all: 'Return no claims at all (would max out an "abstention" metric).',
cite_everything: 'Cite every retrieved source, including the author-derived note.',
duplicate_claim: 'Repeat the same claim twice with the right sources.',
other_product_label: "Cite the other product's label summary.",
invented_source: 'Cite a source id that was never retrieved.',
swap_product: 'Keep the sources but declare the other product id.',
honest_expected: 'Cite exactly the independently expected sources (control).'
} as const;
type Strategy = keyof typeof STRATEGIES;
function strategyDraft(strategy: Strategy, req: ResearchRequest, receipts: ReturnType<typeof readResearchTool>): ResearchDraft {
const expected = expectedClaim(req, receipts);
const other = req.product_id === 'DRG0ERKBH' ? 'DRG0CYMEB' : 'DRG0ERKBH';
const claim = (source_ids: string[]) => ({ claim_id: req.question_id, source_ids });
switch (strategy) {
case 'abstain_all': return { product_id: req.product_id, claims: [] };
case 'cite_everything': return { product_id: req.product_id, claims: [claim(receipts.map(r => r.id))] };
case 'duplicate_claim': return { product_id: req.product_id, claims: [claim(expected.source_ids), claim(expected.source_ids)] };
case 'other_product_label': return { product_id: req.product_id, claims: [claim([labelSourceId(other)])] };
case 'invented_source': return { product_id: req.product_id, claims: [claim(['ADCDB-PRIMARY-ASSAY-2024'])] };
case 'swap_product': return { product_id: other, claims: [claim(expected.source_ids)] };
case 'honest_expected': return { product_id: req.product_id, claims: [claim(expected.source_ids)] };
}
}
type StrategyRow = Scope & { strategy: Strategy; accepted: boolean; caught_by: string[]; omitted: string[] };
function gridStrategies() {
const rows: StrategyRow[] = [];
for (const scope of scopes) for (const strategy of Object.keys(STRATEGIES) as Strategy[]) {
const req = request(scope, 'evidence');
const receipts = allowedTools(req).flatMap(tool => readResearchTool(tool, req));
const audit = auditResearchDraft(req, strategyDraft(strategy, req, receipts), receipts);
rows.push({ ...scope, strategy, accepted: audit.accepted, caught_by: audit.challenges.filter(c => c.outcome === 'caught').map(c => c.code),
omitted: audit.omitted_claim_ids });
}
const byStrategy = (Object.keys(STRATEGIES) as Strategy[]).map(strategy => {
const mine = rows.filter(r => r.strategy === strategy);
return { strategy, description: STRATEGIES[strategy], accepted: mine.filter(r => r.accepted).length, total: mine.length,
caught_by: [...new Set(mine.flatMap(r => r.caught_by))] };
});
return { rows, by_strategy: byStrategy };
}
// Grid D: Claude (plan + draft) on every scope. Measures whether the model's identifier-only
// output matches the independent expected selection, plus latency. Skipped without a key.
async function gridClaude(apiKey: string | undefined) {
if (!apiKey) return { skipped: true as const, reason: 'ANTHROPIC_API_KEY not set' };
type ClaudeOk = Scope & { ok: true; accepted: boolean; verdict: string; tools_planned: (string | null)[]; draft_source_ids: string[]; draft_claim_count: number; plan_ms: number; draft_ms: number; wall_ms: number; invariants: boolean };
type ClaudeFail = Scope & { ok: false; error_code: string; wall_ms: number };
const rows: (ClaudeOk | ClaudeFail)[] = [];
for (const scope of scopes) {
const started = performance.now();
try {
const result = await runResearch(request(scope, 'claude'), { claude: { apiKey } });
const expected = result.claims[0];
const draftClaim = result.draft.claims[0];
rows.push({ ...scope, ok: true as const, accepted: result.draft_integrity === 'accepted', verdict: expected?.verdict ?? 'no_claim',
tools_planned: result.trace.filter(s => s.stage === 'retrieve' && s.status === 'completed').map(s => s.tool),
draft_source_ids: draftClaim?.source_ids ?? [], draft_claim_count: result.draft.claims.length,
plan_ms: traceMs(result, 'plan'), draft_ms: traceMs(result, 'draft'), wall_ms: Math.round(performance.now() - started), invariants: invariantsHold(result) });
} catch (error) {
rows.push({ ...scope, ok: false as const, error_code: (error as { code?: string }).code ?? 'UNKNOWN', wall_ms: Math.round(performance.now() - started) });
}
}
const ok = rows.filter((r): r is ClaudeOk => r.ok);
const med = (xs: number[]) => { const s = [...xs].sort((a, b) => a - b); return s.length ? s[Math.floor(s.length / 2)] : null; };
return { skipped: false as const, model: CLAUDE_MODEL, rows, completed: ok.length, failed: rows.length - ok.length,
accepted: ok.filter(r => r.accepted).length, total: rows.length,
median_plan_ms: med(ok.map(r => r.plan_ms)), median_draft_ms: med(ok.map(r => r.draft_ms)), median_wall_ms: med(ok.map(r => r.wall_ms)) };
}
async function main() {
const verdicts = await gridVerdicts();
const drills = await gridDrills();
const strategies = gridStrategies();
const claude = await gridClaude(process.env.ANTHROPIC_API_KEY?.trim() || undefined);
const out = {
kind: 'software_evals_not_clinical_benchmark',
generated_at: new Date().toISOString(),
dataset_sha256: DATASET_SHA256,
products: PRODUCTS, questions: QUESTIONS, policies: POLICIES,
source_ids_example: { workbook: workbookSourceId('DRG0ERKBH'), label: labelSourceId('DRG0ERKBH'), derived: derivedSourceId('DRG0ERKBH') },
verdicts, drills, strategies, claude,
notes: [
'Expected claims and sources are the app\'s own server-side definitions; agreement measures contract enforcement, not scientific truth.',
'Grid C drafts are scripted by the authors to probe the verifier. They are not observed model behaviour.',
'Clinical release stays blocked in every run; no eval changes that gate.'
]
};
mkdirSync('evals', { recursive: true });
writeFileSync('evals/results.json', JSON.stringify(out, null, 1) + '\n');
console.log(JSON.stringify({ verdict_shifts: `${verdicts.changed_count}/${verdicts.total}`, drills_rejected: `${drills.rejected_count}/${drills.total}`,
strategies: strategies.by_strategy.map(s => `${s.strategy}:${s.accepted}/${s.total}`), claude: claude.skipped ? claude.reason : `${claude.accepted}/${claude.total} accepted, ${claude.failed} failed, median wall ${claude.median_wall_ms}ms` }));
}
main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); });
|