import { describe, expect, it } from 'vitest'; import { computeShares, eligibility, log2Ratio, per1000Deaths, RESEARCH_GAP_FORMULA_VERSION, RESEARCH_GAP_THRESHOLDS } from '../src/research-gap.js'; const T = { minDeaths: 100, minActivity: 1 }; describe('eligibility', () => { it('rejects missing or small burden with a stored reason', () => { expect(eligibility({ deaths: null, activeTrials: 5, publications5y: 5 }, T)).toEqual({ eligible: false, reason: 'no_mortality_observation', trialRatio: false, publicationRatio: false }); expect(eligibility({ deaths: NaN, activeTrials: 5, publications5y: 5 }, T).reason).toBe('no_mortality_observation'); const small = eligibility({ deaths: 99, activeTrials: 5, publications5y: 5 }, T); expect(small.eligible).toBe(false); expect(small.reason).toMatch(/deaths_below_threshold \(99 < 100\)/); }); it('accepts deaths at the threshold and gates each ratio on its own activity', () => { expect(eligibility({ deaths: 100, activeTrials: 1, publications5y: 1 }, T)).toEqual({ eligible: true, reason: null, trialRatio: true, publicationRatio: true }); const noTrials = eligibility({ deaths: 5000, activeTrials: 0, publications5y: 12 }, T); expect(noTrials).toEqual({ eligible: true, reason: null, trialRatio: false, publicationRatio: true }); }); it('uses the seeded thresholds by default', () => { expect(RESEARCH_GAP_THRESHOLDS.minDeaths).toBe(100); expect(RESEARCH_GAP_THRESHOLDS.minActivity).toBe(1); expect(eligibility({ deaths: 99, activeTrials: 1, publications5y: 1 }).eligible).toBe(false); expect(RESEARCH_GAP_FORMULA_VERSION).toBe('ci-research-gap-components-v1'); }); }); describe('log2Ratio', () => { it('is 0 when shares match, ±1 when they differ by a factor of two', () => { expect(log2Ratio(0.25, 0.25)).toBe(0); expect(log2Ratio(0.5, 0.25)).toBe(1); expect(log2Ratio(0.25, 0.5)).toBe(-1); expect(log2Ratio(0.3, 0.1)).toBeCloseTo(Math.log2(3), 6); }); it('is undefined (null) for zero, negative, null or non-finite operands', () => { expect(log2Ratio(0.2, 0)).toBeNull(); expect(log2Ratio(0, 0.2)).toBeNull(); expect(log2Ratio(-1, 0.2)).toBeNull(); expect(log2Ratio(null, 0.2)).toBeNull(); expect(log2Ratio(0.2, undefined)).toBeNull(); expect(log2Ratio(Infinity, 0.2)).toBeNull(); expect(log2Ratio(0.2, NaN)).toBeNull(); }); it('rounds to 6 decimals', () => { const v = log2Ratio(1 / 3, 1 / 7)!; expect(v).toBe(Math.round(Math.log2(7 / 3) * 1e6) / 1e6); }); }); describe('per1000Deaths', () => { it('divides activity by thousands of deaths', () => { expect(per1000Deaths(50, 1000)).toBe(50); expect(per1000Deaths(2622, 125000)).toBeCloseTo(20.976, 3); expect(per1000Deaths(0, 1000)).toBe(0); }); it('is null without positive deaths', () => { expect(per1000Deaths(5, 0)).toBeNull(); expect(per1000Deaths(5, null)).toBeNull(); expect(per1000Deaths(5, -3)).toBeNull(); expect(per1000Deaths(NaN, 100)).toBeNull(); }); }); describe('computeShares', () => { const inputs = [ { id: 'lung', deaths: 60_000, activeTrials: 300, publications5y: 9_000 }, { id: 'breast', deaths: 30_000, activeTrials: 300, publications5y: 9_000 }, { id: 'rare', deaths: 10_000, activeTrials: 200, publications5y: 2_000 }, { id: 'tiny', deaths: 50, activeTrials: 400, publications5y: 100 }, // below minDeaths → excluded from every sum ]; it('computes shares over the eligible set only and they sum to 1', () => { const { rows, sums } = computeShares(inputs, T); expect(sums).toEqual({ deaths: 100_000, activeTrials: 800, publications5y: 20_000, eligible: 3 }); const byId = new Map(rows.map((r) => [r.id, r])); expect(byId.get('lung')!.deathShare).toBe(0.6); expect(byId.get('breast')!.deathShare).toBe(0.3); expect(byId.get('rare')!.deathShare).toBe(0.1); expect(byId.get('lung')!.trialShare).toBe(0.375); expect(byId.get('rare')!.trialShare).toBe(0.25); expect(byId.get('lung')!.publicationShare).toBe(0.45); const eligible = rows.filter((r) => r.eligible); for (const key of ['deathShare', 'trialShare', 'publicationShare'] as const) { const sum = eligible.reduce((s, r) => s + (r[key] ?? 0), 0); expect(sum).toBeCloseTo(1, 9); } }); it('derives the log2 ratios from the shares and keeps the ineligible row with nulls', () => { const { rows } = computeShares(inputs, T); const byId = new Map(rows.map((r) => [r.id, r])); expect(byId.get('lung')!.trialGapRatio).toBeCloseTo(Math.log2(0.6 / 0.375), 6); // +0.678: more deaths than trials would suggest expect(byId.get('rare')!.trialGapRatio).toBeCloseTo(Math.log2(0.1 / 0.25), 6); // −1.32: comparatively well trialled expect(byId.get('rare')!.researchGapRatio).toBe(0); // 10% of deaths, 10% of publications expect(byId.get('lung')!.trialsPer1000Deaths).toBe(5); expect(byId.get('lung')!.publicationsPer1000Deaths).toBe(150); const tiny = byId.get('tiny')!; expect(tiny.eligible).toBe(false); expect(tiny.reason).toMatch(/deaths_below_threshold/); expect(tiny.deathShare).toBeNull(); expect(tiny.trialShare).toBeNull(); expect(tiny.trialGapRatio).toBeNull(); expect(tiny.researchGapRatio).toBeNull(); // Per-1,000 values are still informative for an ineligible row (no cross-entity denominator involved). expect(tiny.trialsPer1000Deaths).toBe(8000); }); it('handles zero activity: share 0, ratio null, others unaffected', () => { const { rows, sums } = computeShares( [ { id: 'a', deaths: 1000, activeTrials: 0, publications5y: 10 }, { id: 'b', deaths: 1000, activeTrials: 10, publications5y: 10 }, ], T, ); expect(sums.activeTrials).toBe(10); const a = rows.find((r) => r.id === 'a')!; const b = rows.find((r) => r.id === 'b')!; expect(a.eligible).toBe(true); expect(a.trialRatio).toBe(false); expect(a.trialShare).toBe(0); expect(a.trialGapRatio).toBeNull(); expect(a.researchGapRatio).toBe(0); expect(a.trialsPer1000Deaths).toBe(0); expect(b.trialShare).toBe(1); expect(b.trialGapRatio).toBe(-1); // 50% of deaths, 100% of trials }); it('single eligible entity: every share is 1 and every ratio 0', () => { const { rows } = computeShares([{ id: 'only', deaths: 500, activeTrials: 3, publications5y: 7 }], T); expect(rows[0]).toMatchObject({ deathShare: 1, trialShare: 1, publicationShare: 1, trialGapRatio: 0, researchGapRatio: 0 }); }); it('no eligible entity: sums are zero and shares null (no division by zero)', () => { const { rows, sums } = computeShares([{ id: 'x', deaths: 10, activeTrials: 3, publications5y: 7 }], T); expect(sums).toEqual({ deaths: 0, activeTrials: 0, publications5y: 0, eligible: 0 }); expect(rows[0]!.deathShare).toBeNull(); }); it('all activity zero in the scope: shares null, ratios null, nothing NaN', () => { const { rows } = computeShares( [ { id: 'a', deaths: 1000, activeTrials: 0, publications5y: 0 }, { id: 'b', deaths: 3000, activeTrials: 0, publications5y: 0 }, ], T, ); for (const r of rows) { expect(r.deathShare).not.toBeNull(); expect(r.trialShare).toBeNull(); expect(r.publicationShare).toBeNull(); expect(r.trialGapRatio).toBeNull(); expect(r.researchGapRatio).toBeNull(); for (const v of Object.values(r)) if (typeof v === 'number') expect(Number.isNaN(v)).toBe(false); } }); it('rounds shares to 6 decimals and is order-independent', () => { const a = computeShares(inputs, T); const b = computeShares([...inputs].reverse(), T); const sortById = (r: { id: string }[]) => [...r].sort((x, y) => x.id.localeCompare(y.id)); expect(sortById(a.rows)).toEqual(sortById(b.rows)); const { rows } = computeShares( [ { id: 'a', deaths: 1000, activeTrials: 1, publications5y: 1 }, { id: 'b', deaths: 2000, activeTrials: 1, publications5y: 1 }, { id: 'c', deaths: 4000, activeTrials: 1, publications5y: 1 }, ], T, ); expect(rows.find((r) => r.id === 'a')!.deathShare).toBe(0.142857); // 1/7 rounded expect(rows.find((r) => r.id === 'a')!.trialShare).toBe(0.333333); // Ratio is computed from unrounded shares: log2((1/7)/(1/3)) = log2(3/7) expect(rows.find((r) => r.id === 'a')!.trialGapRatio).toBeCloseTo(Math.log2(3 / 7), 6); }); });