SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
8.3 KB · 176 lines typescript
Raw Blame History
1import { describe, expect, it } from 'vitest';2import { computeShares, eligibility, log2Ratio, per1000Deaths, RESEARCH_GAP_FORMULA_VERSION, RESEARCH_GAP_THRESHOLDS } from '../src/research-gap.js';34const T = { minDeaths: 100, minActivity: 1 };56describe('eligibility', () => {7  it('rejects missing or small burden with a stored reason', () => {8    expect(eligibility({ deaths: null, activeTrials: 5, publications5y: 5 }, T)).toEqual({ eligible: false, reason: 'no_mortality_observation', trialRatio: false, publicationRatio: false });9    expect(eligibility({ deaths: NaN, activeTrials: 5, publications5y: 5 }, T).reason).toBe('no_mortality_observation');10    const small = eligibility({ deaths: 99, activeTrials: 5, publications5y: 5 }, T);11    expect(small.eligible).toBe(false);12    expect(small.reason).toMatch(/deaths_below_threshold \(99 < 100\)/);13  });14  it('accepts deaths at the threshold and gates each ratio on its own activity', () => {15    expect(eligibility({ deaths: 100, activeTrials: 1, publications5y: 1 }, T)).toEqual({ eligible: true, reason: null, trialRatio: true, publicationRatio: true });16    const noTrials = eligibility({ deaths: 5000, activeTrials: 0, publications5y: 12 }, T);17    expect(noTrials).toEqual({ eligible: true, reason: null, trialRatio: false, publicationRatio: true });18  });19  it('uses the seeded thresholds by default', () => {20    expect(RESEARCH_GAP_THRESHOLDS.minDeaths).toBe(100);21    expect(RESEARCH_GAP_THRESHOLDS.minActivity).toBe(1);22    expect(eligibility({ deaths: 99, activeTrials: 1, publications5y: 1 }).eligible).toBe(false);23    expect(RESEARCH_GAP_FORMULA_VERSION).toBe('ci-research-gap-components-v1');24  });25});2627describe('log2Ratio', () => {28  it('is 0 when shares match, ±1 when they differ by a factor of two', () => {29    expect(log2Ratio(0.25, 0.25)).toBe(0);30    expect(log2Ratio(0.5, 0.25)).toBe(1);31    expect(log2Ratio(0.25, 0.5)).toBe(-1);32    expect(log2Ratio(0.3, 0.1)).toBeCloseTo(Math.log2(3), 6);33  });34  it('is undefined (null) for zero, negative, null or non-finite operands', () => {35    expect(log2Ratio(0.2, 0)).toBeNull();36    expect(log2Ratio(0, 0.2)).toBeNull();37    expect(log2Ratio(-1, 0.2)).toBeNull();38    expect(log2Ratio(null, 0.2)).toBeNull();39    expect(log2Ratio(0.2, undefined)).toBeNull();40    expect(log2Ratio(Infinity, 0.2)).toBeNull();41    expect(log2Ratio(0.2, NaN)).toBeNull();42  });43  it('rounds to 6 decimals', () => {44    const v = log2Ratio(1 / 3, 1 / 7)!;45    expect(v).toBe(Math.round(Math.log2(7 / 3) * 1e6) / 1e6);46  });47});4849describe('per1000Deaths', () => {50  it('divides activity by thousands of deaths', () => {51    expect(per1000Deaths(50, 1000)).toBe(50);52    expect(per1000Deaths(2622, 125000)).toBeCloseTo(20.976, 3);53    expect(per1000Deaths(0, 1000)).toBe(0);54  });55  it('is null without positive deaths', () => {56    expect(per1000Deaths(5, 0)).toBeNull();57    expect(per1000Deaths(5, null)).toBeNull();58    expect(per1000Deaths(5, -3)).toBeNull();59    expect(per1000Deaths(NaN, 100)).toBeNull();60  });61});6263describe('computeShares', () => {64  const inputs = [65    { id: 'lung', deaths: 60_000, activeTrials: 300, publications5y: 9_000 },66    { id: 'breast', deaths: 30_000, activeTrials: 300, publications5y: 9_000 },67    { id: 'rare', deaths: 10_000, activeTrials: 200, publications5y: 2_000 },68    { id: 'tiny', deaths: 50, activeTrials: 400, publications5y: 100 }, // below minDeaths → excluded from every sum69  ];7071  it('computes shares over the eligible set only and they sum to 1', () => {72    const { rows, sums } = computeShares(inputs, T);73    expect(sums).toEqual({ deaths: 100_000, activeTrials: 800, publications5y: 20_000, eligible: 3 });74    const byId = new Map(rows.map((r) => [r.id, r]));75    expect(byId.get('lung')!.deathShare).toBe(0.6);76    expect(byId.get('breast')!.deathShare).toBe(0.3);77    expect(byId.get('rare')!.deathShare).toBe(0.1);78    expect(byId.get('lung')!.trialShare).toBe(0.375);79    expect(byId.get('rare')!.trialShare).toBe(0.25);80    expect(byId.get('lung')!.publicationShare).toBe(0.45);81    const eligible = rows.filter((r) => r.eligible);82    for (const key of ['deathShare', 'trialShare', 'publicationShare'] as const) {83      const sum = eligible.reduce((s, r) => s + (r[key] ?? 0), 0);84      expect(sum).toBeCloseTo(1, 9);85    }86  });8788  it('derives the log2 ratios from the shares and keeps the ineligible row with nulls', () => {89    const { rows } = computeShares(inputs, T);90    const byId = new Map(rows.map((r) => [r.id, r]));91    expect(byId.get('lung')!.trialGapRatio).toBeCloseTo(Math.log2(0.6 / 0.375), 6); // +0.678: more deaths than trials would suggest92    expect(byId.get('rare')!.trialGapRatio).toBeCloseTo(Math.log2(0.1 / 0.25), 6); // −1.32: comparatively well trialled93    expect(byId.get('rare')!.researchGapRatio).toBe(0); // 10% of deaths, 10% of publications94    expect(byId.get('lung')!.trialsPer1000Deaths).toBe(5);95    expect(byId.get('lung')!.publicationsPer1000Deaths).toBe(150);96    const tiny = byId.get('tiny')!;97    expect(tiny.eligible).toBe(false);98    expect(tiny.reason).toMatch(/deaths_below_threshold/);99    expect(tiny.deathShare).toBeNull();100    expect(tiny.trialShare).toBeNull();101    expect(tiny.trialGapRatio).toBeNull();102    expect(tiny.researchGapRatio).toBeNull();103    // Per-1,000 values are still informative for an ineligible row (no cross-entity denominator involved).104    expect(tiny.trialsPer1000Deaths).toBe(8000);105  });106107  it('handles zero activity: share 0, ratio null, others unaffected', () => {108    const { rows, sums } = computeShares(109      [110        { id: 'a', deaths: 1000, activeTrials: 0, publications5y: 10 },111        { id: 'b', deaths: 1000, activeTrials: 10, publications5y: 10 },112      ],113      T,114    );115    expect(sums.activeTrials).toBe(10);116    const a = rows.find((r) => r.id === 'a')!;117    const b = rows.find((r) => r.id === 'b')!;118    expect(a.eligible).toBe(true);119    expect(a.trialRatio).toBe(false);120    expect(a.trialShare).toBe(0);121    expect(a.trialGapRatio).toBeNull();122    expect(a.researchGapRatio).toBe(0);123    expect(a.trialsPer1000Deaths).toBe(0);124    expect(b.trialShare).toBe(1);125    expect(b.trialGapRatio).toBe(-1); // 50% of deaths, 100% of trials126  });127128  it('single eligible entity: every share is 1 and every ratio 0', () => {129    const { rows } = computeShares([{ id: 'only', deaths: 500, activeTrials: 3, publications5y: 7 }], T);130    expect(rows[0]).toMatchObject({ deathShare: 1, trialShare: 1, publicationShare: 1, trialGapRatio: 0, researchGapRatio: 0 });131  });132133  it('no eligible entity: sums are zero and shares null (no division by zero)', () => {134    const { rows, sums } = computeShares([{ id: 'x', deaths: 10, activeTrials: 3, publications5y: 7 }], T);135    expect(sums).toEqual({ deaths: 0, activeTrials: 0, publications5y: 0, eligible: 0 });136    expect(rows[0]!.deathShare).toBeNull();137  });138139  it('all activity zero in the scope: shares null, ratios null, nothing NaN', () => {140    const { rows } = computeShares(141      [142        { id: 'a', deaths: 1000, activeTrials: 0, publications5y: 0 },143        { id: 'b', deaths: 3000, activeTrials: 0, publications5y: 0 },144      ],145      T,146    );147    for (const r of rows) {148      expect(r.deathShare).not.toBeNull();149      expect(r.trialShare).toBeNull();150      expect(r.publicationShare).toBeNull();151      expect(r.trialGapRatio).toBeNull();152      expect(r.researchGapRatio).toBeNull();153      for (const v of Object.values(r)) if (typeof v === 'number') expect(Number.isNaN(v)).toBe(false);154    }155  });156157  it('rounds shares to 6 decimals and is order-independent', () => {158    const a = computeShares(inputs, T);159    const b = computeShares([...inputs].reverse(), T);160    const sortById = (r: { id: string }[]) => [...r].sort((x, y) => x.id.localeCompare(y.id));161    expect(sortById(a.rows)).toEqual(sortById(b.rows));162    const { rows } = computeShares(163      [164        { id: 'a', deaths: 1000, activeTrials: 1, publications5y: 1 },165        { id: 'b', deaths: 2000, activeTrials: 1, publications5y: 1 },166        { id: 'c', deaths: 4000, activeTrials: 1, publications5y: 1 },167      ],168      T,169    );170    expect(rows.find((r) => r.id === 'a')!.deathShare).toBe(0.142857); // 1/7 rounded171    expect(rows.find((r) => r.id === 'a')!.trialShare).toBe(0.333333);172    // Ratio is computed from unrounded shares: log2((1/7)/(1/3)) = log2(3/7)173    expect(rows.find((r) => r.id === 'a')!.trialGapRatio).toBeCloseTo(Math.log2(3 / 7), 6);174  });175});176