Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,6 @@ NGINX_PORT=3081
# Solr and Redis ports exposed to host (for debugging/admin)
SOLR_PORT=8984
REDIS_PORT=6379

# Measure evaluation engine: sql (star-schema aggregation) | cql (future cqf-ruler bridge)
MEASURE_EVALUATOR=sql
6 changes: 3 additions & 3 deletions apps/api/src/routes/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
generateDeidentifiedCohort,
} from '../../services/omopExport.js';
import { sql } from '@medgnosis/db';
import { refreshMeasureResults } from '../../services/measureCalculatorV2.js';
import { getMeasureEvaluator } from '../../services/measureEvaluator.js';
import { getSolrClient, isSolrAvailable } from '../../plugins/solr.js';
import { config } from '../../config.js';

Expand Down Expand Up @@ -380,7 +380,7 @@ export default async function adminRoutes(app: FastifyInstance) {

// Also refresh measure results after mat views
try {
await refreshMeasureResults();
await getMeasureEvaluator().refresh();
results.push({ view: 'fact_measure_result', status: 'ok' });
} catch (err) {
results.push({ view: 'fact_measure_result', status: 'error', error: String(err) });
Expand All @@ -396,7 +396,7 @@ export default async function adminRoutes(app: FastifyInstance) {

app.post('/refresh-measures', async (req, reply) => {
try {
const result = await refreshMeasureResults();
const result = await getMeasureEvaluator().refresh();

await sql`
INSERT INTO public.audit_log (user_id, action, resource_type, details)
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import clinicalNoteRoutes from './clinical-notes/index.js';
import orderRoutes from './orders/index.js';
import cdsHooksRoutes from './cds-hooks/index.js';
import rulesRoutes from './rules/index.js';
import valueSetRoutes from './value-sets/index.js';
import problemListRoutes from './problem-list/index.js';
import populationFinderRoutes from './population-finder/index.js';
import closeTheLoopRoutes from './close-the-loop/index.js';
Expand Down Expand Up @@ -52,6 +53,7 @@ export async function registerRoutes(fastify: FastifyInstance): Promise<void> {
await api.register(clinicalNoteRoutes, { prefix: '/clinical-notes' });
await api.register(orderRoutes, { prefix: '/orders' });
await api.register(rulesRoutes, { prefix: '/rules' });
await api.register(valueSetRoutes, { prefix: '/value-sets' });
await api.register(problemListRoutes, { prefix: '/problem-list' });
await api.register(populationFinderRoutes, { prefix: '/population-finder' });
await api.register(closeTheLoopRoutes, { prefix: '/close-the-loop' });
Expand Down
40 changes: 40 additions & 0 deletions apps/api/src/routes/measures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import type { FastifyInstance } from 'fastify';
import { sql } from '@medgnosis/db';
import { measureFilterSchema } from '@medgnosis/shared';
import { wilsonCI } from '../../services/wilsonCI.js';

export default async function measureRoutes(fastify: FastifyInstance): Promise<void> {
fastify.addHook('preHandler', fastify.authenticate);
Expand Down Expand Up @@ -77,4 +78,43 @@ export default async function measureRoutes(fastify: FastifyInstance): Promise<v
},
});
});

// GET /measures/:id/strata — age/sex strata with Wilson 95% CIs.
// measure_id != measure_key: resolve through dim_measure (see GET /:id).
fastify.get<{ Params: { id: string } }>('/:id/strata', async (request, reply) => {
const { id } = request.params;

const rows = await sql<
{ dimension: string; stratum: string; denominator: number; numerator: number; excluded: number }[]
>`
SELECT fms.dimension, fms.stratum,
fms.denominator::int, fms.numerator::int, fms.excluded::int
FROM phm_star.fact_measure_strata fms
JOIN phm_star.dim_measure dm ON dm.measure_key = fms.measure_key
WHERE dm.measure_id = ${id}::int
ORDER BY fms.dimension, fms.stratum
`;

if (rows.length === 0) {
return reply.status(404).send({
success: false,
error: { code: 'NOT_FOUND', message: 'No strata for this measure (run a measure refresh first)' },
});
}

const data = rows.map((row) => {
if (row.denominator <= 0) {
return { ...row, rate: null, ci_lower: null, ci_upper: null };
}
const ci = wilsonCI(row.numerator, row.denominator);
return {
...row,
rate: Math.round((row.numerator / row.denominator) * 1000) / 10,
ci_lower: Math.round(ci.lower * 1000) / 10,
ci_upper: Math.round(ci.upper * 1000) / 10,
};
});

return reply.send({ success: true, data });
});
}
60 changes: 60 additions & 0 deletions apps/api/src/routes/value-sets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// =============================================================================
// Medgnosis API — VSAC value set transparency routes
// Show the authoritative CMS code lists behind any measure. Read-only.
// =============================================================================

import type { FastifyInstance } from 'fastify';
import {
listValueSets,
getValueSetCodes,
getMeasureValueSets,
} from '../../services/vsacService.js';

export default async function valueSetRoutes(fastify: FastifyInstance): Promise<void> {
fastify.addHook('preHandler', fastify.authenticate);

// GET /value-sets?search= — catalog with code counts
fastify.get<{ Querystring: { search?: string } }>('/', async (request, reply) => {
const valueSets = await listValueSets(request.query.search);
return reply.send({ success: true, data: valueSets });
});

// GET /value-sets/measure/:measureCode — value sets bridged to a measure
// (registered before /:oid so "measure" is not swallowed as an OID)
fastify.get<{ Params: { measureCode: string } }>(
'/measure/:measureCode',
async (request, reply) => {
const valueSets = await getMeasureValueSets(request.params.measureCode);
if (valueSets.length === 0) {
return reply.status(404).send({
success: false,
error: {
code: 'NOT_FOUND',
message: `No value sets bridged to measure ${request.params.measureCode}`,
},
});
}
return reply.send({ success: true, data: valueSets });
},
);

// GET /value-sets/:oid/codes?code_system= — the flattened expansion
fastify.get<{
Params: { oid: string };
Querystring: { code_system?: string };
}>('/:oid/codes', async (request, reply) => {
const codes = await getValueSetCodes(request.params.oid, request.query.code_system);
if (codes.length === 0) {
return reply.status(404).send({
success: false,
error: {
code: 'NOT_FOUND',
message: `No codes for value set ${request.params.oid}${
request.query.code_system ? ` in ${request.query.code_system}` : ''
}`,
},
});
}
return reply.send({ success: true, data: codes });
});
}
60 changes: 60 additions & 0 deletions apps/api/src/services/__tests__/measureEvaluator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// =============================================================================
// Unit tests — MeasureEvaluator seam
// =============================================================================

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

const { mockRefresh } = vi.hoisted(() => ({
mockRefresh: vi.fn(async () => ({ rowCount: 42, durationMs: 5 })),
}));

vi.mock('../measureCalculatorV2.js', () => ({
refreshMeasureResults: mockRefresh,
}));

import { getMeasureEvaluator, sqlMeasureEvaluator, cqlMeasureEvaluator } from '../measureEvaluator.js';

const ORIGINAL_ENV = process.env['MEASURE_EVALUATOR'];

beforeEach(() => {
vi.clearAllMocks();
delete process.env['MEASURE_EVALUATOR'];
});

afterEach(() => {
if (ORIGINAL_ENV === undefined) {
delete process.env['MEASURE_EVALUATOR'];
} else {
process.env['MEASURE_EVALUATOR'] = ORIGINAL_ENV;
}
});

describe('sqlMeasureEvaluator', () => {
it('delegates to refreshMeasureResults', async () => {
const result = await sqlMeasureEvaluator.refresh();
expect(mockRefresh).toHaveBeenCalledOnce();
expect(result).toEqual({ rowCount: 42, durationMs: 5 });
});
});

describe('cqlMeasureEvaluator', () => {
it('throws an actionable not-implemented error at refresh time', async () => {
await expect(cqlMeasureEvaluator.refresh()).rejects.toThrow(/CQL evaluator not implemented/);
});
});

describe('getMeasureEvaluator', () => {
it('defaults to sql', () => {
expect(getMeasureEvaluator().kind).toBe('sql');
});

it('selects cql when MEASURE_EVALUATOR=cql', () => {
process.env['MEASURE_EVALUATOR'] = 'cql';
expect(getMeasureEvaluator().kind).toBe('cql');
});

it('rejects unknown evaluator kinds loudly', () => {
process.env['MEASURE_EVALUATOR'] = 'quantum';
expect(() => getMeasureEvaluator()).toThrow(/Unknown MEASURE_EVALUATOR/);
});
});
98 changes: 98 additions & 0 deletions apps/api/src/services/__tests__/vsacService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// =============================================================================
// Unit tests — VSAC value set service
// =============================================================================

import { describe, it, expect, vi, beforeEach } from 'vitest';

type SqlRow = Record<string, unknown>;

const { mockSql } = vi.hoisted(() => {
const fn = vi.fn<(strings: TemplateStringsArray, ...values: unknown[]) => Promise<SqlRow[]>>();
fn.mockResolvedValue([]);
return { mockSql: fn };
});

vi.mock('@medgnosis/db', () => ({
sql: Object.assign(mockSql, {
unsafe: vi.fn().mockResolvedValue([]),
}),
}));

import {
listValueSets,
getValueSetCodes,
getMeasureValueSets,
resolveMeasureCodes,
EDW_CODE_SYSTEM,
} from '../vsacService.js';

beforeEach(() => {
vi.clearAllMocks();
mockSql.mockResolvedValue([]);
});

describe('EDW_CODE_SYSTEM', () => {
it('routes EDW domains to the verified VSAC code systems', () => {
// condition/procedure are SNOMED in phm_edw (verified 2026-06-12) — NOT ICD-10/CPT
expect(EDW_CODE_SYSTEM.condition).toBe('SNOMEDCT');
expect(EDW_CODE_SYSTEM.procedure).toBe('SNOMEDCT');
expect(EDW_CODE_SYSTEM.medication).toBe('RXNORM');
expect(EDW_CODE_SYSTEM.observation).toBe('LOINC');
});
});

describe('listValueSets', () => {
it('returns value set summaries', async () => {
mockSql.mockResolvedValueOnce([
{ value_set_oid: '2.16.840.1.113883.3.464.1003.103.12.1001', name: 'Diabetes', qdm_category: 'Condition', code_count: 120 },
]);
const result = await listValueSets();
expect(result).toHaveLength(1);
expect(result[0]?.name).toBe('Diabetes');
});
});

describe('getValueSetCodes', () => {
// First test asserts the full return path (no filter); the next test
// exercises the code-system filter branch.
it('returns the codes for an OID', async () => {
mockSql.mockResolvedValueOnce([
{ code: '44054006', description: 'Diabetes mellitus type 2', code_system: 'SNOMEDCT' },
]);
const codes = await getValueSetCodes('2.16.840.1.113883.3.464.1003.103.12.1001');
expect(codes).toEqual([
{ code: '44054006', description: 'Diabetes mellitus type 2', code_system: 'SNOMEDCT' },
]);
const values = mockSql.mock.calls[0]?.slice(1) ?? [];
expect(values).toContain('2.16.840.1.113883.3.464.1003.103.12.1001');
});

it('does not throw when a code-system filter is supplied', async () => {
await expect(
getValueSetCodes('2.16.840.1.113883.3.464.1003.103.12.1001', 'SNOMEDCT'),
).resolves.toEqual([]);
});
});

describe('getMeasureValueSets', () => {
it('returns bridged value sets for a measure code', async () => {
mockSql.mockResolvedValueOnce([
{ value_set_oid: '2.16...', name: 'Diabetes', vsac_cms_id: 'CMS122v14', qdm_category: 'Condition', code_count: 120 },
]);
const result = await getMeasureValueSets('CMS122v12');
expect(result[0]?.vsac_cms_id).toBe('CMS122v14');
});
});

describe('resolveMeasureCodes', () => {
it('flattens code rows to a string array', async () => {
mockSql.mockResolvedValueOnce([{ code: '44054006' }, { code: '73211009' }]);
const codes = await resolveMeasureCodes('CMS122v12', 'SNOMEDCT');
expect(codes).toEqual(['44054006', '73211009']);
});

it('returns an empty array for an unbridged measure', async () => {
const codes = await resolveMeasureCodes('CMS249v6', 'SNOMEDCT');
expect(codes).toEqual([]);
});
});
39 changes: 39 additions & 0 deletions apps/api/src/services/__tests__/wilsonCI.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// =============================================================================
// Unit tests — Wilson 95% confidence interval
// Reference values cross-checked against R: binom::binom.wilson()
// =============================================================================

import { describe, it, expect } from 'vitest';
import { wilsonCI } from '../wilsonCI.js';

describe('wilsonCI', () => {
it('computes the textbook 50/100 interval', () => {
const ci = wilsonCI(50, 100);
expect(ci.lower).toBeCloseTo(0.4038, 3);
expect(ci.upper).toBeCloseTo(0.5962, 3);
});

it('handles a perfect rate without exceeding 1', () => {
const ci = wilsonCI(10, 10);
expect(ci.lower).toBeCloseTo(0.7225, 3);
expect(ci.upper).toBeLessThanOrEqual(1);
expect(ci.upper).toBeCloseTo(1.0, 3);
});

it('handles a zero rate without going below 0', () => {
const ci = wilsonCI(0, 10);
expect(ci.lower).toBeGreaterThanOrEqual(0);
expect(ci.lower).toBeCloseTo(0, 3);
expect(ci.upper).toBeCloseTo(0.2775, 3);
});

it('returns a degenerate interval for an empty denominator', () => {
expect(wilsonCI(0, 0)).toEqual({ lower: 0, upper: 0 });
});

it('narrows as n grows', () => {
const small = wilsonCI(5, 10);
const large = wilsonCI(500, 1000);
expect(large.upper - large.lower).toBeLessThan(small.upper - small.lower);
});
});
Loading
Loading