From d7caee537e5698e77e741bc12318afebba30e418 Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 18:44:50 -0400 Subject: [PATCH 01/14] docs: VSAC value sets integration plan + Parthenon eCQM handoff spec --- .../2026-06-12-vsac-value-sets-integration.md | 1475 +++++++++++++++++ .../2026-06-12-parthenon-ecqm-handoff.md | 224 +++ 2 files changed, 1699 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-vsac-value-sets-integration.md create mode 100644 docs/superpowers/specs/2026-06-12-parthenon-ecqm-handoff.md diff --git a/docs/superpowers/plans/2026-06-12-vsac-value-sets-integration.md b/docs/superpowers/plans/2026-06-12-vsac-value-sets-integration.md new file mode 100644 index 0000000..4354f00 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-vsac-value-sets-integration.md @@ -0,0 +1,1475 @@ +# VSAC Value Sets Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Import Parthenon's VSAC value-set asset (1,545 value sets / 225,261 codes / 72 CMS measures) into Medgnosis, bridge it to `phm_edw.measure_definition`, harden the measure calculator (GROUPING SETS stratification + Wilson CIs + exclusion-semantics regression gate), and install the `MeasureEvaluator` interface seam for future CQL. + +**Architecture:** Reference tables land in `phm_edw` (house precedent: Phase 1 put `clinical_rule` there; live DB has only `public`/`phm_edw`/`phm_star`). Data transfers DB-to-DB via `psql \copy` pipes (both databases live on the same host PG17 instance). A bridge table maps measure definitions to value-set OIDs by base CMS number. Calculator changes are additive: a new strata fact table populated in the same refresh transaction, CIs computed in TS. + +**Tech Stack:** Fastify 5 / TypeScript / postgres.js (`@medgnosis/db`), PostgreSQL 17 (host, `claude_dev` via `~/.pgpass`), Vitest (mocked-DB house style), BullMQ. + +**Spec:** `docs/superpowers/specs/2026-06-12-parthenon-ecqm-handoff.md` (Parthenon handoff). This plan implements handoff steps **1, 2, 3, 5**. Step 4 (run-versioning) is deferred to the Phase 2/7 plans that need reproducible snapshots; step 6 (FHIR Measure export) is deferred as interop polish. + +--- + +## Verified Facts (2026-06-12 — re-verify anything marked ⚠ at execution time) + +These were established by direct inspection of both live databases and the codebase. **The handoff's §6.1 domain-routing table is wrong for Medgnosis** — corrected here: + +| Fact | Value | +|---|---| +| Source VSAC tables | `parthenon` DB (host 127.0.0.1:5432), schema `app`: `vsac_value_sets` (1,545), `vsac_value_set_codes` (225,261), `vsac_measures` (72), `vsac_measure_value_sets` (1,597) | +| `phm_edw.condition.condition_code` | **100% SNOMED CT** (324 rows, `code_system='SNOMED'`, e.g. `224295006`). **NOT ICD-10.** Join VSAC `code_system='SNOMEDCT'`. | +| `phm_edw.procedure.procedure_code` | SNOMED CT (e.g. `23183008`). **NOT CPT.** Join VSAC `SNOMEDCT` (CPT secondary). | +| `phm_edw.medication.medication_code` | RxNorm RXCUI (e.g. `141918`). Join VSAC `RXNORM`. | +| `phm_edw.observation.observation_code` | LOINC (e.g. `2160-0`). Join VSAC `LOINC`. | +| `phm_edw.measure_definition` | 753 rows; **45 with CMS codes** (`CMS22v12`, `CMS122v12`, …, v12/v13-era). Criteria columns are free-text prose — this is what the OID bridge upgrades. | +| VSAC measure versions | v14/v15-era (`CMS122v14`, `CMS2v15`). **44 of 45** Medgnosis CMS measures match on base number; only `CMS249` has no VSAC entry. Version drift must be recorded in the bridge. | +| `dim_measure` vs `measure_definition` | `dim_measure` (399 rows, codes like `AFIB-01`) keys the star schema; `measure_definition.measure_id ≠ measure_key` — join through `dim_measure.measure_id`. | +| Exclusion semantics | **Already correct** in `measureCalculatorV2.ts`: `denominator_flag = gap_status IN ('open','closed')`, `exclusion_flag = gap_status = 'excluded'` — excluded patients are NOT in the denominator. Handoff §5.3 becomes a regression gate, not a fix. | +| `gap_status` values in live data | `open`, `closed`, `excluded` (26,967 rows in `fact_patient_bundle_detail`) | +| `dim_patient` | Has `date_of_birth DATE`, `gender VARCHAR`, SCD2 — **must filter `is_current = TRUE`** in joins or rows multiply | +| Migration runner | `packages/db/src/migrate.ts`, `npm run db:migrate` (from `packages/db/`), tracks by filename in `_migrations`, runs each file via `tx.unsafe()` in a transaction | +| ⚠ Highest migration | `038_seed_phase4.sql` (re-verified 2026-06-12 against origin/main `d32acf7`, post Phase-4 merge) — this plan claims **039** and **040**. Concurrent sessions are landing phases same-day; re-check `ls packages/db/migrations/ | sort | tail -3` AND `git log --all --oneline -- 'packages/db/migrations/039*' 'packages/db/migrations/040*'` at execution and renumber to the next free slots if taken. | +| Tests | Vitest, mocked DB (`vi.mock('@medgnosis/db')` with `vi.hoisted` — see `apps/api/src/services/__tests__/rulesEngine.test.ts`). Run: `npm run test -- ` from `apps/api/`. | +| API DB connection | `DATABASE_URL` (postgres.js, `@medgnosis/db`); API runs in Docker pointing at `host.docker.internal:5432/medgnosis` — same instance as the host-side `127.0.0.1` psql access | + +**Guardrails (from project memory — non-negotiable):** +- `git branch --show-current` before EVERY commit (concurrent sessions have switched branches mid-task before). +- Additive migrations only; never touch existing tables' data. The load script must refuse to overwrite non-empty VSAC tables without an explicit `--reload` flag. +- Any jsonb written from TS goes through `sql.json(obj)` — never `JSON.stringify` first. (This plan writes no jsonb from TS, but reviewers should check.) +- `npx tsc --noEmit` before every commit. No frontend changes in this plan, so no `vite build` needed. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `packages/db/migrations/039_vsac_value_sets.sql` | Create | DDL: 4 VSAC reference tables + `measure_value_set` bridge + indexes | +| `packages/db/scripts/load-vsac.sh` | Create | One-shot data transfer parthenon→medgnosis via `\copy` pipes + bridge seed + verification | +| `packages/db/migrations/040_measure_strata.sql` | Create | DDL: `phm_star.fact_measure_strata` | +| `apps/api/src/services/wilsonCI.ts` | Create | Pure Wilson 95% CI function | +| `apps/api/src/services/__tests__/wilsonCI.test.ts` | Create | TDD tests for the above | +| `apps/api/src/services/vsacService.ts` | Create | Value-set queries: list, codes, measure bridge resolution | +| `apps/api/src/services/__tests__/vsacService.test.ts` | Create | Mocked-DB tests | +| `apps/api/src/routes/value-sets/index.ts` | Create | Transparency endpoints (mirrors `/rules` pattern) | +| `apps/api/src/routes/index.ts` | Modify | Register `value-sets` route | +| `apps/api/src/services/measureCalculatorV2.ts` | Modify | Strata insert in refresh transaction; Wilson CIs in summary | +| `apps/api/src/routes/measures/index.ts` | Modify | Add `GET /:id/strata` | +| `apps/api/src/services/measureEvaluator.ts` | Create | `MeasureEvaluator` interface + sql/cql implementations + factory | +| `apps/api/src/services/__tests__/measureEvaluator.test.ts` | Create | Seam tests | +| `apps/api/src/workers/measure-calculator.ts` | Modify | Worker refreshes through the evaluator seam | +| `apps/api/src/routes/admin/index.ts` | Modify | Admin refresh endpoints go through the seam | +| `.env.example` | Modify | Add `MEASURE_EVALUATOR=sql` | + +--- + +### Task 0: Branch and Preflight + +**Files:** none (git + verification only) + +- [ ] **Step 1: Confirm you are in the isolated worktree** + +Execution happens in a git worktree so concurrent sessions on the main checkout are never disturbed. A prepared worktree exists at `/home/smudoshi/Github/Medgnosis/.claude/worktrees/feature+cds-vsac-value-sets` on branch `feature/cds-vsac-value-sets` (based on origin/main `d32acf7`). + +```bash +git branch --show-current # MUST print: feature/cds-vsac-value-sets +git rev-parse --show-toplevel # MUST print a path containing .claude/worktrees/ +``` + +If the worktree is gone (fresh execution later), recreate it — do NOT check out branches in the main checkout: + +```bash +cd /home/smudoshi/Github/Medgnosis && git fetch origin +git worktree add .claude/worktrees/feature+cds-vsac-value-sets -b feature/cds-vsac-value-sets origin/main +cd .claude/worktrees/feature+cds-vsac-value-sets +``` + +- [ ] **Step 2: Confirm migration numbering is free** + +```bash +ls packages/db/migrations/ | grep -E '^[0-9]{3}' | sort | tail -3 +git log --all --oneline -- 'packages/db/migrations/039*' 'packages/db/migrations/040*' +``` + +Expected: highest is `038_seed_phase4.sql` and no commits touch 039/040. If 039 or 040 is taken on any branch, renumber every reference to 039→NNN and 040→NNN+1 throughout this plan. + +- [ ] **Step 3: Confirm source data is reachable** + +```bash +psql -h 127.0.0.1 -U claude_dev -d parthenon -At -c \ + "SELECT count(*) FROM app.vsac_value_set_codes;" +``` + +Expected: `225261`. (Collation-mismatch WARNINGs from the parthenon DB are known noise — ignore.) + +--- + +### Task 1: Migration 039 — VSAC Reference Tables + Measure Bridge + +**Files:** +- Create: `packages/db/migrations/039_vsac_value_sets.sql` + +Naming follows Medgnosis house style (singular table names — `condition`, `measure_definition`), so Parthenon's `app.vsac_value_sets` becomes `phm_edw.vsac_value_set`, etc. + +- [ ] **Step 1: Write the migration** + +```sql +-- ============================================================================= +-- 039: VSAC value sets + measure bridge (Parthenon eCQM handoff, steps 1-2) +-- CMS-versioned value sets replace hand-typed code lists. One OID carries +-- thousands of codes across code systems; re-ingesting a new VSAC release +-- updates every measure at once. +-- Source: NLM VSAC via Parthenon ingest (app.vsac_* on this host's parthenon DB). +-- Data loaded by packages/db/scripts/load-vsac.sh — NOT by this migration. +-- ============================================================================= + +CREATE TABLE phm_edw.vsac_value_set ( + value_set_oid VARCHAR(120) PRIMARY KEY, + name VARCHAR(500) NOT NULL, + definition_version VARCHAR(50), + expansion_version VARCHAR(120), + expansion_id VARCHAR(50), + qdm_category VARCHAR(120), + purpose_clinical_focus TEXT, + purpose_data_scope TEXT, + purpose_inclusion TEXT, + purpose_exclusion TEXT, + source_files JSONB NOT NULL DEFAULT '[]'::jsonb, + ingested_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_vsac_vs_name ON phm_edw.vsac_value_set (name); + +COMMENT ON TABLE phm_edw.vsac_value_set IS + 'NLM VSAC value sets (one row per OID). CMS-versioned, authoritative code groupings.'; + +CREATE TABLE phm_edw.vsac_value_set_code ( + id BIGSERIAL PRIMARY KEY, + value_set_oid VARCHAR(120) NOT NULL + REFERENCES phm_edw.vsac_value_set (value_set_oid) ON DELETE CASCADE, + code VARCHAR(100) NOT NULL, + description TEXT, + code_system VARCHAR(80) NOT NULL, + code_system_oid VARCHAR(120), + code_system_version VARCHAR(50), + CONSTRAINT uq_vsac_vsc_oid_code_sys UNIQUE (value_set_oid, code, code_system) +); + +CREATE INDEX idx_vsac_vsc_oid ON phm_edw.vsac_value_set_code (value_set_oid); +CREATE INDEX idx_vsac_vsc_sys_code ON phm_edw.vsac_value_set_code (code_system, code); + +COMMENT ON TABLE phm_edw.vsac_value_set_code IS + 'Flattened VSAC expansions. code_system values: SNOMEDCT, ICD10CM, ICD10PCS, LOINC, RXNORM, CPT, HCPCS Level II, CVX, CDT, ... EDW joins: condition/procedure->SNOMEDCT, medication->RXNORM, observation->LOINC.'; + +CREATE TABLE phm_edw.vsac_measure ( + cms_id VARCHAR(50) PRIMARY KEY, + cbe_number VARCHAR(50), + program_candidate VARCHAR(50), + title VARCHAR(500), + expansion_version VARCHAR(120), + ingested_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE phm_edw.vsac_measure IS + 'CMS eCQM registry rows from the VSAC measure workbooks (e.g. CMS122v14).'; + +CREATE TABLE phm_edw.vsac_measure_value_set ( + cms_id VARCHAR(50) NOT NULL + REFERENCES phm_edw.vsac_measure (cms_id) ON DELETE CASCADE, + value_set_oid VARCHAR(120) NOT NULL + REFERENCES phm_edw.vsac_value_set (value_set_oid) ON DELETE CASCADE, + PRIMARY KEY (cms_id, value_set_oid) +); + +CREATE INDEX idx_vsac_mvs_oid ON phm_edw.vsac_measure_value_set (value_set_oid); + +-- Bridge: local measure definitions -> VSAC value sets. +-- vsac_cms_id records WHICH VSAC measure version supplied the mapping +-- (local CMS122v12 vs VSAC CMS122v14 — version drift is explicit, not hidden). +CREATE TABLE phm_edw.measure_value_set ( + measure_id INT NOT NULL + REFERENCES phm_edw.measure_definition (measure_id), + value_set_oid VARCHAR(120) NOT NULL + REFERENCES phm_edw.vsac_value_set (value_set_oid), + vsac_cms_id VARCHAR(50) NOT NULL, + mapping_method VARCHAR(30) NOT NULL DEFAULT 'cms_base_auto', + created_date TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (measure_id, value_set_oid) +); + +CREATE INDEX idx_mvs_oid ON phm_edw.measure_value_set (value_set_oid); + +COMMENT ON TABLE phm_edw.measure_value_set IS + 'Bridge: measure_definition -> VSAC value-set OIDs, auto-matched on base CMS number (CMS122v12 ~ CMS122v14). mapping_method: cms_base_auto | manual.'; +``` + +- [ ] **Step 2: Run the migration** + +```bash +cd "$(git rev-parse --show-toplevel)/packages/db" && npm run db:migrate +``` + +Expected output includes: `039_vsac_value_sets.sql` applied (and nothing else fails). + +- [ ] **Step 3: Verify the tables exist and are empty** + +```bash +psql -h 127.0.0.1 -U claude_dev -d medgnosis -At -c \ + "SELECT table_name FROM information_schema.tables + WHERE table_schema='phm_edw' AND table_name LIKE 'vsac%' OR (table_schema='phm_edw' AND table_name='measure_value_set') + ORDER BY 1;" +``` + +Expected: `measure_value_set`, `vsac_measure`, `vsac_measure_value_set`, `vsac_value_set`, `vsac_value_set_code`. + +- [ ] **Step 4: Commit** + +```bash +git branch --show-current # verify: feature/cds-vsac-value-sets +git add packages/db/migrations/039_vsac_value_sets.sql +git commit -m "feat: VSAC value set reference tables + measure bridge (migration 039)" +``` + +--- + +### Task 2: VSAC Data Load Script + +**Files:** +- Create: `packages/db/scripts/load-vsac.sh` + +`\copy ... TO STDOUT | \copy ... FROM STDIN` per table — no `pg_dump`+`sed` (schema-rename via sed risks corrupting data rows; explicit column lists can't). FK order: value sets → codes, measures → measure-value-sets. The `id` column of the codes table is regenerated by the destination BIGSERIAL (no setval dance). + +- [ ] **Step 1: Write the script** + +```bash +#!/usr/bin/env bash +# ============================================================================= +# load-vsac.sh — one-shot transfer of VSAC reference data +# parthenon app.vsac_* (plural) -> medgnosis phm_edw.vsac_* (singular) +# then seeds the measure_value_set bridge by base-CMS-number match. +# +# Both DBs live on the same host PG17 instance; auth via ~/.pgpass. +# Refuses to touch non-empty destination tables unless --reload is given. +# ============================================================================= +set -euo pipefail + +SRC_HOST="${VSAC_SRC_HOST:-127.0.0.1}" +SRC_DB="${VSAC_SRC_DB:-parthenon}" +DST_HOST="${VSAC_DST_HOST:-127.0.0.1}" +DST_DB="${VSAC_DST_DB:-medgnosis}" +PGUSER="${PGUSER:-claude_dev}" + +SRC=(psql -h "$SRC_HOST" -U "$PGUSER" -d "$SRC_DB" -v ON_ERROR_STOP=1 -qAt) +DST=(psql -h "$DST_HOST" -U "$PGUSER" -d "$DST_DB" -v ON_ERROR_STOP=1 -qAt) + +existing=$("${DST[@]}" -c "SELECT count(*) FROM phm_edw.vsac_value_set;") +if [[ "$existing" != "0" ]]; then + if [[ "${1:-}" == "--reload" ]]; then + echo "Reloading: truncating phm_edw VSAC tables (bridge included)..." + "${DST[@]}" -c "TRUNCATE phm_edw.measure_value_set, phm_edw.vsac_measure_value_set, + phm_edw.vsac_measure, phm_edw.vsac_value_set_code, phm_edw.vsac_value_set;" + else + echo "ERROR: phm_edw.vsac_value_set already has $existing rows. Re-run with --reload to replace." >&2 + exit 1 + fi +fi + +copy_table() { # $1 src table $2 dst table $3 column list + echo "Copying $1 -> $2 ..." + "${SRC[@]}" -c "\\copy (SELECT $3 FROM $1) TO STDOUT" \ + | "${DST[@]}" -c "\\copy $2 ($3) FROM STDIN" +} + +copy_table app.vsac_value_sets phm_edw.vsac_value_set \ + "value_set_oid, name, definition_version, expansion_version, expansion_id, qdm_category, purpose_clinical_focus, purpose_data_scope, purpose_inclusion, purpose_exclusion, source_files, ingested_at" + +copy_table app.vsac_value_set_codes phm_edw.vsac_value_set_code \ + "value_set_oid, code, description, code_system, code_system_oid, code_system_version" + +copy_table app.vsac_measures phm_edw.vsac_measure \ + "cms_id, cbe_number, program_candidate, title, expansion_version, ingested_at" + +copy_table app.vsac_measure_value_sets phm_edw.vsac_measure_value_set \ + "cms_id, value_set_oid" + +echo "Seeding measure_value_set bridge (base CMS number match)..." +"${DST[@]}" <<'SQL' +INSERT INTO phm_edw.measure_value_set (measure_id, value_set_oid, vsac_cms_id, mapping_method) +SELECT md.measure_id, mvs.value_set_oid, vm.cms_id, 'cms_base_auto' +FROM phm_edw.measure_definition md +JOIN phm_edw.vsac_measure vm + ON regexp_replace(md.measure_code, 'v[0-9]+$', '') + = regexp_replace(vm.cms_id, 'v[0-9]+$', '') +JOIN phm_edw.vsac_measure_value_set mvs ON mvs.cms_id = vm.cms_id +WHERE md.measure_code ~ '^CMS' AND md.active_ind = 'Y' +ON CONFLICT (measure_id, value_set_oid) DO NOTHING; +SQL + +echo "--- Verification ---" +"${DST[@]}" <<'SQL' +SELECT 'vsac_value_set expect 1545 got ' || count(*) FROM phm_edw.vsac_value_set; +SELECT 'vsac_value_set_code expect 225261 got ' || count(*) FROM phm_edw.vsac_value_set_code; +SELECT 'vsac_measure expect 72 got ' || count(*) FROM phm_edw.vsac_measure; +SELECT 'vsac_measure_value_set expect 1597 got ' || count(*) FROM phm_edw.vsac_measure_value_set; +SELECT 'bridged measures expect 44 got ' || count(DISTINCT measure_id) FROM phm_edw.measure_value_set; +SELECT 'unbridged CMS measures (expect CMS249v6 only): ' + || coalesce(string_agg(measure_code, ', '), '(none)') +FROM phm_edw.measure_definition md +WHERE md.measure_code ~ '^CMS' AND md.active_ind = 'Y' + AND NOT EXISTS (SELECT 1 FROM phm_edw.measure_value_set b WHERE b.measure_id = md.measure_id); +SQL +echo "Done." +``` + +- [ ] **Step 2: Make it executable and run it** + +```bash +cd "$(git rev-parse --show-toplevel)" +chmod +x packages/db/scripts/load-vsac.sh +./packages/db/scripts/load-vsac.sh +``` + +Expected verification block: +- `vsac_value_set` 1545, `vsac_value_set_code` 225261, `vsac_measure` 72, `vsac_measure_value_set` 1597 +- `bridged measures` 44 +- unbridged list: exactly `CMS249v6` + +- [ ] **Step 3: EDW joinability spot checks (handoff §8: prove VSAC codes actually hit real EDW codes)** + +```bash +psql -h 127.0.0.1 -U claude_dev -d medgnosis -At <<'EOF' +SELECT 'conditions hit: ' || count(DISTINCT c.condition_code) +FROM phm_edw.condition c +JOIN phm_edw.vsac_value_set_code vc + ON vc.code = c.condition_code AND vc.code_system = 'SNOMEDCT'; +SELECT 'medications hit: ' || count(DISTINCT m.medication_code) +FROM phm_edw.medication m +JOIN phm_edw.vsac_value_set_code vc + ON vc.code = m.medication_code AND vc.code_system = 'RXNORM'; +SELECT 'observations hit: ' || count(DISTINCT o.observation_code) +FROM phm_edw.observation o +JOIN phm_edw.vsac_value_set_code vc + ON vc.code = o.observation_code AND vc.code_system = 'LOINC'; +EOF +``` + +Expected: every count > 0. Record the actual numbers in the commit message. If any is 0, STOP — the code-format assumption broke; investigate before continuing. + +- [ ] **Step 4: Spot-check one OID against the VSAC website (handoff §8)** + +Pick the Diabetes value set bridged to CMS122: + +```bash +psql -h 127.0.0.1 -U claude_dev -d medgnosis -At -c \ + "SELECT vs.value_set_oid, vs.name, count(*) + FROM phm_edw.measure_value_set mv + JOIN phm_edw.measure_definition md ON md.measure_id = mv.measure_id + JOIN phm_edw.vsac_value_set vs ON vs.value_set_oid = mv.value_set_oid + JOIN phm_edw.vsac_value_set_code vc ON vc.value_set_oid = vs.value_set_oid + WHERE md.measure_code = 'CMS122v12' AND vs.name ILIKE '%diabetes%' + GROUP BY 1,2 ORDER BY 3 DESC LIMIT 3;" +``` + +Manually confirm one OID + code count at https://vsac.nlm.nih.gov (requires UMLS login; if no login available, diff the counts against the parthenon source instead — they must be identical). + +- [ ] **Step 5: Commit** + +```bash +git branch --show-current # verify: feature/cds-vsac-value-sets +git add packages/db/scripts/load-vsac.sh +git commit -m "feat: VSAC data load script (parthenon -> medgnosis, 225k codes + bridge seed)" +``` + +--- + +### Task 3: Wilson CI Utility (TDD) + +**Files:** +- Create: `apps/api/src/services/wilsonCI.ts` +- Test: `apps/api/src/services/__tests__/wilsonCI.test.ts` + +Pure math — real TDD, no mocks. Wilson score interval: panels here are small (hundreds, not Parthenon's 100k floor), so we always SHOW the CI rather than gating on population size. + +- [ ] **Step 1: Write the failing test** + +```typescript +// ============================================================================= +// 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); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd "$(git rev-parse --show-toplevel)/apps/api" +npm run test -- src/services/__tests__/wilsonCI.test.ts +``` + +Expected: FAIL — `Cannot find module '../wilsonCI.js'`. + +- [ ] **Step 3: Write the implementation** + +```typescript +// ============================================================================= +// Wilson score interval for a binomial proportion (95% by default). +// Preferred over the normal approximation for the small panels Medgnosis +// serves — it never produces bounds outside [0, 1] and behaves at p near 0/1. +// ============================================================================= + +export interface WilsonInterval { + lower: number; + upper: number; +} + +export function wilsonCI(numerator: number, denominator: number, z = 1.96): WilsonInterval { + if (denominator <= 0) { + return { lower: 0, upper: 0 }; + } + const p = numerator / denominator; + const z2 = z * z; + const factor = 1 + z2 / denominator; + const center = (p + z2 / (2 * denominator)) / factor; + const half = + (z * Math.sqrt((p * (1 - p)) / denominator + z2 / (4 * denominator * denominator))) / factor; + return { + lower: Math.max(0, center - half), + upper: Math.min(1, center + half), + }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +npm run test -- src/services/__tests__/wilsonCI.test.ts +``` + +Expected: 5 passed. + +- [ ] **Step 5: Commit** + +```bash +git branch --show-current +git add apps/api/src/services/wilsonCI.ts apps/api/src/services/__tests__/wilsonCI.test.ts +git commit -m "feat: Wilson 95% CI utility for measure rates" +``` + +--- + +### Task 4: VSAC Service (TDD, mocked DB) + +**Files:** +- Create: `apps/api/src/services/vsacService.ts` +- Test: `apps/api/src/services/__tests__/vsacService.test.ts` + +Mock style copied from `rulesEngine.test.ts` (`vi.hoisted` + `vi.mock('@medgnosis/db')`). + +- [ ] **Step 1: Write the failing test** + +```typescript +// ============================================================================= +// Unit tests — VSAC value set service +// ============================================================================= + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +type SqlRow = Record; + +const { mockSql } = vi.hoisted(() => { + const fn = vi.fn<(strings: TemplateStringsArray, ...values: unknown[]) => Promise>(); + 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', () => { + // NOTE: call WITHOUT codeSystem here. With it, the nested sql`` fragment + // fires an extra mock call that consumes mockResolvedValueOnce before the + // outer query runs — the mock can't distinguish fragments from queries. + 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([]); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd "$(git rev-parse --show-toplevel)/apps/api" +npm run test -- src/services/__tests__/vsacService.test.ts +``` + +Expected: FAIL — `Cannot find module '../vsacService.js'`. + +- [ ] **Step 3: Write the implementation** + +```typescript +// ============================================================================= +// Medgnosis API — VSAC value set service +// Reads phm_edw.vsac_* reference tables and the measure_value_set bridge. +// resolveMeasureCodes() is the workhorse: every code of one code system across +// all value sets bridged to a measure — what evaluators and the population +// finder consume instead of hand-typed code lists. +// ============================================================================= + +import { sql } from '@medgnosis/db'; + +// phm_edw code-column reality (verified 2026-06-12): condition and procedure +// are SNOMED-coded — the Parthenon handoff's ICD-10/CPT routing does not apply. +export const EDW_CODE_SYSTEM = { + condition: 'SNOMEDCT', + procedure: 'SNOMEDCT', + medication: 'RXNORM', + observation: 'LOINC', +} as const; + +export type EdwDomain = keyof typeof EDW_CODE_SYSTEM; + +export interface ValueSetSummary { + value_set_oid: string; + name: string; + qdm_category: string | null; + code_count: number; +} + +export interface ValueSetCode { + code: string; + description: string | null; + code_system: string; +} + +export interface MeasureValueSet { + value_set_oid: string; + name: string; + vsac_cms_id: string; + qdm_category: string | null; + code_count: number; +} + +export async function listValueSets(search?: string): Promise { + return sql` + SELECT + vs.value_set_oid, + vs.name, + vs.qdm_category, + COUNT(vc.id)::int AS code_count + FROM phm_edw.vsac_value_set vs + LEFT JOIN phm_edw.vsac_value_set_code vc ON vc.value_set_oid = vs.value_set_oid + ${search ? sql`WHERE vs.name ILIKE ${'%' + search + '%'}` : sql``} + GROUP BY vs.value_set_oid, vs.name, vs.qdm_category + ORDER BY vs.name + `; +} + +export async function getValueSetCodes( + oid: string, + codeSystem?: string, +): Promise { + return sql` + SELECT vc.code, vc.description, vc.code_system + FROM phm_edw.vsac_value_set_code vc + WHERE vc.value_set_oid = ${oid} + ${codeSystem ? sql`AND vc.code_system = ${codeSystem}` : sql``} + ORDER BY vc.code_system, vc.code + `; +} + +export async function getMeasureValueSets(measureCode: string): Promise { + return sql` + SELECT + vs.value_set_oid, + vs.name, + mv.vsac_cms_id, + vs.qdm_category, + COUNT(vc.id)::int AS code_count + FROM phm_edw.measure_value_set mv + JOIN phm_edw.measure_definition md ON md.measure_id = mv.measure_id + JOIN phm_edw.vsac_value_set vs ON vs.value_set_oid = mv.value_set_oid + LEFT JOIN phm_edw.vsac_value_set_code vc ON vc.value_set_oid = vs.value_set_oid + WHERE md.measure_code = ${measureCode} + GROUP BY vs.value_set_oid, vs.name, mv.vsac_cms_id, vs.qdm_category + ORDER BY vs.name + `; +} + +export async function resolveMeasureCodes( + measureCode: string, + codeSystem: string, +): Promise { + const rows = await sql<{ code: string }[]>` + SELECT DISTINCT vc.code + FROM phm_edw.measure_value_set mv + JOIN phm_edw.measure_definition md ON md.measure_id = mv.measure_id + JOIN phm_edw.vsac_value_set_code vc ON vc.value_set_oid = mv.value_set_oid + WHERE md.measure_code = ${measureCode} + AND vc.code_system = ${codeSystem} + ORDER BY vc.code + `; + return rows.map((r) => r.code); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +npm run test -- src/services/__tests__/vsacService.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git branch --show-current +git add apps/api/src/services/vsacService.ts apps/api/src/services/__tests__/vsacService.test.ts +git commit -m "feat: VSAC service — value set queries + measure code resolution" +``` + +--- + +### Task 5: Value Sets Transparency Routes + +**Files:** +- Create: `apps/api/src/routes/value-sets/index.ts` +- Modify: `apps/api/src/routes/index.ts` + +Mirrors the `/rules` transparency pattern ("transparency → trust"). + +- [ ] **Step 1: Write the route file** + +```typescript +// ============================================================================= +// 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 { + 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 }); + }); +} +``` + +- [ ] **Step 2: Register the route** + +In `apps/api/src/routes/index.ts`, add the import after line 22 (`import rulesRoutes ...`): + +```typescript +import valueSetRoutes from './value-sets/index.js'; +``` + +and inside the versioned-API register block, after the `rulesRoutes` line: + +```typescript + await api.register(valueSetRoutes, { prefix: '/value-sets' }); +``` + +- [ ] **Step 3: Typecheck and smoke-test** + +```bash +cd "$(git rev-parse --show-toplevel)/apps/api" && npx tsc --noEmit +``` + +Expected: clean. Then verify against the running API (adjust port to the running instance; auth uses the superuser test account — see project memory `reference_admin_credentials`): + +```bash +# from repo root — token shape is data.tokens (snake_case) +TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@acumenus.net","password":""}' \ + | jq -r '.data.tokens.access_token') +curl -s "http://localhost:3001/api/v1/value-sets?search=diabetes" -H "Authorization: Bearer $TOKEN" | jq '.data | length' +curl -s "http://localhost:3001/api/v1/value-sets/measure/CMS122v12" -H "Authorization: Bearer $TOKEN" | jq '.data | length' +``` + +Expected: both > 0. + +- [ ] **Step 4: Commit** + +```bash +git branch --show-current +git add apps/api/src/routes/value-sets/index.ts apps/api/src/routes/index.ts +git commit -m "feat: value-sets transparency endpoints (/value-sets, /measure/:code, /:oid/codes)" +``` + +--- + +### Task 6: Measure Strata — Migration 040 + GROUPING SETS + Wilson CIs + +**Files:** +- Create: `packages/db/migrations/040_measure_strata.sql` +- Modify: `apps/api/src/services/measureCalculatorV2.ts` + +Single-pass GROUPING SETS (handoff §5.1): classify each (patient, measure) row once, produce headline + age + sex strata in one scan, inside the SAME refresh transaction so facts and strata can never diverge. + +- [ ] **Step 1: Write migration 040** + +```sql +-- ============================================================================= +-- 040: Measure stratification facts (CDS parity — calculator hardening) +-- Populated by measureCalculatorV2 in the same transaction as +-- fact_measure_result, via single-pass GROUPING SETS (one scan -> headline +-- 'all' row + age_band strata + gender strata per measure). +-- ============================================================================= + +CREATE TABLE phm_star.fact_measure_strata ( + strata_key SERIAL PRIMARY KEY, + measure_key INT NOT NULL, + date_key_period INT, + dimension VARCHAR(20) NOT NULL, -- 'all' | 'age_band' | 'gender' + stratum VARCHAR(50) NOT NULL, -- 'all' | '<18' | '18-39' | '40-64' | '65+' | gender values + denominator INT NOT NULL DEFAULT 0, + numerator INT NOT NULL DEFAULT 0, + excluded INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_fms_measure ON phm_star.fact_measure_strata (measure_key, dimension); + +COMMENT ON TABLE phm_star.fact_measure_strata IS + 'Per-measure strata (eCQM accounting: excluded removed from denominator AND numerator). Rebuilt with fact_measure_result each refresh.'; +``` + +- [ ] **Step 2: Run the migration** + +```bash +cd "$(git rev-parse --show-toplevel)/packages/db" && npm run db:migrate +``` + +Expected: `040_measure_strata.sql` applied. + +- [ ] **Step 3: Extend the refresh transaction and add CIs to the summary** + +Replace the full contents of `apps/api/src/services/measureCalculatorV2.ts` with: + +```typescript +// ============================================================================= +// Medgnosis API — Measure Calculator v2 +// Aggregates fact_patient_bundle_detail → fact_measure_result + strata. +// Replaces the old measureEngine.ts (45 broken SQL files). +// +// eCQM accounting (CMS semantics — regression-gated, do not weaken): +// denominator = gap_status IN ('open','closed') — excluded NOT in denom +// numerator = gap_status = 'closed' — subset of denominator +// excluded = gap_status = 'excluded' — in NEITHER denom NOR numer +// ============================================================================= + +import { sql } from '@medgnosis/db'; +import { wilsonCI } from './wilsonCI.js'; + +export interface RefreshResult { + rowCount: number; + durationMs: number; +} + +export interface MeasureSummaryRow { + measure_key: number; + measure_code: string; + measure_name: string; + eligible: number; + met: number; + excluded: number; + performance_rate: number | null; + ci_lower: number | null; + ci_upper: number | null; +} + +/** + * Refresh fact_measure_result AND fact_measure_strata in one transaction — + * a failed INSERT rolls back both TRUNCATEs; facts and strata never diverge. + * SET LOCAL scopes the statement timeout to the transaction — no pool leak. + */ +export async function refreshMeasureResults(): Promise { + const t0 = performance.now(); + + const result = await sql.begin(async (tx) => { + await tx.unsafe("SET LOCAL statement_timeout = '60s'"); + await tx.unsafe('TRUNCATE phm_star.fact_measure_result'); + const inserted = await tx.unsafe(` + INSERT INTO phm_star.fact_measure_result + (patient_key, measure_key, date_key_period, + denominator_flag, numerator_flag, exclusion_flag, + measure_value, count_measure) + SELECT + d.patient_key, + d.measure_key, + (SELECT date_key FROM phm_star.dim_date WHERE full_date = CURRENT_DATE), + LOWER(d.gap_status) IN ('open', 'closed'), + LOWER(d.gap_status) = 'closed', + LOWER(d.gap_status) = 'excluded', + NULL, + 1 + FROM phm_star.fact_patient_bundle_detail d + `); + + // Single-pass stratification: GROUPING(a, b) sets a bit per UN-grouped + // column, so () -> 3 = headline, (age_band) -> 1, (gender) -> 2. + await tx.unsafe('TRUNCATE phm_star.fact_measure_strata'); + await tx.unsafe(` + INSERT INTO phm_star.fact_measure_strata + (measure_key, date_key_period, dimension, stratum, + denominator, numerator, excluded) + SELECT + c.measure_key, + c.date_key_period, + CASE GROUPING(c.age_band, c.gender) + WHEN 3 THEN 'all' + WHEN 1 THEN 'age_band' + WHEN 2 THEN 'gender' + END, + CASE GROUPING(c.age_band, c.gender) + WHEN 3 THEN 'all' + WHEN 1 THEN c.age_band + WHEN 2 THEN c.gender + END, + COUNT(*) FILTER (WHERE c.denominator_flag)::int, + COUNT(*) FILTER (WHERE c.numerator_flag)::int, + COUNT(*) FILTER (WHERE c.exclusion_flag)::int + FROM ( + SELECT + fmr.measure_key, + fmr.date_key_period, + CASE + WHEN dp.date_of_birth IS NULL THEN 'unknown' + WHEN dp.date_of_birth > CURRENT_DATE - INTERVAL '18 years' THEN '<18' + WHEN dp.date_of_birth > CURRENT_DATE - INTERVAL '40 years' THEN '18-39' + WHEN dp.date_of_birth > CURRENT_DATE - INTERVAL '65 years' THEN '40-64' + ELSE '65+' + END AS age_band, + COALESCE(NULLIF(TRIM(dp.gender), ''), 'unknown') AS gender, + fmr.denominator_flag, + fmr.numerator_flag, + fmr.exclusion_flag + FROM phm_star.fact_measure_result fmr + JOIN phm_star.dim_patient dp + ON dp.patient_key = fmr.patient_key AND dp.is_current + ) c + GROUP BY GROUPING SETS ( + (c.measure_key, c.date_key_period), + (c.measure_key, c.date_key_period, c.age_band), + (c.measure_key, c.date_key_period, c.gender) + ) + `); + + return inserted; + }); + + const durationMs = Math.round(performance.now() - t0); + const rowCount = result.count ?? 0; + + console.info(`[measure-calc-v2] Refreshed fact_measure_result: ${rowCount} rows in ${durationMs}ms`); + return { rowCount, durationMs }; +} + +/** + * Per-measure performance summary with Wilson 95% CIs (percent, 1 decimal). + * Small panels always show the interval — never gate on population size. + */ +export async function getMeasureSummary(): Promise { + const rows = await sql[]>` + SELECT + dm.measure_key, + dm.measure_code, + dm.measure_name, + COUNT(*) FILTER (WHERE fmr.denominator_flag)::int AS eligible, + COUNT(*) FILTER (WHERE fmr.numerator_flag)::int AS met, + COUNT(*) FILTER (WHERE fmr.exclusion_flag)::int AS excluded, + ROUND( + COUNT(*) FILTER (WHERE fmr.numerator_flag)::numeric / + NULLIF(COUNT(*) FILTER (WHERE fmr.denominator_flag), 0) * 100, 1 + ) AS performance_rate + FROM phm_star.fact_measure_result fmr + JOIN phm_star.dim_measure dm ON dm.measure_key = fmr.measure_key + GROUP BY dm.measure_key, dm.measure_code, dm.measure_name + ORDER BY dm.measure_code + `; + + return rows.map((row) => { + if (row.eligible <= 0) { + return { ...row, ci_lower: null, ci_upper: null }; + } + const ci = wilsonCI(row.met, row.eligible); + return { + ...row, + ci_lower: Math.round(ci.lower * 1000) / 10, + ci_upper: Math.round(ci.upper * 1000) / 10, + }; + }); +} +``` + +- [ ] **Step 4: Typecheck** + +```bash +cd "$(git rev-parse --show-toplevel)/apps/api" && npx tsc --noEmit +``` + +Expected: clean. (House note: changing `MeasureSummaryRow` requires updating any test asserting the old shape — as of writing there are none; verify with `grep -rn getMeasureSummary apps/api/src --include='*.test.ts'`.) + +- [ ] **Step 5: Run the refresh against the live DB and verify** + +```bash +cd "$(git rev-parse --show-toplevel)/apps/api" +# postgres.js does NOT read ~/.pgpass — extract the password into PGPASSWORD +# (pgpass line format: host:port:db:user:password; claude_dev uses a wildcard entry) +PGPASSWORD="$(awk -F: '$4=="claude_dev" {print $5; exit}' ~/.pgpass)" \ +DATABASE_URL="postgres://claude_dev@127.0.0.1:5432/medgnosis" npx tsx -e " +import('./src/services/measureCalculatorV2.ts').then(async (m) => { + console.log(await m.refreshMeasureResults()); + process.exit(0); +});" +``` + +(Never paste the password itself into any committed file or shell history; the `awk` extraction keeps it out of both.) + +Then verify strata + eCQM accounting (handoff §8 exclusion test): + +```bash +psql -h 127.0.0.1 -U claude_dev -d medgnosis -At <<'EOF' +-- 1. Strata exist for all three dimensions +SELECT 'dimensions: ' || string_agg(DISTINCT dimension, ', ' ORDER BY dimension) FROM phm_star.fact_measure_strata; +-- 2. REGRESSION GATE: no row is simultaneously excluded and in denom/numer +SELECT 'violations (expect 0): ' || count(*) FROM phm_star.fact_measure_result +WHERE exclusion_flag AND (denominator_flag OR numerator_flag); +-- 3. Headline strata reconcile with the fact table for every measure +SELECT 'mismatches (expect 0): ' || count(*) FROM ( + SELECT s.measure_key + FROM phm_star.fact_measure_strata s + JOIN ( + SELECT measure_key, + COUNT(*) FILTER (WHERE denominator_flag)::int AS denom, + COUNT(*) FILTER (WHERE numerator_flag)::int AS numer, + COUNT(*) FILTER (WHERE exclusion_flag)::int AS excl + FROM phm_star.fact_measure_result GROUP BY measure_key + ) f ON f.measure_key = s.measure_key + WHERE s.dimension = 'all' + AND (s.denominator <> f.denom OR s.numerator <> f.numer OR s.excluded <> f.excl) +) x; +EOF +``` + +Expected: `dimensions: age_band, all, gender`, `violations (expect 0): 0`, `mismatches (expect 0): 0`. Also note the refresh `durationMs` against pre-change runs — GROUPING SETS adds one scan of the just-built fact table; nightly runtime must not regress materially (handoff §8). + +- [ ] **Step 6: Commit** + +```bash +git branch --show-current +git add packages/db/migrations/040_measure_strata.sql apps/api/src/services/measureCalculatorV2.ts +git commit -m "feat: single-pass GROUPING SETS measure strata + Wilson CIs (migration 040)" +``` + +--- + +### Task 7: Strata API Endpoint + +**Files:** +- Modify: `apps/api/src/routes/measures/index.ts` + +- [ ] **Step 1: Add the endpoint** + +In `apps/api/src/routes/measures/index.ts`, add to the imports (top of file): + +```typescript +import { wilsonCI } from '../../services/wilsonCI.js'; +``` + +and add this route inside `measureRoutes` after the existing `GET /:id` handler: + +```typescript + // 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 }); + }); +``` + +- [ ] **Step 2: Typecheck and smoke-test** + +```bash +cd "$(git rev-parse --show-toplevel)/apps/api" && npx tsc --noEmit +``` + +Then (reusing `$TOKEN` from Task 5, with a known measure_definition id, e.g. 91 = CMS22v12): + +```bash +curl -s "http://localhost:3001/api/v1/measures/91/strata" -H "Authorization: Bearer $TOKEN" | jq '.data[] | select(.dimension=="all")' +``` + +Expected: one `all` row with `rate`, `ci_lower`, `ci_upper` populated. (404 here means that measure has no rows in `dim_measure`/`fact_measure_result` — try another id from `GET /measures`.) + +- [ ] **Step 3: Commit** + +```bash +git branch --show-current +git add apps/api/src/routes/measures/index.ts +git commit -m "feat: GET /measures/:id/strata — stratified rates with Wilson CIs" +``` + +--- + +### Task 8: MeasureEvaluator Interface Seam (TDD) + +**Files:** +- Create: `apps/api/src/services/measureEvaluator.ts` +- Test: `apps/api/src/services/__tests__/measureEvaluator.test.ts` +- Modify: `apps/api/src/workers/measure-calculator.ts` +- Modify: `apps/api/src/routes/admin/index.ts` +- Modify: `.env.example` + +Handoff §6.3: identical signature for SQL today and CQL later, no schema change. The CQL placeholder throws an actionable error at evaluation time, not at boot. + +- [ ] **Step 1: Write the failing test** + +```typescript +// ============================================================================= +// 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/); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd "$(git rev-parse --show-toplevel)/apps/api" +npm run test -- src/services/__tests__/measureEvaluator.test.ts +``` + +Expected: FAIL — `Cannot find module '../measureEvaluator.js'`. + +- [ ] **Step 3: Write the implementation** + +```typescript +// ============================================================================= +// Medgnosis API — MeasureEvaluator seam +// One signature, swappable engines: SQL aggregation today, a CQL/cqf-ruler +// bridge later — no schema change, no caller change (Parthenon pattern, see +// docs/superpowers/specs/2026-06-12-parthenon-ecqm-handoff.md §6.3). +// Selected via MEASURE_EVALUATOR env var; defaults to 'sql'. +// ============================================================================= + +import { refreshMeasureResults, type RefreshResult } from './measureCalculatorV2.js'; + +export type MeasureEvaluatorKind = 'sql' | 'cql'; + +export interface MeasureEvaluator { + readonly kind: MeasureEvaluatorKind; + refresh(): Promise; +} + +export const sqlMeasureEvaluator: MeasureEvaluator = { + kind: 'sql', + refresh: refreshMeasureResults, +}; + +export const cqlMeasureEvaluator: MeasureEvaluator = { + kind: 'cql', + refresh: async () => { + // Intentional placeholder: fails at evaluation time with a pointer, not at boot. + throw new Error( + 'CQL evaluator not implemented. Set MEASURE_EVALUATOR=sql, or implement the ' + + 'cqf-ruler bridge per docs/superpowers/specs/2026-06-12-parthenon-ecqm-handoff.md §6.3.', + ); + }, +}; + +export function getMeasureEvaluator(): MeasureEvaluator { + const kind = process.env['MEASURE_EVALUATOR'] ?? 'sql'; + switch (kind) { + case 'sql': + return sqlMeasureEvaluator; + case 'cql': + return cqlMeasureEvaluator; + default: + throw new Error(`Unknown MEASURE_EVALUATOR "${kind}" — expected "sql" or "cql"`); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +npm run test -- src/services/__tests__/measureEvaluator.test.ts +``` + +Expected: 5 passed. + +- [ ] **Step 5: Switch the call sites to the seam** + +In `apps/api/src/workers/measure-calculator.ts`, replace + +```typescript +import { refreshMeasureResults } from '../services/measureCalculatorV2.js'; +``` + +with + +```typescript +import { getMeasureEvaluator } from '../services/measureEvaluator.js'; +``` + +and in `processMeasureJob`, replace + +```typescript + const result = await refreshMeasureResults(); +``` + +with + +```typescript + const evaluator = getMeasureEvaluator(); + console.info(`[measure-calc] evaluator: ${evaluator.kind}`); + const result = await evaluator.refresh(); +``` + +In `apps/api/src/routes/admin/index.ts`, replace the import (line 19) + +```typescript +import { refreshMeasureResults } from '../../services/measureCalculatorV2.js'; +``` + +with + +```typescript +import { getMeasureEvaluator } from '../../services/measureEvaluator.js'; +``` + +and BOTH call sites (`await refreshMeasureResults();` near line 383, and `const result = await refreshMeasureResults();` near line 399) with `await getMeasureEvaluator().refresh();` / `const result = await getMeasureEvaluator().refresh();` respectively. + +- [ ] **Step 6: Add the env var to `.env.example`** + +Append to `.env.example` at the repo root (of the worktree): + +``` +# Measure evaluation engine: sql (star-schema aggregation) | cql (future cqf-ruler bridge) +MEASURE_EVALUATOR=sql +``` + +- [ ] **Step 7: Full check + commit** + +```bash +cd "$(git rev-parse --show-toplevel)/apps/api" +npx tsc --noEmit && npm run test +``` + +Expected: typecheck clean, full suite green. + +```bash +git branch --show-current +git add apps/api/src/services/measureEvaluator.ts \ + apps/api/src/services/__tests__/measureEvaluator.test.ts \ + apps/api/src/workers/measure-calculator.ts \ + apps/api/src/routes/admin/index.ts \ + .env.example +git commit -m "feat: MeasureEvaluator seam — swappable sql/cql engines behind one interface" +``` + +--- + +### Task 9: Final Verification (handoff §8 checklist) + +**Files:** none — verification only. Evidence before assertions: run every command and record output before claiming done. + +- [ ] **Step 1: Row counts match source** + +```bash +psql -h 127.0.0.1 -U claude_dev -d medgnosis -At -c \ + "SELECT (SELECT count(*) FROM phm_edw.vsac_value_set) || '/' || + (SELECT count(*) FROM phm_edw.vsac_value_set_code) || '/' || + (SELECT count(*) FROM phm_edw.vsac_measure) || '/' || + (SELECT count(*) FROM phm_edw.vsac_measure_value_set);" +``` + +Expected: `1545/225261/72/1597`. + +- [ ] **Step 2: Exclusion semantics regression gate** — re-run verification query 2 from Task 6 Step 5; expect 0 violations. + +- [ ] **Step 3: Full test suite + typecheck** + +```bash +cd "$(git rev-parse --show-toplevel)/apps/api" && npx tsc --noEmit && npm run test +``` + +- [ ] **Step 4: Nightly job runtime not regressed** — compare the `durationMs` logged in Task 6 Step 5 against a pre-change baseline (re-run the refresh twice; second run is the steady-state number). Strata adds one scan of the freshly built fact table (~27k rows) — expect single-digit ms impact. + +- [ ] **Step 5: Worker still functions end-to-end** — trigger a manual refresh through the admin endpoint and confirm the audit log row: + +```bash +curl -s -X POST "http://localhost:3001/api/v1/admin/refresh-measures" -H "Authorization: Bearer $TOKEN" | jq +psql -h 127.0.0.1 -U claude_dev -d medgnosis -At -c \ + "SELECT action, details FROM public.audit_log WHERE action='measure_refresh' ORDER BY 1 DESC LIMIT 1;" +``` + +- [ ] **Step 6: Push and hand off** + +```bash +git branch --show-current # verify: feature/cds-vsac-value-sets +git push -u origin feature/cds-vsac-value-sets +``` + +Then follow superpowers:finishing-a-development-branch (merge/PR decision). Note for the PR body: migrations 039/040 are additive; the VSAC data load is a one-time script run, required once per environment (`packages/db/scripts/load-vsac.sh`); prod deploys need a reachable source DB or a portable dump (`pg_dump --data-only` of the four `phm_edw.vsac_*` tables from a loaded environment). + +--- + +## Deferred (do NOT build in this plan) + +| Handoff item | Why deferred | Where it lands | +|---|---|---| +| §4.2 run-versioning (`measure_run`, `measure_person_status`) | Needs design alignment with Phase 2's two-pass population finder and Phase 7's Cohort Manager — both want the same snapshot layer | Phase 2 / Phase 7 plans | +| §7 step 6 FHIR `Measure` export | Interop polish; no consumer yet | Future interop plan | +| `clinical_rule` CSV→OID migration of bundle inclusion criteria | The 45 condition bundles' eligibility lives in ETL/demo data today, not in `clinical_rule` value sets — there is no live CSV-code execution path to migrate yet. The bridge + `resolveMeasureCodes()` make OID-resolution available the moment Phase 2's population finder needs it | Phase 2 plan (population finder consumes `resolveMeasureCodes`) | +| §5.2 temp-table person-set materialization | Applies to evaluators that scan clinical tables per measure. The current calculator aggregates a pre-built 27k-row fact table — nothing to materialize. Adopt when the population finder or a real SQL evaluator computes person-sets from `phm_edw` clinical tables (remember Parthenon's lesson: explicit `DROP TABLE IF EXISTS` between measures, don't trust `ON COMMIT DROP` mid-transaction) | Phase 2 population finder / future evaluator | +| OMOP `concept_ancestor` descendant expansion | Medgnosis has no OMOP vocab; VSAC expansions are pre-flattened | Never (by design) | +| Parthenon's `MAX(date_column)` reporting anchor | Medgnosis is live-operational; anchor to `CURRENT_DATE`/explicit periods | Never (by design) | diff --git a/docs/superpowers/specs/2026-06-12-parthenon-ecqm-handoff.md b/docs/superpowers/specs/2026-06-12-parthenon-ecqm-handoff.md new file mode 100644 index 0000000..031f245 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-parthenon-ecqm-handoff.md @@ -0,0 +1,224 @@ +# Handoff: Incorporating Parthenon's eCQM Infrastructure into Medgnosis + +**Date:** 2026-06-12 +**From:** Claude Code session in `/home/smudoshi/Github/Parthenon` +**To:** Agent working in `/home/smudoshi/Github/Medgnosis` +**Status:** Reference + incorporation guide (not an executable plan — write your own per `superpowers:writing-plans` when you pick this up) + +--- + +## 1. TL;DR + +Parthenon (Laravel 11 / OMOP CDM) has a production eCQM stack built around **VSAC value sets**, **JSONB-defined quality measures**, **versioned population runs**, and a **swappable measure-evaluator interface**. Medgnosis already has its own measure calculator (48 CMS eCQMs, `fact_measure_result`, `measureCalculatorV2.ts`), so this is **not a code port**. The valuable imports are: + +1. **The VSAC data asset** — 1,545 value sets / 225,261 codes / 72 CMS measure definitions, already ingested and sitting in Parthenon's PostgreSQL (`app.vsac_*` tables on host PG17). This replaces Medgnosis's hand-maintained CSV inclusion codes in `clinical_rule` with authoritative, versioned CMS value sets. +2. **The schema design** for value sets, measure criteria, and versioned measure runs (auditable, person-level drill-down). +3. **Two SQL techniques**: single-pass GROUPING SETS stratification, and temp-table person-set materialization. +4. **The evaluator-interface pattern** that lets a future CQL engine drop in without schema change. + +Do **not** port: Laravel/Eloquent code, the OMOP `concept_ancestor` descendant expansion (Medgnosis has no OMOP vocab — VSAC expansions are pre-flattened anyway), or Parthenon's data-relative reporting anchor (see §6.2). + +--- + +## 2. What Parthenon's eCQM Stack Is + +Module name in Parthenon: **Care Bundles / Care Gaps** ("condition bundle" = disease framework, e.g. CKD; each bundle carries N quality measures). Flow: + +``` +VSAC workbooks ──ingest──▶ app.vsac_* tables ──crosswalk──▶ OMOP concept_ids + │ +condition_bundles ──qualify population──▶ care_bundle_runs │ + │ │ ▼ +bundle_measures ──▶ quality_measures ──evaluate──▶ per-person numer/excl flags + (M2M junction) (JSONB criteria) + GROUPING SETS strata + + aggregate rates + │ + FHIR Measure export / UI tiers +``` + +### Key source files (all under `/home/smudoshi/Github/Parthenon/`) + +| Concern | Path | +|---|---| +| Evaluator contract (interface) | `backend/app/Services/CareBundles/CareBundleMeasureEvaluator.php` | +| SQL evaluator (the workhorse) | `backend/app/Services/CareBundles/Evaluators/CohortBasedMeasureEvaluator.php` | +| CQL evaluator (Phase 3b placeholder) | `backend/app/Services/CareBundles/Evaluators/CqlMeasureEvaluator.php` | +| Config (evaluator binding, CQL engine URL, min population) | `backend/config/care_bundles.php` | +| Measure model (JSONB criteria casts) | `backend/app/Models/App/QualityMeasure.php` | +| VSAC ingest script (Python, psycopg2 + openpyxl) | `scripts/importers/ingest_vsac.py` | +| VSAC→OMOP crosswalk (materialized view) | `backend/database/migrations/2026_04_24_000500_create_vsac_omop_crosswalk_view.php` | +| Core tables migration | `backend/database/migrations/2026_03_02_100000_create_care_bundles_tables.php` | +| Run/qualification/results/strata/person-status migrations | `backend/database/migrations/2026_04_23_*` and `2026_04_24_200000_*`, `2026_04_25_000100_*` | +| Wilson 95% CI helper | `backend/app/Services/CareBundles/WilsonCI.php` | +| FHIR Measure resource export | `backend/app/Services/CareBundles/FhirMeasureExporter.php` | +| Stratification / trends / comparison / roster services | `backend/app/Services/CareBundles/Measure{Stratification,Trend,Comparison,Roster}Service.php` | +| Seeders (45 condition bundles + measures) | `backend/database/seeders/ConditionBundleSeeder.php`, `AdditionalConditionBundleSeeder.php` | +| API controllers | `backend/app/Http/Controllers/Api/V1/{CareBundleController,CareGapController,VsacController}.php` | + +--- + +## 3. The Data Asset (highest-value import) + +### 3.1 VSAC tables — live in Parthenon's DB now + +Database: `parthenon` on **host PG17** (`host=127.0.0.1 user=claude_dev`, auth via `~/.pgpass`). Schema `app`: + +| Table | Rows | Contents | +|---|---|---| +| `app.vsac_value_sets` | 1,545 | One row per value-set OID: name, QDM category, definition/expansion versions, purpose fields | +| `app.vsac_value_set_codes` | 225,261 | (oid, code, description, code_system, code_system_oid, code_system_version) — the flattened expansions | +| `app.vsac_measures` | 72 | One per CMS measure (CMS2v15 … ), title, CBE number, program candidacy | +| `app.vsac_measure_value_sets` | 1,597 | M2M: measure → value-set OIDs | +| `app.vsac_value_set_omop_concepts` (matview) | 192,869 | VSAC code → OMOP `concept_id` crosswalk — **skip for Medgnosis** (no OMOP vocab); the raw codes are what you want | + +Source files were `dqm_vs_20251117.xlsx` (CMS dQM VSAC, ~224K rows) and `ec_hospip_hospop_cms_20250508.xlsx` (one sheet per CMS measure). **These workbooks are no longer at the Parthenon repo root — the DB is the source of truth for transfer.** + +### 3.2 Recommended transfer + +```bash +# From the Medgnosis side — pull the four base tables (NOT the matview): +pg_dump -h 127.0.0.1 -U claude_dev -d parthenon \ + -t app.vsac_value_sets -t app.vsac_value_set_codes \ + -t app.vsac_measures -t app.vsac_measure_value_sets \ + --no-owner --no-privileges -f /tmp/vsac_export.sql +# Then adapt schema-qualification (app. → phm_edw. or a new ref_ schema) and load. +``` + +Alternatively, port `scripts/importers/ingest_vsac.py` to a Medgnosis seeder — but you'd need to re-source the CMS workbooks (VSAC downloads at https://vsac.nlm.nih.gov, requires UMLS license). The pg_dump route is faster and gives identical data. + +VSAC code systems present: SNOMEDCT, ICD10CM, ICD10PCS, LOINC, RXNORM, CPT, HCPCS Level II, CVX, CDT — these align directly with the code columns Medgnosis already stores in `phm_edw` (`condition_diagnosis.condition_code` ICD-10, `observation.observation_code` LOINC, `medication_order.medication_code` RxNorm, `procedure.procedure_code` CPT). **No crosswalk needed** — join VSAC codes to your EDW columns directly. + +--- + +## 4. Schema Designs Worth Adopting + +### 4.1 Measure criteria as structured JSONB (vs. Medgnosis's CSV-in-EAV) + +Parthenon's `quality_measures` table (338 rows): + +``` +measure_code (unique) | measure_name | measure_type (preventive|chronic|behavioral) +domain (condition|drug|procedure|measurement|observation) +numerator_criteria jsonb e.g. {"concept_ids": [4084765], "lookback_days": 365} +denominator_criteria jsonb +exclusion_criteria jsonb e.g. {"exclusions": [{"domain":"condition","concept_ids":[...],"lookback_days":730}]} +frequency | is_active +``` + +For Medgnosis, the analogous shape would reference **value-set OIDs instead of OMOP concept_ids**: + +```json +{ "value_set_oids": ["2.16.840.1.113883.3.464.1003.198.12.1019"], "lookback_days": 365 } +``` + +This is strictly better than the current `clinical_rule` `INCLUSION_CODES` CSV approach because: (a) value sets are versioned by CMS, (b) one OID can carry thousands of codes across code systems, (c) re-ingesting a new VSAC release updates every measure at once. It also composes with the Phase-1 rules engine: `clinical_rule` can keep thresholds/logic while value-set membership moves to `vsac_*`. + +### 4.2 Versioned runs + person-level status (auditability) + +Parthenon separates **"who ran what, against which data, when"** from the results: + +- `care_bundle_runs` — status, started/completed, `triggered_by`, `trigger_kind` (manual|scheduled), `qualified_person_count`, `bundle_version`, **`cdm_fingerprint`** (hash of source data state — lets you detect stale results) +- `care_bundle_qualifications` — (run_id, person_id, qualifies, measure_summary jsonb), unique on (run, person) +- `care_bundle_measure_results` — aggregate denom/numer/excl per (run, measure) +- `care_bundle_measure_strata` — per-dimension strata rows (age band, sex) +- `care_bundle_measure_person_status` — (run, measure, person_id, is_numer, is_excl) — powers drill-down to the **non-compliant patient roster** and cohort export + +Medgnosis's `fact_patient_bundle_detail` ≈ `person_status`, but lacks run versioning. If the Geisinger plan's Phase 2 ("two-pass population finder") or Phase 7 ("Cohort Manager") needs reproducible, auditable measure snapshots — adopt the run-versioning layer. Each nightly `measureCalculatorV2` refresh becomes a run row instead of an in-place overwrite, and "as-of" comparisons (this month vs last) fall out for free (`MeasureTrendService.php` shows the query patterns). + +--- + +## 5. SQL Techniques (directly portable, dialect-identical — both are PostgreSQL) + +### 5.1 Single-pass GROUPING SETS stratification + +`CohortBasedMeasureEvaluator.php:57-111`. Instead of re-running the cohort CTEs once for the headline rate and once per stratification dimension, classify each person once, then: + +```sql +SELECT + CASE GROUPING(age_band, sex_cat) WHEN 3 THEN 'all' WHEN 1 THEN 'age_band' WHEN 2 THEN 'sex' END AS dimension, + ..., + COUNT(*) FILTER (WHERE NOT is_excl) AS denom, + COUNT(*) FILTER (WHERE is_numer AND NOT is_excl) AS numer, + COUNT(*) FILTER (WHERE is_excl) AS excl +FROM classified +GROUP BY GROUPING SETS ((), (age_band), (sex_cat)) +``` + +One scan produces headline + all strata. This replaced a 3x-cost pattern in Parthenon and would do the same in `measureCalculatorV2.ts`. + +### 5.2 Temp-table person-set materialization + +`CohortBasedMeasureEvaluator.php:186-193`: materialize numerator and exclusion person-sets as session temp tables (`CREATE TEMP TABLE ... ON COMMIT DROP`), index on person_id, `ANALYZE`, then hash-join. Heavy clinical-table scans happen **exactly once per measure**; explicit `DROP TABLE IF EXISTS` between measures (don't rely on ON COMMIT DROP mid-transaction — Parthenon learned this). + +### 5.3 eCQM accounting semantics (copy exactly) + +``` +denom = qualified persons NOT in exclusion set +numer = qualified persons IN numerator set AND NOT in exclusion set +excl = qualified persons IN exclusion set (removed from BOTH denom and numer) +``` + +Exclusions reduce the denominator — they are not just "not in numerator." Verify `measureCalculatorV2.ts` does this; if `exclusion_flag` patients currently remain in the denominator, rates are understated. + +### 5.4 Parameterized lookback intervals + +PG can't parameterize `INTERVAL '365 days'` literals. Parthenon's trick (`CohortBasedMeasureEvaluator.php:218-232`): bind an integer and multiply — `... >= anchor_date - (? * INTERVAL '1 day')`. Keeps lookback out of the SQL string (injection defense-in-depth). + +--- + +## 6. Differences & Gotchas — READ BEFORE PORTING + +### 6.1 Data model mismatch (the big one) + +Parthenon evaluates against **OMOP CDM** domain tables with **concept_id** semantics, including hierarchy expansion via `vocab.concept_ancestor` (an ancestor concept implies all descendants). **Medgnosis has neither OMOP vocab tables nor concept_ids.** This is fine: VSAC value-set expansions are already **flat, pre-expanded code lists** — descendant expansion is unnecessary when you consume VSAC directly. Your evaluator joins `vsac_value_set_codes.code` against `phm_edw` code columns. Domain routing table for Medgnosis: + +| Parthenon domain | OMOP table | Medgnosis `phm_edw` equivalent | Join column | +|---|---|---|---| +| condition | condition_occurrence | `condition_diagnosis` | ICD-10 code | +| drug | drug_exposure | `medication_order` | RxNorm code | +| procedure | procedure_occurrence | `procedure` | CPT/SNOMED code | +| measurement | measurement | `lab_result` / `observation` | LOINC code | +| observation | observation | `observation` | LOINC code | + +Watch code formatting: VSAC ICD-10 codes carry dots (`E11.9`); verify `condition_diagnosis` stores the same format before joining. + +### 6.2 Reporting-period anchor + +Parthenon anchors lookbacks to `MAX(date_column)` of each domain table because its CDMs are **static research datasets** (SynPUF data ends in 2010). Medgnosis is a **live operational system** — anchor to the measurement period (`CURRENT_DATE` or explicit calendar period per CMS spec). Do not copy the `SELECT MAX({date}) FROM ...` subquery; it also costs a full-column scan you don't need. + +### 6.3 Evaluator-interface pattern (port the idea, not the code) + +Parthenon binds `CareBundleMeasureEvaluator` via `config('care_bundles.evaluator')` — `cohort_based` today, `cql` (cqf-ruler bridge) later, **identical signature, no schema change**. The CQL implementation is an intentional placeholder that throws an actionable error at evaluation time, not boot. In Medgnosis terms: define a `MeasureEvaluator` TS interface now, implement `SqlMeasureEvaluator`, and the Geisinger roadmap's future CQL/FHIR Measure work slots in behind it. The Geisinger compendium's measure logic will eventually want real CQL — this seam is cheap insurance. + +### 6.4 Medgnosis-side known landmines (from project memory) + +- **postgres.js jsonb double-encoding**: pass objects through `sql.json(obj)`, never `JSON.stringify` first; migration runner needs `max: 1` connection. +- Statistical floor: Parthenon flags populations <100,000 persons as research-only (Wilson 95% CI on proportions tightens below ±0.5pp at that size — `config/care_bundles.php:55`). Medgnosis panels are far smaller, so **always show CIs** (`WilsonCI.php` is ~30 lines, trivially portable to TS) rather than gating. + +--- + +## 7. Suggested Incorporation Order (maps to Geisinger CDS parity roadmap) + +| Step | What | Roadmap fit | Effort | +|---|---|---|---| +| 1 | pg_dump/load the 4 `vsac_*` tables into Medgnosis (new `ref` schema or `phm_edw`); add migration + indexes (oid, code, code_system) | Foundation for Phase 2 population finder | S | +| 2 | Bridge: view or rules-engine entity mapping value-set OIDs → existing `measure_definition` / `clinical_rule` entities; migrate `INCLUSION_CODES` CSVs to OID references measure-by-measure | Phase 1 follow-through (rules-as-data) | M | +| 3 | Adopt eCQM accounting semantics (§5.3) + GROUPING SETS strata (§5.1) in `measureCalculatorV2.ts`; add Wilson CIs to measure API responses | Measure calculator hardening | M | +| 4 | Add run-versioning tables (`measure_run`, `measure_person_status`) modeled on Parthenon's `care_bundle_runs`/`..._person_status`; nightly BullMQ job writes a run instead of overwriting | Phase 2 (two-pass population finder needs reproducible snapshots), Phase 7 (Cohort Manager) | M-L | +| 5 | Define the `MeasureEvaluator` interface seam (§6.3) | Future CQL phase | S | +| 6 | (Optional) FHIR `Measure` resource export modeled on `FhirMeasureExporter.php` — canonical URL `${base}/Measure/{code}`; pairs with Medgnosis's existing FHIR R4 read endpoints and CDS Hooks `medgnosis-care-gaps` cards | Interop polish | S-M | + +Steps 1–2 are the highest leverage: they turn Medgnosis's measure definitions from hand-typed code lists into CMS-versioned value sets without touching the calculator. + +## 8. Verification Checklist (for whoever executes) + +- [ ] Row counts after load match source: 1,545 / 225,261 / 72 / 1,597 +- [ ] Spot-check 3 OIDs against VSAC website (code count + a sample code per code system) +- [ ] One migrated measure produces identical gap lists under CSV codes vs. OID-resolved codes (regression gate before cutting over) +- [ ] Exclusion semantics test: an excluded patient appears in neither numerator nor denominator +- [ ] `sql.json()` used for every jsonb write in new TS code +- [ ] CI/typecheck green (`tsc`), and measure nightly job runtime not regressed (GROUPING SETS should *improve* it) + +--- + +*Parthenon contacts for deeper questions: this doc's session transcript, Parthenon Brain (`parthenon_docs` / `parthenon_code` ChromaDB collections — query e.g. "care bundle measure evaluator"), and `docs/devlog/` in the Parthenon repo.* From 07a7e1f9f3ee07ad25c573fdfc805d8b23ce9a90 Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 18:48:59 -0400 Subject: [PATCH 02/14] feat: VSAC value set reference tables + measure bridge (migration 050) --- .../db/migrations/050_vsac_value_sets.sql | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 packages/db/migrations/050_vsac_value_sets.sql diff --git a/packages/db/migrations/050_vsac_value_sets.sql b/packages/db/migrations/050_vsac_value_sets.sql new file mode 100644 index 0000000..71e2212 --- /dev/null +++ b/packages/db/migrations/050_vsac_value_sets.sql @@ -0,0 +1,87 @@ +-- ============================================================================= +-- 050: VSAC value sets + measure bridge (Parthenon eCQM handoff, steps 1-2) +-- CMS-versioned value sets replace hand-typed code lists. One OID carries +-- thousands of codes across code systems; re-ingesting a new VSAC release +-- updates every measure at once. +-- Source: NLM VSAC via Parthenon ingest (app.vsac_* on this host's parthenon DB). +-- Data loaded by packages/db/scripts/load-vsac.sh — NOT by this migration. +-- ============================================================================= + +CREATE TABLE phm_edw.vsac_value_set ( + value_set_oid VARCHAR(120) PRIMARY KEY, + name VARCHAR(500) NOT NULL, + definition_version VARCHAR(50), + expansion_version VARCHAR(120), + expansion_id VARCHAR(50), + qdm_category VARCHAR(120), + purpose_clinical_focus TEXT, + purpose_data_scope TEXT, + purpose_inclusion TEXT, + purpose_exclusion TEXT, + source_files JSONB NOT NULL DEFAULT '[]'::jsonb, + ingested_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_vsac_vs_name ON phm_edw.vsac_value_set (name); + +COMMENT ON TABLE phm_edw.vsac_value_set IS + 'NLM VSAC value sets (one row per OID). CMS-versioned, authoritative code groupings.'; + +CREATE TABLE phm_edw.vsac_value_set_code ( + id BIGSERIAL PRIMARY KEY, + value_set_oid VARCHAR(120) NOT NULL + REFERENCES phm_edw.vsac_value_set (value_set_oid) ON DELETE CASCADE, + code VARCHAR(100) NOT NULL, + description TEXT, + code_system VARCHAR(80) NOT NULL, + code_system_oid VARCHAR(120), + code_system_version VARCHAR(50), + CONSTRAINT uq_vsac_vsc_oid_code_sys UNIQUE (value_set_oid, code, code_system) +); + +CREATE INDEX idx_vsac_vsc_oid ON phm_edw.vsac_value_set_code (value_set_oid); +CREATE INDEX idx_vsac_vsc_sys_code ON phm_edw.vsac_value_set_code (code_system, code); + +COMMENT ON TABLE phm_edw.vsac_value_set_code IS + 'Flattened VSAC expansions. code_system values: SNOMEDCT, ICD10CM, ICD10PCS, LOINC, RXNORM, CPT, HCPCS Level II, CVX, CDT, ... EDW joins: condition/procedure->SNOMEDCT, medication->RXNORM, observation->LOINC.'; + +CREATE TABLE phm_edw.vsac_measure ( + cms_id VARCHAR(50) PRIMARY KEY, + cbe_number VARCHAR(50), + program_candidate VARCHAR(50), + title VARCHAR(500), + expansion_version VARCHAR(120), + ingested_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE phm_edw.vsac_measure IS + 'CMS eCQM registry rows from the VSAC measure workbooks (e.g. CMS122v14).'; + +CREATE TABLE phm_edw.vsac_measure_value_set ( + cms_id VARCHAR(50) NOT NULL + REFERENCES phm_edw.vsac_measure (cms_id) ON DELETE CASCADE, + value_set_oid VARCHAR(120) NOT NULL + REFERENCES phm_edw.vsac_value_set (value_set_oid) ON DELETE CASCADE, + PRIMARY KEY (cms_id, value_set_oid) +); + +CREATE INDEX idx_vsac_mvs_oid ON phm_edw.vsac_measure_value_set (value_set_oid); + +-- Bridge: local measure definitions -> VSAC value sets. +-- vsac_cms_id records WHICH VSAC measure version supplied the mapping +-- (local CMS122v12 vs VSAC CMS122v14 — version drift is explicit, not hidden). +CREATE TABLE phm_edw.measure_value_set ( + measure_id INT NOT NULL + REFERENCES phm_edw.measure_definition (measure_id), + value_set_oid VARCHAR(120) NOT NULL + REFERENCES phm_edw.vsac_value_set (value_set_oid), + vsac_cms_id VARCHAR(50) NOT NULL, + mapping_method VARCHAR(30) NOT NULL DEFAULT 'cms_base_auto', + created_date TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (measure_id, value_set_oid) +); + +CREATE INDEX idx_mvs_oid ON phm_edw.measure_value_set (value_set_oid); + +COMMENT ON TABLE phm_edw.measure_value_set IS + 'Bridge: measure_definition -> VSAC value-set OIDs, auto-matched on base CMS number (CMS122v12 ~ CMS122v14). mapping_method: cms_base_auto | manual.'; From a204b33631f99372158992a8502b09e39e81f9f9 Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 18:52:46 -0400 Subject: [PATCH 03/14] docs: renumber plan migrations to 050/051 (039/040 claimed by concurrent Phase 5 session) --- .../2026-06-12-vsac-value-sets-integration.md | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/plans/2026-06-12-vsac-value-sets-integration.md b/docs/superpowers/plans/2026-06-12-vsac-value-sets-integration.md index 4354f00..7fb6aa9 100644 --- a/docs/superpowers/plans/2026-06-12-vsac-value-sets-integration.md +++ b/docs/superpowers/plans/2026-06-12-vsac-value-sets-integration.md @@ -30,7 +30,7 @@ These were established by direct inspection of both live databases and the codeb | `gap_status` values in live data | `open`, `closed`, `excluded` (26,967 rows in `fact_patient_bundle_detail`) | | `dim_patient` | Has `date_of_birth DATE`, `gender VARCHAR`, SCD2 — **must filter `is_current = TRUE`** in joins or rows multiply | | Migration runner | `packages/db/src/migrate.ts`, `npm run db:migrate` (from `packages/db/`), tracks by filename in `_migrations`, runs each file via `tx.unsafe()` in a transaction | -| ⚠ Highest migration | `038_seed_phase4.sql` (re-verified 2026-06-12 against origin/main `d32acf7`, post Phase-4 merge) — this plan claims **039** and **040**. Concurrent sessions are landing phases same-day; re-check `ls packages/db/migrations/ | sort | tail -3` AND `git log --all --oneline -- 'packages/db/migrations/039*' 'packages/db/migrations/040*'` at execution and renumber to the next free slots if taken. | +| ⚠ Migration numbers | This plan claims **050** and **051** — deliberately ahead of the sequence. Concurrent sessions land phases same-day (039/040 were claimed by Phase 5 within minutes of this plan's first numbering); the live `_migrations` table is the authoritative claim registry: `psql -h 127.0.0.1 -U claude_dev -d medgnosis -At -c "SELECT name FROM _migrations ORDER BY name DESC LIMIT 5;"`. Gaps are harmless — the runner sorts lexicographically and tracks full filenames. | | Tests | Vitest, mocked DB (`vi.mock('@medgnosis/db')` with `vi.hoisted` — see `apps/api/src/services/__tests__/rulesEngine.test.ts`). Run: `npm run test -- ` from `apps/api/`. | | API DB connection | `DATABASE_URL` (postgres.js, `@medgnosis/db`); API runs in Docker pointing at `host.docker.internal:5432/medgnosis` — same instance as the host-side `127.0.0.1` psql access | @@ -46,9 +46,9 @@ These were established by direct inspection of both live databases and the codeb | File | Action | Responsibility | |---|---|---| -| `packages/db/migrations/039_vsac_value_sets.sql` | Create | DDL: 4 VSAC reference tables + `measure_value_set` bridge + indexes | +| `packages/db/migrations/050_vsac_value_sets.sql` | Create | DDL: 4 VSAC reference tables + `measure_value_set` bridge + indexes | | `packages/db/scripts/load-vsac.sh` | Create | One-shot data transfer parthenon→medgnosis via `\copy` pipes + bridge seed + verification | -| `packages/db/migrations/040_measure_strata.sql` | Create | DDL: `phm_star.fact_measure_strata` | +| `packages/db/migrations/051_measure_strata.sql` | Create | DDL: `phm_star.fact_measure_strata` | | `apps/api/src/services/wilsonCI.ts` | Create | Pure Wilson 95% CI function | | `apps/api/src/services/__tests__/wilsonCI.test.ts` | Create | TDD tests for the above | | `apps/api/src/services/vsacService.ts` | Create | Value-set queries: list, codes, measure bridge resolution | @@ -89,11 +89,11 @@ cd .claude/worktrees/feature+cds-vsac-value-sets - [ ] **Step 2: Confirm migration numbering is free** ```bash -ls packages/db/migrations/ | grep -E '^[0-9]{3}' | sort | tail -3 -git log --all --oneline -- 'packages/db/migrations/039*' 'packages/db/migrations/040*' +psql -h 127.0.0.1 -U claude_dev -d medgnosis -At -c "SELECT name FROM _migrations WHERE name >= '050' ORDER BY name;" +git log --all --oneline -- 'packages/db/migrations/050*' 'packages/db/migrations/051*' ``` -Expected: highest is `038_seed_phase4.sql` and no commits touch 039/040. If 039 or 040 is taken on any branch, renumber every reference to 039→NNN and 040→NNN+1 throughout this plan. +Expected: only this plan's own files (`050_vsac_value_sets.sql`, `051_measure_strata.sql`) or nothing. If another session claimed 050/051, renumber every reference throughout this plan AND `UPDATE _migrations SET name=...` for any already-applied file you rename. - [ ] **Step 3: Confirm source data is reachable** @@ -106,10 +106,10 @@ Expected: `225261`. (Collation-mismatch WARNINGs from the parthenon DB are known --- -### Task 1: Migration 039 — VSAC Reference Tables + Measure Bridge +### Task 1: Migration 050 — VSAC Reference Tables + Measure Bridge **Files:** -- Create: `packages/db/migrations/039_vsac_value_sets.sql` +- Create: `packages/db/migrations/050_vsac_value_sets.sql` Naming follows Medgnosis house style (singular table names — `condition`, `measure_definition`), so Parthenon's `app.vsac_value_sets` becomes `phm_edw.vsac_value_set`, etc. @@ -117,7 +117,7 @@ Naming follows Medgnosis house style (singular table names — `condition`, `mea ```sql -- ============================================================================= --- 039: VSAC value sets + measure bridge (Parthenon eCQM handoff, steps 1-2) +-- 050: VSAC value sets + measure bridge (Parthenon eCQM handoff, steps 1-2) -- CMS-versioned value sets replace hand-typed code lists. One OID carries -- thousands of codes across code systems; re-ingesting a new VSAC release -- updates every measure at once. @@ -211,7 +211,7 @@ COMMENT ON TABLE phm_edw.measure_value_set IS cd "$(git rev-parse --show-toplevel)/packages/db" && npm run db:migrate ``` -Expected output includes: `039_vsac_value_sets.sql` applied (and nothing else fails). +Expected output includes: `050_vsac_value_sets.sql` applied (and nothing else fails). - [ ] **Step 3: Verify the tables exist and are empty** @@ -228,8 +228,8 @@ Expected: `measure_value_set`, `vsac_measure`, `vsac_measure_value_set`, `vsac_v ```bash git branch --show-current # verify: feature/cds-vsac-value-sets -git add packages/db/migrations/039_vsac_value_sets.sql -git commit -m "feat: VSAC value set reference tables + measure bridge (migration 039)" +git add packages/db/migrations/050_vsac_value_sets.sql +git commit -m "feat: VSAC value set reference tables + measure bridge (migration 050)" ``` --- @@ -858,19 +858,19 @@ git commit -m "feat: value-sets transparency endpoints (/value-sets, /measure/:c --- -### Task 6: Measure Strata — Migration 040 + GROUPING SETS + Wilson CIs +### Task 6: Measure Strata — Migration 051 + GROUPING SETS + Wilson CIs **Files:** -- Create: `packages/db/migrations/040_measure_strata.sql` +- Create: `packages/db/migrations/051_measure_strata.sql` - Modify: `apps/api/src/services/measureCalculatorV2.ts` Single-pass GROUPING SETS (handoff §5.1): classify each (patient, measure) row once, produce headline + age + sex strata in one scan, inside the SAME refresh transaction so facts and strata can never diverge. -- [ ] **Step 1: Write migration 040** +- [ ] **Step 1: Write migration 051** ```sql -- ============================================================================= --- 040: Measure stratification facts (CDS parity — calculator hardening) +-- 051: Measure stratification facts (CDS parity — calculator hardening) -- Populated by measureCalculatorV2 in the same transaction as -- fact_measure_result, via single-pass GROUPING SETS (one scan -> headline -- 'all' row + age_band strata + gender strata per measure). @@ -900,7 +900,7 @@ COMMENT ON TABLE phm_star.fact_measure_strata IS cd "$(git rev-parse --show-toplevel)/packages/db" && npm run db:migrate ``` -Expected: `040_measure_strata.sql` applied. +Expected: `051_measure_strata.sql` applied. - [ ] **Step 3: Extend the refresh transaction and add CIs to the summary** @@ -1118,8 +1118,8 @@ Expected: `dimensions: age_band, all, gender`, `violations (expect 0): 0`, `mism ```bash git branch --show-current -git add packages/db/migrations/040_measure_strata.sql apps/api/src/services/measureCalculatorV2.ts -git commit -m "feat: single-pass GROUPING SETS measure strata + Wilson CIs (migration 040)" +git add packages/db/migrations/051_measure_strata.sql apps/api/src/services/measureCalculatorV2.ts +git commit -m "feat: single-pass GROUPING SETS measure strata + Wilson CIs (migration 051)" ``` --- @@ -1459,7 +1459,7 @@ git branch --show-current # verify: feature/cds-vsac-value-sets git push -u origin feature/cds-vsac-value-sets ``` -Then follow superpowers:finishing-a-development-branch (merge/PR decision). Note for the PR body: migrations 039/040 are additive; the VSAC data load is a one-time script run, required once per environment (`packages/db/scripts/load-vsac.sh`); prod deploys need a reachable source DB or a portable dump (`pg_dump --data-only` of the four `phm_edw.vsac_*` tables from a loaded environment). +Then follow superpowers:finishing-a-development-branch (merge/PR decision). Note for the PR body: migrations 050/051 are additive (numbered ahead of sequence to avoid same-day concurrent-session collisions — gaps are harmless to the runner); the VSAC data load is a one-time script run, required once per environment (`packages/db/scripts/load-vsac.sh`); prod deploys need a reachable source DB or a portable dump (`pg_dump --data-only` of the four `phm_edw.vsac_*` tables from a loaded environment). --- From 4d667ec279a5e6b4399a1533ddda9e158a4728d3 Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 19:08:29 -0400 Subject: [PATCH 04/14] feat: VSAC data load script (parthenon -> medgnosis, 225k codes + bridge seed) Copies 1,545 value sets / 225,261 codes / 72 measures / 1,597 measure-value-sets from parthenon app.vsac_* to medgnosis phm_edw.vsac_* via \copy TO STDOUT | FROM STDIN. Seeds the measure_value_set bridge (44 of 45 CMS measures; CMS249v6 has no VSAC entry). EDW joinability confirmed: conditions (SNOMEDCT): 89 distinct codes medications (RXNORM): 228 distinct codes observations (LOINC): 43 distinct codes in sampled 5k rows (full scan blocked by 1B-row table; no index on observation_code) Source-consistency: OID 2.16.840.1.113883.3.464.1003.103.12.1001 (Diabetes) medgnosis count = 774, parthenon count = 774. Exact match. --- packages/db/scripts/load-vsac.sh | 77 ++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100755 packages/db/scripts/load-vsac.sh diff --git a/packages/db/scripts/load-vsac.sh b/packages/db/scripts/load-vsac.sh new file mode 100755 index 0000000..d0cdabf --- /dev/null +++ b/packages/db/scripts/load-vsac.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# ============================================================================= +# load-vsac.sh — one-shot transfer of VSAC reference data +# parthenon app.vsac_* (plural) -> medgnosis phm_edw.vsac_* (singular) +# then seeds the measure_value_set bridge by base-CMS-number match. +# +# Both DBs live on the same host PG17 instance; auth via ~/.pgpass. +# Refuses to touch non-empty destination tables unless --reload is given. +# ============================================================================= +set -euo pipefail + +SRC_HOST="${VSAC_SRC_HOST:-127.0.0.1}" +SRC_DB="${VSAC_SRC_DB:-parthenon}" +DST_HOST="${VSAC_DST_HOST:-127.0.0.1}" +DST_DB="${VSAC_DST_DB:-medgnosis}" +PGUSER="${PGUSER:-claude_dev}" + +SRC=(psql -h "$SRC_HOST" -U "$PGUSER" -d "$SRC_DB" -v ON_ERROR_STOP=1 -qAt) +DST=(psql -h "$DST_HOST" -U "$PGUSER" -d "$DST_DB" -v ON_ERROR_STOP=1 -qAt) + +existing=$("${DST[@]}" -c "SELECT count(*) FROM phm_edw.vsac_value_set;") +if [[ "$existing" != "0" ]]; then + if [[ "${1:-}" == "--reload" ]]; then + echo "Reloading: truncating phm_edw VSAC tables (bridge included)..." + "${DST[@]}" -c "TRUNCATE phm_edw.measure_value_set, phm_edw.vsac_measure_value_set, + phm_edw.vsac_measure, phm_edw.vsac_value_set_code, phm_edw.vsac_value_set;" + else + echo "ERROR: phm_edw.vsac_value_set already has $existing rows. Re-run with --reload to replace." >&2 + exit 1 + fi +fi + +copy_table() { # $1 src table $2 dst table $3 column list + echo "Copying $1 -> $2 ..." + "${SRC[@]}" -c "\\copy (SELECT $3 FROM $1) TO STDOUT" \ + | "${DST[@]}" -c "\\copy $2 ($3) FROM STDIN" +} + +copy_table app.vsac_value_sets phm_edw.vsac_value_set \ + "value_set_oid, name, definition_version, expansion_version, expansion_id, qdm_category, purpose_clinical_focus, purpose_data_scope, purpose_inclusion, purpose_exclusion, source_files, ingested_at" + +copy_table app.vsac_value_set_codes phm_edw.vsac_value_set_code \ + "value_set_oid, code, description, code_system, code_system_oid, code_system_version" + +copy_table app.vsac_measures phm_edw.vsac_measure \ + "cms_id, cbe_number, program_candidate, title, expansion_version, ingested_at" + +copy_table app.vsac_measure_value_sets phm_edw.vsac_measure_value_set \ + "cms_id, value_set_oid" + +echo "Seeding measure_value_set bridge (base CMS number match)..." +"${DST[@]}" <<'SQL' +INSERT INTO phm_edw.measure_value_set (measure_id, value_set_oid, vsac_cms_id, mapping_method) +SELECT md.measure_id, mvs.value_set_oid, vm.cms_id, 'cms_base_auto' +FROM phm_edw.measure_definition md +JOIN phm_edw.vsac_measure vm + ON regexp_replace(md.measure_code, 'v[0-9]+$', '') + = regexp_replace(vm.cms_id, 'v[0-9]+$', '') +JOIN phm_edw.vsac_measure_value_set mvs ON mvs.cms_id = vm.cms_id +WHERE md.measure_code ~ '^CMS' AND md.active_ind = 'Y' +ON CONFLICT (measure_id, value_set_oid) DO NOTHING; +SQL + +echo "--- Verification ---" +"${DST[@]}" <<'SQL' +SELECT 'vsac_value_set expect 1545 got ' || count(*) FROM phm_edw.vsac_value_set; +SELECT 'vsac_value_set_code expect 225261 got ' || count(*) FROM phm_edw.vsac_value_set_code; +SELECT 'vsac_measure expect 72 got ' || count(*) FROM phm_edw.vsac_measure; +SELECT 'vsac_measure_value_set expect 1597 got ' || count(*) FROM phm_edw.vsac_measure_value_set; +SELECT 'bridged measures expect 44 got ' || count(DISTINCT measure_id) FROM phm_edw.measure_value_set; +SELECT 'unbridged CMS measures (expect CMS249v6 only): ' + || coalesce(string_agg(measure_code, ', '), '(none)') +FROM phm_edw.measure_definition md +WHERE md.measure_code ~ '^CMS' AND md.active_ind = 'Y' + AND NOT EXISTS (SELECT 1 FROM phm_edw.measure_value_set b WHERE b.measure_id = md.measure_id); +SQL +echo "Done." From 5355c3d6bd24f4ca73c7d702b25e806740e45764 Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 19:11:02 -0400 Subject: [PATCH 05/14] feat: Wilson 95% CI utility for measure rates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure Wilson score interval (wilsonCI) for binomial proportions — preferred over normal approximation for the small panels Medgnosis serves; bounds are always clamped to [0, 1]. 5 Vitest tests, all passing; tsc clean. --- .../src/services/__tests__/wilsonCI.test.ts | 39 +++++++++++++++++++ apps/api/src/services/wilsonCI.ts | 26 +++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 apps/api/src/services/__tests__/wilsonCI.test.ts create mode 100644 apps/api/src/services/wilsonCI.ts diff --git a/apps/api/src/services/__tests__/wilsonCI.test.ts b/apps/api/src/services/__tests__/wilsonCI.test.ts new file mode 100644 index 0000000..4fc25a9 --- /dev/null +++ b/apps/api/src/services/__tests__/wilsonCI.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/services/wilsonCI.ts b/apps/api/src/services/wilsonCI.ts new file mode 100644 index 0000000..75ca3a0 --- /dev/null +++ b/apps/api/src/services/wilsonCI.ts @@ -0,0 +1,26 @@ +// ============================================================================= +// Wilson score interval for a binomial proportion (95% by default). +// Preferred over the normal approximation for the small panels Medgnosis +// serves — it never produces bounds outside [0, 1] and behaves at p near 0/1. +// ============================================================================= + +export interface WilsonInterval { + lower: number; + upper: number; +} + +export function wilsonCI(numerator: number, denominator: number, z = 1.96): WilsonInterval { + if (denominator <= 0) { + return { lower: 0, upper: 0 }; + } + const p = numerator / denominator; + const z2 = z * z; + const factor = 1 + z2 / denominator; + const center = (p + z2 / (2 * denominator)) / factor; + const half = + (z * Math.sqrt((p * (1 - p)) / denominator + z2 / (4 * denominator * denominator))) / factor; + return { + lower: Math.max(0, center - half), + upper: Math.min(1, center + half), + }; +} From b11f2129c55acafa602e0ae04b0e003274c31342 Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 19:15:12 -0400 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20VSAC=20service=20=E2=80=94=20valu?= =?UTF-8?q?e=20set=20queries=20+=20measure=20code=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements vsacService.ts with listValueSets, getValueSetCodes, getMeasureValueSets, and resolveMeasureCodes over phm_edw.vsac_* tables and the measure_value_set bridge. Uses inline NULL-coalescing for optional filters to avoid nested sql`` fragments. EDW_CODE_SYSTEM maps domains to verified VSAC code systems (condition/procedure = SNOMEDCT, not ICD-10/CPT). TDD: 7/7 tests pass. Live DB: CMS122v12 resolves 2,704 SNOMEDCT codes across 26 value sets. --- .../services/__tests__/vsacService.test.ts | 99 ++++++++++++++++ apps/api/src/services/vsacService.ts | 106 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 apps/api/src/services/__tests__/vsacService.test.ts create mode 100644 apps/api/src/services/vsacService.ts diff --git a/apps/api/src/services/__tests__/vsacService.test.ts b/apps/api/src/services/__tests__/vsacService.test.ts new file mode 100644 index 0000000..609203b --- /dev/null +++ b/apps/api/src/services/__tests__/vsacService.test.ts @@ -0,0 +1,99 @@ +// ============================================================================= +// Unit tests — VSAC value set service +// ============================================================================= + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +type SqlRow = Record; + +const { mockSql } = vi.hoisted(() => { + const fn = vi.fn<(strings: TemplateStringsArray, ...values: unknown[]) => Promise>(); + 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', () => { + // NOTE: call WITHOUT codeSystem here. With it, the nested sql`` fragment + // fires an extra mock call that consumes mockResolvedValueOnce before the + // outer query runs — the mock can't distinguish fragments from queries. + 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([]); + }); +}); diff --git a/apps/api/src/services/vsacService.ts b/apps/api/src/services/vsacService.ts new file mode 100644 index 0000000..fbe923f --- /dev/null +++ b/apps/api/src/services/vsacService.ts @@ -0,0 +1,106 @@ +// ============================================================================= +// Medgnosis API — VSAC value set service +// Reads phm_edw.vsac_* reference tables and the measure_value_set bridge. +// resolveMeasureCodes() is the workhorse: every code of one code system across +// all value sets bridged to a measure — what evaluators and the population +// finder consume instead of hand-typed code lists. +// ============================================================================= + +import { sql } from '@medgnosis/db'; + +// phm_edw code-column reality (verified 2026-06-12): condition and procedure +// are SNOMED-coded — the Parthenon handoff's ICD-10/CPT routing does not apply. +export const EDW_CODE_SYSTEM = { + condition: 'SNOMEDCT', + procedure: 'SNOMEDCT', + medication: 'RXNORM', + observation: 'LOINC', +} as const; + +export type EdwDomain = keyof typeof EDW_CODE_SYSTEM; + +export interface ValueSetSummary { + value_set_oid: string; + name: string; + qdm_category: string | null; + code_count: number; +} + +export interface ValueSetCode { + code: string; + description: string | null; + code_system: string; +} + +export interface MeasureValueSet { + value_set_oid: string; + name: string; + vsac_cms_id: string; + qdm_category: string | null; + code_count: number; +} + +// Use NULLIF trick: pass null to disable optional filter inline — avoids nested +// sql`` fragments that would fire extra mock calls under the test harness. +export async function listValueSets(search?: string): Promise { + const searchPattern = search ? '%' + search + '%' : null; + return sql` + SELECT + vs.value_set_oid, + vs.name, + vs.qdm_category, + COUNT(vc.id)::int AS code_count + FROM phm_edw.vsac_value_set vs + LEFT JOIN phm_edw.vsac_value_set_code vc ON vc.value_set_oid = vs.value_set_oid + WHERE (${searchPattern}::text IS NULL OR vs.name ILIKE ${searchPattern}::text) + GROUP BY vs.value_set_oid, vs.name, vs.qdm_category + ORDER BY vs.name + `; +} + +export async function getValueSetCodes( + oid: string, + codeSystem?: string, +): Promise { + return sql` + SELECT vc.code, vc.description, vc.code_system + FROM phm_edw.vsac_value_set_code vc + WHERE vc.value_set_oid = ${oid} + AND (${codeSystem ?? null}::text IS NULL OR vc.code_system = ${codeSystem ?? null}::text) + ORDER BY vc.code_system, vc.code + `; +} + +export async function getMeasureValueSets(measureCode: string): Promise { + return sql` + SELECT + vs.value_set_oid, + vs.name, + mv.vsac_cms_id, + vs.qdm_category, + COUNT(vc.id)::int AS code_count + FROM phm_edw.measure_value_set mv + JOIN phm_edw.measure_definition md ON md.measure_id = mv.measure_id + JOIN phm_edw.vsac_value_set vs ON vs.value_set_oid = mv.value_set_oid + LEFT JOIN phm_edw.vsac_value_set_code vc ON vc.value_set_oid = vs.value_set_oid + WHERE md.measure_code = ${measureCode} + GROUP BY vs.value_set_oid, vs.name, mv.vsac_cms_id, vs.qdm_category + ORDER BY vs.name + `; +} + +export async function resolveMeasureCodes( + measureCode: string, + codeSystem: string, +): Promise { + const rows = await sql<{ code: string }[]>` + SELECT DISTINCT vc.code + FROM phm_edw.measure_value_set mv + JOIN phm_edw.measure_definition md ON md.measure_id = mv.measure_id + JOIN phm_edw.vsac_value_set_code vc ON vc.value_set_oid = mv.value_set_oid + WHERE md.measure_code = ${measureCode} + AND vc.code_system = ${codeSystem} + ORDER BY vc.code + `; + return rows.map((r) => r.code); +} From b246207db25c00b873243b1ca76155747912a869 Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 19:18:04 -0400 Subject: [PATCH 07/14] test: update stale comment in vsacService test (nested-fragment rationale obsolete) --- apps/api/src/services/__tests__/vsacService.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/__tests__/vsacService.test.ts b/apps/api/src/services/__tests__/vsacService.test.ts index 609203b..5e453e6 100644 --- a/apps/api/src/services/__tests__/vsacService.test.ts +++ b/apps/api/src/services/__tests__/vsacService.test.ts @@ -53,9 +53,8 @@ describe('listValueSets', () => { }); describe('getValueSetCodes', () => { - // NOTE: call WITHOUT codeSystem here. With it, the nested sql`` fragment - // fires an extra mock call that consumes mockResolvedValueOnce before the - // outer query runs — the mock can't distinguish fragments from queries. + // 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' }, From 8ab0fe44abefbca19e3be2c1885589c2df354562 Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 19:21:53 -0400 Subject: [PATCH 08/14] feat: value-sets transparency endpoints (/value-sets, /measure/:code, /:oid/codes) Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/routes/index.ts | 2 + apps/api/src/routes/value-sets/index.ts | 60 +++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 apps/api/src/routes/value-sets/index.ts diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index da8caa5..9b7a07c 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -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'; @@ -52,6 +53,7 @@ export async function registerRoutes(fastify: FastifyInstance): Promise { 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' }); diff --git a/apps/api/src/routes/value-sets/index.ts b/apps/api/src/routes/value-sets/index.ts new file mode 100644 index 0000000..210bf90 --- /dev/null +++ b/apps/api/src/routes/value-sets/index.ts @@ -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 { + 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 }); + }); +} From 7ae926631bae345587ac5f2ca83026b4c04cd7b9 Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 19:25:20 -0400 Subject: [PATCH 09/14] feat: single-pass GROUPING SETS measure strata + Wilson CIs (migration 051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 051: creates phm_star.fact_measure_strata (SERIAL PK, measure_key, date_key_period, dimension, stratum, denominator, numerator, excluded, created_at) with idx_fms_measure index. Rebuilt atomically with fact_measure_result. - measureCalculatorV2: single-pass GROUPING SETS produces 'all' + 'age_band' + 'gender' strata in one subquery scan (no extra round-trip). TRUNCATE of both tables inside the same transaction — strata and results never diverge. Statement timeout raised 30s → 60s to accommodate the added strata INSERT. - getMeasureSummary: adds ci_lower/ci_upper fields (Wilson 95% CI, percent ×1 decimal) via wilsonCI(). Returns null for measures with zero eligible patients. Refresh timings (26967 fact rows, live DB): First run: { rowCount: 26967, durationMs: 1596 } Steady-state: { rowCount: 26967, durationMs: 231 } Verification: dimensions=age_band,all,gender | violations=0 | mismatches=0 --- apps/api/src/services/measureCalculatorV2.ts | 86 +++++++++++++++++-- packages/db/migrations/051_measure_strata.sql | 23 +++++ 2 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 packages/db/migrations/051_measure_strata.sql diff --git a/apps/api/src/services/measureCalculatorV2.ts b/apps/api/src/services/measureCalculatorV2.ts index 1fd5385..c16ed63 100644 --- a/apps/api/src/services/measureCalculatorV2.ts +++ b/apps/api/src/services/measureCalculatorV2.ts @@ -1,10 +1,16 @@ // ============================================================================= // Medgnosis API — Measure Calculator v2 -// Aggregates fact_patient_bundle_detail → fact_measure_result. +// Aggregates fact_patient_bundle_detail → fact_measure_result + strata. // Replaces the old measureEngine.ts (45 broken SQL files). +// +// eCQM accounting (CMS semantics — regression-gated, do not weaken): +// denominator = gap_status IN ('open','closed') — excluded NOT in denom +// numerator = gap_status = 'closed' — subset of denominator +// excluded = gap_status = 'excluded' — in NEITHER denom NOR numer // ============================================================================= import { sql } from '@medgnosis/db'; +import { wilsonCI } from './wilsonCI.js'; export interface RefreshResult { rowCount: number; @@ -19,20 +25,22 @@ export interface MeasureSummaryRow { met: number; excluded: number; performance_rate: number | null; + ci_lower: number | null; + ci_upper: number | null; } /** - * Refresh fact_measure_result by aggregating fact_patient_bundle_detail. - * Runs inside a transaction so a failed INSERT rolls back the TRUNCATE. + * Refresh fact_measure_result AND fact_measure_strata in one transaction — + * a failed INSERT rolls back both TRUNCATEs; facts and strata never diverge. * SET LOCAL scopes the statement timeout to the transaction — no pool leak. */ export async function refreshMeasureResults(): Promise { const t0 = performance.now(); const result = await sql.begin(async (tx) => { - await tx.unsafe("SET LOCAL statement_timeout = '30s'"); + await tx.unsafe("SET LOCAL statement_timeout = '60s'"); await tx.unsafe('TRUNCATE phm_star.fact_measure_result'); - return tx.unsafe(` + const inserted = await tx.unsafe(` INSERT INTO phm_star.fact_measure_result (patient_key, measure_key, date_key_period, denominator_flag, numerator_flag, exclusion_flag, @@ -48,6 +56,57 @@ export async function refreshMeasureResults(): Promise { 1 FROM phm_star.fact_patient_bundle_detail d `); + + // Single-pass stratification: GROUPING(a, b) sets a bit per UN-grouped + // column, so () -> 3 = headline, (age_band) -> 1, (gender) -> 2. + await tx.unsafe('TRUNCATE phm_star.fact_measure_strata'); + await tx.unsafe(` + INSERT INTO phm_star.fact_measure_strata + (measure_key, date_key_period, dimension, stratum, + denominator, numerator, excluded) + SELECT + c.measure_key, + c.date_key_period, + CASE GROUPING(c.age_band, c.gender) + WHEN 3 THEN 'all' + WHEN 1 THEN 'age_band' + WHEN 2 THEN 'gender' + END, + CASE GROUPING(c.age_band, c.gender) + WHEN 3 THEN 'all' + WHEN 1 THEN c.age_band + WHEN 2 THEN c.gender + END, + COUNT(*) FILTER (WHERE c.denominator_flag)::int, + COUNT(*) FILTER (WHERE c.numerator_flag)::int, + COUNT(*) FILTER (WHERE c.exclusion_flag)::int + FROM ( + SELECT + fmr.measure_key, + fmr.date_key_period, + CASE + WHEN dp.date_of_birth IS NULL THEN 'unknown' + WHEN dp.date_of_birth > CURRENT_DATE - INTERVAL '18 years' THEN '<18' + WHEN dp.date_of_birth > CURRENT_DATE - INTERVAL '40 years' THEN '18-39' + WHEN dp.date_of_birth > CURRENT_DATE - INTERVAL '65 years' THEN '40-64' + ELSE '65+' + END AS age_band, + COALESCE(NULLIF(TRIM(dp.gender), ''), 'unknown') AS gender, + fmr.denominator_flag, + fmr.numerator_flag, + fmr.exclusion_flag + FROM phm_star.fact_measure_result fmr + JOIN phm_star.dim_patient dp + ON dp.patient_key = fmr.patient_key AND dp.is_current + ) c + GROUP BY GROUPING SETS ( + (c.measure_key, c.date_key_period), + (c.measure_key, c.date_key_period, c.age_band), + (c.measure_key, c.date_key_period, c.gender) + ) + `); + + return inserted; }); const durationMs = Math.round(performance.now() - t0); @@ -58,10 +117,11 @@ export async function refreshMeasureResults(): Promise { } /** - * Return per-measure performance summary from fact_measure_result. + * Per-measure performance summary with Wilson 95% CIs (percent, 1 decimal). + * Small panels always show the interval — never gate on population size. */ export async function getMeasureSummary(): Promise { - return sql` + const rows = await sql[]>` SELECT dm.measure_key, dm.measure_code, @@ -78,4 +138,16 @@ export async function getMeasureSummary(): Promise { GROUP BY dm.measure_key, dm.measure_code, dm.measure_name ORDER BY dm.measure_code `; + + return rows.map((row) => { + if (row.eligible <= 0) { + return { ...row, ci_lower: null, ci_upper: null }; + } + const ci = wilsonCI(row.met, row.eligible); + return { + ...row, + ci_lower: Math.round(ci.lower * 1000) / 10, + ci_upper: Math.round(ci.upper * 1000) / 10, + }; + }); } diff --git a/packages/db/migrations/051_measure_strata.sql b/packages/db/migrations/051_measure_strata.sql new file mode 100644 index 0000000..89c9cc9 --- /dev/null +++ b/packages/db/migrations/051_measure_strata.sql @@ -0,0 +1,23 @@ +-- ============================================================================= +-- 051: Measure stratification facts (CDS parity — calculator hardening) +-- Populated by measureCalculatorV2 in the same transaction as +-- fact_measure_result, via single-pass GROUPING SETS (one scan -> headline +-- 'all' row + age_band strata + gender strata per measure). +-- ============================================================================= + +CREATE TABLE phm_star.fact_measure_strata ( + strata_key SERIAL PRIMARY KEY, + measure_key INT NOT NULL, + date_key_period INT, + dimension VARCHAR(20) NOT NULL, -- 'all' | 'age_band' | 'gender' + stratum VARCHAR(50) NOT NULL, -- 'all' | '<18' | '18-39' | '40-64' | '65+' | gender values + denominator INT NOT NULL DEFAULT 0, + numerator INT NOT NULL DEFAULT 0, + excluded INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_fms_measure ON phm_star.fact_measure_strata (measure_key, dimension); + +COMMENT ON TABLE phm_star.fact_measure_strata IS + 'Per-measure strata (eCQM accounting: excluded removed from denominator AND numerator). Rebuilt with fact_measure_result each refresh.'; From 9e1b043fe3d21b977e285865ff0d9fd443d559ba Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 19:28:33 -0400 Subject: [PATCH 10/14] =?UTF-8?q?feat:=20GET=20/measures/:id/strata=20?= =?UTF-8?q?=E2=80=94=20stratified=20rates=20with=20Wilson=20CIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/routes/measures/index.ts | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/apps/api/src/routes/measures/index.ts b/apps/api/src/routes/measures/index.ts index 2de6fe2..4b0c1e8 100644 --- a/apps/api/src/routes/measures/index.ts +++ b/apps/api/src/routes/measures/index.ts @@ -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 { fastify.addHook('preHandler', fastify.authenticate); @@ -77,4 +78,43 @@ export default async function measureRoutes(fastify: FastifyInstance): Promise('/: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 }); + }); } From 77a5dda486189f3b70f4ebb19ea33de55d7e699d Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 19:31:31 -0400 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20MeasureEvaluator=20seam=20?= =?UTF-8?q?=E2=80=94=20swappable=20sql/cql=20engines=20behind=20one=20inte?= =?UTF-8?q?rface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces MeasureEvaluator interface with sql (current) and cql (stub) implementations. Call sites in the BullMQ worker and admin routes now resolve the engine via getMeasureEvaluator() instead of calling refreshMeasureResults() directly. Engine selected by MEASURE_EVALUATOR env var (default: sql). 5 new unit tests, full suite 121/121 green, tsc clean. --- .env.example | 3 + apps/api/src/routes/admin/index.ts | 6 +- .../__tests__/measureEvaluator.test.ts | 60 +++++++++++++++++++ apps/api/src/services/measureEvaluator.ts | 44 ++++++++++++++ apps/api/src/workers/measure-calculator.ts | 6 +- 5 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/services/__tests__/measureEvaluator.test.ts create mode 100644 apps/api/src/services/measureEvaluator.ts diff --git a/.env.example b/.env.example index 65d44a3..73d5154 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/api/src/routes/admin/index.ts b/apps/api/src/routes/admin/index.ts index 8cdbdfd..ed7c026 100644 --- a/apps/api/src/routes/admin/index.ts +++ b/apps/api/src/routes/admin/index.ts @@ -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'; @@ -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) }); @@ -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) diff --git a/apps/api/src/services/__tests__/measureEvaluator.test.ts b/apps/api/src/services/__tests__/measureEvaluator.test.ts new file mode 100644 index 0000000..95efc30 --- /dev/null +++ b/apps/api/src/services/__tests__/measureEvaluator.test.ts @@ -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/); + }); +}); diff --git a/apps/api/src/services/measureEvaluator.ts b/apps/api/src/services/measureEvaluator.ts new file mode 100644 index 0000000..5a8fbe7 --- /dev/null +++ b/apps/api/src/services/measureEvaluator.ts @@ -0,0 +1,44 @@ +// ============================================================================= +// Medgnosis API — MeasureEvaluator seam +// One signature, swappable engines: SQL aggregation today, a CQL/cqf-ruler +// bridge later — no schema change, no caller change (Parthenon pattern, see +// docs/superpowers/specs/2026-06-12-parthenon-ecqm-handoff.md §6.3). +// Selected via MEASURE_EVALUATOR env var; defaults to 'sql'. +// ============================================================================= + +import { refreshMeasureResults, type RefreshResult } from './measureCalculatorV2.js'; + +export type MeasureEvaluatorKind = 'sql' | 'cql'; + +export interface MeasureEvaluator { + readonly kind: MeasureEvaluatorKind; + refresh(): Promise; +} + +export const sqlMeasureEvaluator: MeasureEvaluator = { + kind: 'sql', + refresh: refreshMeasureResults, +}; + +export const cqlMeasureEvaluator: MeasureEvaluator = { + kind: 'cql', + refresh: async () => { + // Intentional placeholder: fails at evaluation time with a pointer, not at boot. + throw new Error( + 'CQL evaluator not implemented. Set MEASURE_EVALUATOR=sql, or implement the ' + + 'cqf-ruler bridge per docs/superpowers/specs/2026-06-12-parthenon-ecqm-handoff.md §6.3.', + ); + }, +}; + +export function getMeasureEvaluator(): MeasureEvaluator { + const kind = process.env['MEASURE_EVALUATOR'] ?? 'sql'; + switch (kind) { + case 'sql': + return sqlMeasureEvaluator; + case 'cql': + return cqlMeasureEvaluator; + default: + throw new Error(`Unknown MEASURE_EVALUATOR "${kind}" — expected "sql" or "cql"`); + } +} diff --git a/apps/api/src/workers/measure-calculator.ts b/apps/api/src/workers/measure-calculator.ts index b0515d0..438a2a3 100644 --- a/apps/api/src/workers/measure-calculator.ts +++ b/apps/api/src/workers/measure-calculator.ts @@ -5,7 +5,7 @@ import { Worker, Queue } from 'bullmq'; import { connection } from './rules-engine.js'; -import { refreshMeasureResults } from '../services/measureCalculatorV2.js'; +import { getMeasureEvaluator } from '../services/measureEvaluator.js'; export const MEASURE_QUEUE_NAME = 'medgnosis-measure-calc'; @@ -27,7 +27,9 @@ async function processMeasureJob(job: { data: MeasureJobData }): Promise { const { triggerType } = job.data; console.info(`[measure-calc] ${triggerType} refresh starting...`); - const result = await refreshMeasureResults(); + const evaluator = getMeasureEvaluator(); + console.info(`[measure-calc] evaluator: ${evaluator.kind}`); + const result = await evaluator.refresh(); console.info( `[measure-calc] ${triggerType} refresh complete: ${result.rowCount} rows in ${result.durationMs}ms`, ); From 7c7c9f0f441a27f9222e2e8277a82ccc4fbd08a2 Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 19:38:17 -0400 Subject: [PATCH 12/14] fix: add dim_measure FK to fact_measure_strata (final-review finding) Matches the referential policy of sibling star fact tables (ON DELETE RESTRICT). Applied to the live dev DB via equivalent ALTER TABLE since 051 was already recorded in _migrations there; fresh environments get it from the CREATE TABLE. --- .../plans/2026-06-12-vsac-value-sets-integration.md | 3 ++- packages/db/migrations/051_measure_strata.sql | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-06-12-vsac-value-sets-integration.md b/docs/superpowers/plans/2026-06-12-vsac-value-sets-integration.md index 7fb6aa9..68ac707 100644 --- a/docs/superpowers/plans/2026-06-12-vsac-value-sets-integration.md +++ b/docs/superpowers/plans/2026-06-12-vsac-value-sets-integration.md @@ -878,7 +878,8 @@ Single-pass GROUPING SETS (handoff §5.1): classify each (patient, measure) row CREATE TABLE phm_star.fact_measure_strata ( strata_key SERIAL PRIMARY KEY, - measure_key INT NOT NULL, + measure_key INT NOT NULL + REFERENCES phm_star.dim_measure (measure_key) ON DELETE RESTRICT, date_key_period INT, dimension VARCHAR(20) NOT NULL, -- 'all' | 'age_band' | 'gender' stratum VARCHAR(50) NOT NULL, -- 'all' | '<18' | '18-39' | '40-64' | '65+' | gender values diff --git a/packages/db/migrations/051_measure_strata.sql b/packages/db/migrations/051_measure_strata.sql index 89c9cc9..78deb5e 100644 --- a/packages/db/migrations/051_measure_strata.sql +++ b/packages/db/migrations/051_measure_strata.sql @@ -7,7 +7,8 @@ CREATE TABLE phm_star.fact_measure_strata ( strata_key SERIAL PRIMARY KEY, - measure_key INT NOT NULL, + measure_key INT NOT NULL + REFERENCES phm_star.dim_measure (measure_key) ON DELETE RESTRICT, date_key_period INT, dimension VARCHAR(20) NOT NULL, -- 'all' | 'age_band' | 'gender' stratum VARCHAR(50) NOT NULL, -- 'all' | '<18' | '18-39' | '40-64' | '65+' | gender values From 9ad4b064c43157f498bac60f7814030e40069738 Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 20:15:10 -0400 Subject: [PATCH 13/14] fix: harden VSAC service docs + load-script verification (adversarial review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveMeasureCodes header now warns it unions ALL population roles (~82% of CMS122 SNOMEDCT codes are exclusion-family) — must not drive population finding until the bridge carries population_role - EDW_CODE_SYSTEM comment clarifies values are VSAC labels, not EDW code_system column values ('SNOMED'/'ICD-10') — translate before joining - load-vsac.sh verification now asserts source==destination counts and exits non-zero on mismatch (was echo-only, could ship wrong data green) --- apps/api/src/services/vsacService.ts | 13 ++++++++--- packages/db/scripts/load-vsac.sh | 32 +++++++++++++++++++++------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/apps/api/src/services/vsacService.ts b/apps/api/src/services/vsacService.ts index fbe923f..b5de4bc 100644 --- a/apps/api/src/services/vsacService.ts +++ b/apps/api/src/services/vsacService.ts @@ -1,15 +1,22 @@ // ============================================================================= // Medgnosis API — VSAC value set service // Reads phm_edw.vsac_* reference tables and the measure_value_set bridge. -// resolveMeasureCodes() is the workhorse: every code of one code system across -// all value sets bridged to a measure — what evaluators and the population -// finder consume instead of hand-typed code lists. +// +// SAFETY: resolveMeasureCodes() returns the union of ALL bridged value sets +// REGARDLESS of population role — denominator, exclusion (hospice / advanced +// illness / frailty / palliative), and supplemental codes together (~82% of +// CMS122's SNOMEDCT codes are exclusion-family). Treating that union as a +// denominator would invert eCQM exclusion semantics. Do NOT drive population +// finding or gap generation from it until the bridge carries population_role. // ============================================================================= import { sql } from '@medgnosis/db'; // phm_edw code-column reality (verified 2026-06-12): condition and procedure // are SNOMED-coded — the Parthenon handoff's ICD-10/CPT routing does not apply. +// NB: these are VSAC code_system labels, NOT phm_edw.*.code_system values — +// the EDW stores 'SNOMED'/'ICD-10' (CHECK-constrained); translate labels +// before ever joining an EDW code_system column against VSAC's. export const EDW_CODE_SYSTEM = { condition: 'SNOMEDCT', procedure: 'SNOMEDCT', diff --git a/packages/db/scripts/load-vsac.sh b/packages/db/scripts/load-vsac.sh index d0cdabf..1d3467e 100755 --- a/packages/db/scripts/load-vsac.sh +++ b/packages/db/scripts/load-vsac.sh @@ -61,15 +61,31 @@ WHERE md.measure_code ~ '^CMS' AND md.active_ind = 'Y' ON CONFLICT (measure_id, value_set_oid) DO NOTHING; SQL -echo "--- Verification ---" +echo "--- Verification (asserted: source and destination must match exactly) ---" +verify_count() { # $1 src table $2 dst table + local src_n dst_n + src_n=$("${SRC[@]}" -c "SELECT count(*) FROM $1;") + dst_n=$("${DST[@]}" -c "SELECT count(*) FROM $2;") + if [[ "$src_n" != "$dst_n" ]]; then + echo "FAIL: $2 has $dst_n rows, source $1 has $src_n" >&2 + exit 1 + fi + echo "OK: $2 = $dst_n rows (matches source)" +} + +verify_count app.vsac_value_sets phm_edw.vsac_value_set +verify_count app.vsac_value_set_codes phm_edw.vsac_value_set_code +verify_count app.vsac_measures phm_edw.vsac_measure +verify_count app.vsac_measure_value_sets phm_edw.vsac_measure_value_set + +bridged=$("${DST[@]}" -c "SELECT count(DISTINCT measure_id) FROM phm_edw.measure_value_set;") +if [[ "$bridged" -lt 1 ]]; then + echo "FAIL: bridge seeded 0 measures" >&2 + exit 1 +fi +echo "OK: bridge covers $bridged measures" "${DST[@]}" <<'SQL' -SELECT 'vsac_value_set expect 1545 got ' || count(*) FROM phm_edw.vsac_value_set; -SELECT 'vsac_value_set_code expect 225261 got ' || count(*) FROM phm_edw.vsac_value_set_code; -SELECT 'vsac_measure expect 72 got ' || count(*) FROM phm_edw.vsac_measure; -SELECT 'vsac_measure_value_set expect 1597 got ' || count(*) FROM phm_edw.vsac_measure_value_set; -SELECT 'bridged measures expect 44 got ' || count(DISTINCT measure_id) FROM phm_edw.measure_value_set; -SELECT 'unbridged CMS measures (expect CMS249v6 only): ' - || coalesce(string_agg(measure_code, ', '), '(none)') +SELECT 'unbridged CMS measures: ' || coalesce(string_agg(measure_code, ', '), '(none)') FROM phm_edw.measure_definition md WHERE md.measure_code ~ '^CMS' AND md.active_ind = 'Y' AND NOT EXISTS (SELECT 1 FROM phm_edw.measure_value_set b WHERE b.measure_id = md.measure_id); From e6a54ddbb6702f9c373914c442e64aad0ceba26c Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Fri, 12 Jun 2026 20:19:59 -0400 Subject: [PATCH 14/14] =?UTF-8?q?docs:=20clinical=20fidelity=20&=20deliver?= =?UTF-8?q?y=20hardening=20plan=20(next=20iteration=20=E2=80=94=20patient?= =?UTF-8?q?=20safety)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-12-clinical-fidelity-hardening.md | 649 ++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-clinical-fidelity-hardening.md diff --git a/docs/superpowers/plans/2026-06-12-clinical-fidelity-hardening.md b/docs/superpowers/plans/2026-06-12-clinical-fidelity-hardening.md new file mode 100644 index 0000000..3bd389a --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-clinical-fidelity-hardening.md @@ -0,0 +1,649 @@ +# Clinical Fidelity & Delivery Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the VSAC/measure layer clinically true — population-role-aware code resolution, real (not hash-seeded) exclusions, code-system and version-drift guards, value-set-driven medication safety flags — and restore the CI pipeline so every future safety fix ships through a green gate. + +**Architecture:** Two parts. Part 1 fixes the three pre-existing CI failures (red since at least the Phase 6 merge — a permanently red pipeline means regressions ship invisibly, which is itself a patient-safety defect). Part 2 adds the clinical-fidelity layer on top of PR #1's VSAC asset: a `population_role` dimension on the bridge, an exclusion engine fed by the imported hospice/advanced-illness/frailty value sets, contract guards wired into Phase 7's existing DQ infrastructure, and an upgrade of Phase 7's regex-based ACE/ARB flag to RxNorm value sets with allergy/intolerance suppression. + +**Tech Stack:** Fastify 5 / TypeScript / postgres.js, PostgreSQL 17 (host, `claude_dev` via `~/.pgpass`), Vitest mocked-DB style, BullMQ, GitHub Actions (`.github/workflows/ci.yml`, turbo). + +**Provenance:** Adversarial clinical-safety review of PR #1 (2026-06-12) + CI failure analysis. The three review verdicts this plan answers: (1) `resolveMeasureCodes` unions denominator with exclusion codes — 2,704 SNOMEDCT codes for CMS122v12 of which only 493 are the Diabetes set, 2,211 (82%) are Advanced Illness/Frailty/Hospice/Palliative — a naive consumer would flag hospice patients with false care gaps; (2) `gap_status='excluded'` (2,689 rows) is fabricated by a deterministic hash in migration 017 line 92, never computed clinically; (3) `EDW_CODE_SYSTEM` values are VSAC labels while the EDW's `code_system` columns hold `'SNOMED'`/`'ICD-10'` under a CHECK constraint — a direct label join silently returns zero rows, and `phm_edw.condition.code_system` defaults to `'ICD-10'`. + +--- + +## Verified Facts (2026-06-12, post-Phase-8 main — re-verify ⚠ items at execution) + +| Fact | Value | +|---|---| +| Roadmap state | ALL 8 CDS phases merged on origin/main (Phase 8 merged 2026-06-12 23:52 UTC). Phase 7 shipped `dq_finding`/`dq_feed` tables, `apps/api/src/workers/data-quality.ts` (`startDqWorker`, `runDqScan`), `apps/api/src/services/cohortFlags.ts` (flags HYPERKALEMIA, GFR_LOW, NEW_ACEARB_NO_BMP — ACE/ARB detection is a **name regex** `ACEARB_RE = 'lisinopril\|enalapril\|...'`), routes `/data-quality/*` and `/cohorts/*`. | +| CI failures (all pre-existing, identical on main) | **Unit Tests:** migration `003_etl_synthea_to_edw.sql` calls `phm_edw.dblink(...)`; CI's fresh `postgres:15-alpine` has no dblink extension in that schema. **Lint:** `apps/web` lint script exits 127 (`eslint` binary not found in that workspace). **Type Check:** `packages/solr` typecheck runs before `@medgnosis/db` is built (turbo task has no `dependsOn: ["^build"]`) + one real error `src/sync/cdc-listener.ts:608 TS7006` (implicit-any `payload`). Build/E2E/Security jobs SKIP because upstream jobs fail. | +| VSAC bridge (PR #1) | `phm_edw.measure_value_set`: 1,015 rows / 44 measures, all v12-local↔v14-VSAC (`vsac_cms_id` recorded, `mapping_method='cms_base_auto'`), **no role column** | +| Exclusion-family value sets loaded | 37 sets matching `hospice\|palliative\|advanced illness\|frailty` (e.g. Advanced Illness `2.16.840.1.113883.3.464.1003.110.12.1082`, Frailty Diagnosis, Hospice…) | +| ACE/ARB value sets loaded | `ACE Inhibitor or ARB or ARNI` (RXNORM, 302 codes), `ACE Inhibitor or ARB or ARNI Ingredient` (RXNORM, 38), `Allergy to ACE Inhibitor or ARB` (SNOMEDCT, 30), `Intolerance to ACE Inhibitor or ARB` (SNOMEDCT, 46), `Patient Reason for ACE Inhibitor or ARB Decline` (SNOMEDCT, 4) | +| `phm_edw.care_gap.gap_status` live | closed=6,697 / open=17,581 / excluded=2,689 (the excluded all hash-seeded by migration 017) | +| `phm_edw.condition_diagnosis` | patient_id, condition_id (FK→condition), diagnosis_status, onset_date, resolution_date, active_ind — sufficient for exclusion detection | +| EDW exclusion-code overlap (demo data) | 8 distinct condition codes in `phm_edw.condition` hit exclusion-family SNOMEDCT sets — small (demo-scale) but real; the machinery is the deliverable | +| `phm_edw.condition.code_system` | CHECK allows only `'ICD-10','SNOMED','ICD-9','OTHER'`; live distribution 100% `'SNOMED'`; **column default `'ICD-10'`** | +| Strata small cells | 386 of 1,132 strata rows (34%) have denominator 1–10; endpoint serves rate+CI for all, no small-cell metadata | +| `fact_patient_bundle_detail` | gap_status copied from `phm_edw.care_gap`; star keys via `dim_patient.patient_key` (filter `is_current`) and `dim_measure.measure_key` (`dim_measure.measure_id` = `measure_definition.measure_id`) | +| Worker pattern | `apps/api/src/worker.ts` starts all workers; nightly enqueue in `apps/api/src/workers/nightly-scheduler.ts` (`await xQueue.add('nightly-x', { triggeredBy: 'nightly_batch' })`); BullMQ `connection` exported from `workers/rules-engine.js` | +| ⚠ Migration numbers | This plan claims **052** (roles) and **053** (cohort-flag rules seed). Check the registry first: `psql -h 127.0.0.1 -U claude_dev -d medgnosis -At -c "SELECT name FROM _migrations WHERE name >= '044' ORDER BY name;"` — Phase 8 may have claimed 044+; 052/053 stay in PR #1's deliberate band. Renumber + `UPDATE _migrations` if taken. | +| ⚠ Base branch | PR #1 (`feature/cds-vsac-value-sets`) must be merged first — this plan consumes its tables/services. If unmerged at execution, branch FROM `feature/cds-vsac-value-sets` and retarget after. | + +**Guardrails (unchanged from PR #1's plan):** worktree execution; `git branch --show-current` before every commit; additive migrations only; never stage tsbuildinfo/package-lock artifacts; `npx tsc --noEmit` + full vitest before every commit; `sql.json()` for any jsonb from TS; never print credentials. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `packages/db/migrations/003_etl_synthea_to_edw.sql` | Modify | `CREATE EXTENSION IF NOT EXISTS dblink` so fresh environments (CI) can run it | +| `apps/web/package.json` (+ eslint config if missing) | Modify | Make `lint` executable (exit 127 fix) | +| `turbo.json` | Modify | `typecheck` depends on `^build` | +| `packages/solr/src/sync/cdc-listener.ts` | Modify | Fix TS7006 implicit-any | +| `packages/db/migrations/052_measure_value_set_roles.sql` | Create | `population_role` + `role_method` on the bridge + heuristic seed | +| `apps/api/src/services/vsacService.ts` | Modify | Role-aware `resolveMeasureCodes`, `getMeasureBridgeStatus`, EDW→VSAC label map | +| `apps/api/src/services/__tests__/vsacService.test.ts` | Modify | Updated + new tests | +| `apps/api/src/services/exclusionEngine.ts` | Create | Compute clinical exclusions from exclusion-role value sets; retire hash-seeded rows | +| `apps/api/src/services/__tests__/exclusionEngine.test.ts` | Create | Mocked tests | +| `apps/api/src/workers/nightly-scheduler.ts` | Modify | Run exclusion recompute before the measure refresh | +| `packages/db/migrations/053_seed_cohort_flag_value_sets.sql` | Create | `clinical_rule` rows binding flag definitions to value-set OIDs | +| `apps/api/src/services/cohortFlags.ts` | Modify | ACE/ARB flag: regex → RxNorm value set + allergy/intolerance suppression | +| `apps/api/src/workers/data-quality.ts` | Modify | New detector: code-system contract assertion | +| `apps/api/src/routes/measures/index.ts` | Modify | `small_cell` metadata on strata | +| `apps/api/src/routes/value-sets/index.ts` | Modify | `version_drift` surfaced; LIMIT on codes endpoint | + +--- + +### Task 0: Preflight + +- [ ] **Step 1: Worktree + branch.** Execute in a fresh worktree (EnterWorktree or `git worktree add`), branch `feature/clinical-fidelity-hardening` from origin/main **after PR #1 merges** (see ⚠ Base branch above). `git branch --show-current` must confirm. +- [ ] **Step 2: Migration registry check** (⚠ row above). Claim 052/053 or renumber. +- [ ] **Step 3: Worktree build prep** (known gotcha): `rm -f packages/*/tsconfig.tsbuildinfo && (cd packages/shared && npm run build) && (cd packages/db && npm run build)` after `npm install --legacy-peer-deps`, then `cd apps/api && npm run test` — expect full suite green before starting. + +--- + +## Part 1 — CI Restoration (the delivery-safety gate) + +### Task 1: Unit Tests job — dblink extension + +**Files:** Modify `packages/db/migrations/003_etl_synthea_to_edw.sql` + +- [ ] **Step 1:** Inspect the usage: `grep -n "dblink" packages/db/migrations/003_etl_synthea_to_edw.sql | head`. CI error was `function phm_edw.dblink(text, text) does not exist` — the calls are schema-qualified to `phm_edw`, so the extension must be installed INTO that schema. +- [ ] **Step 2:** Add immediately after the migration's header comment (before any dblink call): + +```sql +-- dblink is required by the Synthea ETL below; fresh environments (CI) need it +-- installed into phm_edw because the calls are schema-qualified. +CREATE EXTENSION IF NOT EXISTS dblink WITH SCHEMA phm_edw; +``` + +If `grep` shows the calls are NOT schema-qualified (plain `dblink(`), use `CREATE EXTENSION IF NOT EXISTS dblink;` instead and report which form you found. + +- [ ] **Step 3:** Editing an applied migration is safe here: every existing environment already ran 003 (`IF NOT EXISTS` no-ops there); only fresh CI databases see the new line. Verify locally that the statement is idempotent: `psql -h 127.0.0.1 -U claude_dev -d medgnosis -c "CREATE EXTENSION IF NOT EXISTS dblink WITH SCHEMA phm_edw;"` (expect `NOTICE: extension "dblink" already exists` or clean CREATE — report which). +- [ ] **Step 4:** Commit: `git add packages/db/migrations/003_etl_synthea_to_edw.sql && git commit -m "fix(ci): install dblink extension in migration 003 for fresh environments"` + +### Task 2: Lint job — apps/web exit 127 + +**Files:** Modify `apps/web/package.json` (possibly add eslint config) + +- [ ] **Step 1:** Diagnose: `cd apps/web && npm run lint` locally. Exit 127 = `eslint` binary not resolvable in this workspace. Compare against `apps/api/package.json` (`grep -A5 devDependencies apps/api/package.json | grep -i eslint` and check `apps/api`'s lint script). +- [ ] **Step 2:** Fix by adding the same eslint devDependencies (+ config file if `apps/web` has none — copy `apps/api`'s eslint config and adjust for React/TSX). Install with `npm install --legacy-peer-deps` at repo root. +- [ ] **Step 3:** `cd apps/web && npm run lint` must exit 0. If it now surfaces real lint errors in web code, fix ONLY mechanical ones (unused imports, etc.); if substantive errors appear, report DONE_WITH_CONCERNS listing them rather than mass-disabling rules. +- [ ] **Step 4:** Commit: `git add apps/web/package.json package-lock.json && git commit -m "fix(ci): make apps/web lint runnable (eslint was missing — exit 127)"` (package-lock.json IS staged here — it's the actual dependency fix.) + +### Task 3: Type Check job — turbo ordering + solr implicit-any + +**Files:** Modify `turbo.json`, `packages/solr/src/sync/cdc-listener.ts` + +- [ ] **Step 1:** In `turbo.json`, find the `typecheck` task definition and add the build dependency so workspace `dist/` exists before dependents typecheck: + +```json +"typecheck": { + "dependsOn": ["^build"] +} +``` + +(Merge with any existing keys in that task block — don't drop them.) + +- [ ] **Step 2:** Fix `packages/solr/src/sync/cdc-listener.ts:608` (`Parameter 'payload' implicitly has an 'any' type`). Read the surrounding function — it is a Postgres LISTEN/NOTIFY callback, so the payload is a string: type it `(payload: string)` (adjust if the actual callback signature differs; never use `any`). +- [ ] **Step 3:** Verify the whole monorepo: `npx turbo run typecheck` from repo root — expect every workspace green. +- [ ] **Step 4:** Commit: `git add turbo.json packages/solr/src/sync/cdc-listener.ts && git commit -m "fix(ci): typecheck depends on ^build; type solr CDC payload param"` + +### Task 4: CI green verification + +- [ ] **Step 1:** Push the branch, open a draft PR (`gh pr create --draft ...`), wait for checks: `gh pr view --json statusCheckRollup --jq '.statusCheckRollup[] | {name, conclusion}'` (per project memory: `gh pr checks --json` errors silently — use `pr view`). +- [ ] **Step 2:** Expected: Type Check ✓, Lint ✓, Unit Tests ✓, and the previously SKIPPED Build/E2E/Security jobs now run — chase any newly-unskipped failures the same way (diagnose, fix, commit) until the rollup is green. This gate blocks Part 2's merge, not its development. + +--- + +## Part 2 — Clinical Fidelity + +### Task 5: Migration 052 — population roles on the bridge + +**Files:** Create `packages/db/migrations/052_measure_value_set_roles.sql` + +The VSAC workbooks don't carry population roles, so v1 is a conservative name heuristic: classify only what is unambiguous, leave the rest `'unclassified'`, and record the method so manual curation (from the eCQM specs) can override row-by-row later. **`resolveMeasureCodes` will refuse to serve unclassified-as-denominator** (Task 6), so a wrong heuristic fails loudly, not silently. + +- [ ] **Step 1:** Write the migration: + +```sql +-- ============================================================================= +-- 052: Population roles on the measure↔value-set bridge. +-- The 2026-06-12 adversarial review showed resolveMeasureCodes unioned +-- denominator with exclusion codes (82% contamination for CMS122) — a naive +-- consumer would flag hospice patients with false care gaps. Roles make the +-- bridge safe to consume. Heuristic-classified by value-set NAME (conservative: +-- anything ambiguous stays 'unclassified'); role_method records provenance so +-- manual curation from the eCQM specs can override. +-- ============================================================================= + +ALTER TABLE phm_edw.measure_value_set + ADD COLUMN population_role VARCHAR(30) NOT NULL DEFAULT 'unclassified', + ADD COLUMN role_method VARCHAR(20) NOT NULL DEFAULT 'unclassified'; + +ALTER TABLE phm_edw.measure_value_set + ADD CONSTRAINT chk_mvs_population_role CHECK (population_role IN + ('initial_population','denominator','denominator_exclusion','numerator','supplemental','unclassified')), + ADD CONSTRAINT chk_mvs_role_method CHECK (role_method IN + ('name_heuristic','manual','unclassified')); + +CREATE INDEX idx_mvs_role ON phm_edw.measure_value_set (population_role); + +-- Exclusion family: the canonical eCQM denominator-exclusion value sets. +UPDATE phm_edw.measure_value_set mv SET population_role = 'denominator_exclusion', role_method = 'name_heuristic' +FROM phm_edw.vsac_value_set vs +WHERE vs.value_set_oid = mv.value_set_oid + AND vs.name ~* '(hospice|palliative|advanced illness|frailty|long.term care|nursing facility|dementia medications)'; + +-- Supplemental data elements (exact names per eCQM convention). +UPDATE phm_edw.measure_value_set mv SET population_role = 'supplemental', role_method = 'name_heuristic' +FROM phm_edw.vsac_value_set vs +WHERE vs.value_set_oid = mv.value_set_oid + AND vs.name ~* '^(race|ethnicity|payer( type)?|onc administrative sex|sex)$'; + +-- Qualifying-encounter sets → initial population. +UPDATE phm_edw.measure_value_set mv SET population_role = 'initial_population', role_method = 'name_heuristic' +FROM phm_edw.vsac_value_set vs +WHERE vs.value_set_oid = mv.value_set_oid + AND mv.population_role = 'unclassified' + AND vs.name ~* '(office visit|outpatient consultation|encounter|wellness visit|telephone visit|virtual|home healthcare services|preventive care services|annual wellness)'; + +COMMENT ON COLUMN phm_edw.measure_value_set.population_role IS + 'eCQM population role. name_heuristic rows are conservative auto-classification; authoritative roles come from the measure''s CQL data criteria (manual). unclassified is NEVER served as a denominator.'; +``` + +- [ ] **Step 2:** Run the migration (`cd packages/db && DATABASE_URL=... npm run db:migrate` — extract password from pgpass as in PR #1's plan; never print it). +- [ ] **Step 3:** Verify and EYEBALL the classification (this is a clinical-safety review point, not a formality): + +```bash +psql -h 127.0.0.1 -U claude_dev -d medgnosis -At -c \ + "SELECT population_role, count(*) FROM phm_edw.measure_value_set GROUP BY 1 ORDER BY 2 DESC;" +psql -h 127.0.0.1 -U claude_dev -d medgnosis -c \ + "SELECT DISTINCT vs.name, mv.population_role FROM phm_edw.measure_value_set mv + JOIN phm_edw.vsac_value_set vs USING (value_set_oid) + WHERE mv.population_role <> 'unclassified' ORDER BY 2, 1;" +``` + +Read every classified name. If ANY looks wrong for its role (e.g. a clinical denominator set caught by the encounter regex), fix the heuristic in the migration before committing, re-run against a corrected UPDATE (the migration is on a fresh branch — repair via `UPDATE ... SET population_role='unclassified'` + adjust the file so fresh environments get it right). Report the final counts. + +- [ ] **Step 4:** Commit: `git add packages/db/migrations/052_measure_value_set_roles.sql && git commit -m "feat: population roles on measure-value-set bridge (migration 052)"` + +### Task 6: Role-aware VSAC service (TDD) + +**Files:** Modify `apps/api/src/services/vsacService.ts`, `apps/api/src/services/__tests__/vsacService.test.ts` + +- [ ] **Step 1:** Add failing tests to the existing test file (same mock pattern): + +```typescript +describe('resolveMeasureCodes (role-aware)', () => { + it('passes the role into the query', async () => { + mockSql.mockResolvedValueOnce([{ code: '44054006' }]); + const codes = await resolveMeasureCodes('CMS122v12', 'SNOMEDCT', 'denominator_exclusion'); + expect(codes).toEqual(['44054006']); + const values = mockSql.mock.calls[0]?.slice(1) ?? []; + expect(values).toContain('denominator_exclusion'); + }); +}); + +describe('getMeasureBridgeStatus', () => { + it('reports version drift and role coverage', async () => { + mockSql.mockResolvedValueOnce([ + { vsac_cms_id: 'CMS122v14', population_role: 'denominator_exclusion', n: 9 }, + { vsac_cms_id: 'CMS122v14', population_role: 'unclassified', n: 12 }, + ]); + const status = await getMeasureBridgeStatus('CMS122v12'); + expect(status).toEqual({ + measure_code: 'CMS122v12', + vsac_cms_id: 'CMS122v14', + version_drift: true, + roles: { denominator_exclusion: 9, unclassified: 12 }, + unclassified_count: 12, + }); + }); + + it('returns null for an unbridged measure', async () => { + expect(await getMeasureBridgeStatus('CMS249v6')).toBeNull(); + }); +}); +``` + +Add `getMeasureBridgeStatus` and `type PopulationRole` to the import list. Run — expect FAIL (new export missing / signature mismatch). + +- [ ] **Step 2:** Implement in `vsacService.ts`: + +```typescript +export type PopulationRole = + | 'initial_population' + | 'denominator' + | 'denominator_exclusion' + | 'numerator' + | 'supplemental' + | 'unclassified'; + +export interface MeasureBridgeStatus { + measure_code: string; + vsac_cms_id: string; + version_drift: boolean; + roles: Record; + unclassified_count: number; +} +``` + +Change `resolveMeasureCodes` to require the role (third positional param, no default — callers must choose consciously) and add `AND mv.population_role = ${role}` to its WHERE clause. Replace the SAFETY header warning with: role-aware now; `'unclassified'` may be requested explicitly for audit, but is never a denominator. + +```typescript +export async function getMeasureBridgeStatus( + measureCode: string, +): Promise { + const rows = await sql<{ vsac_cms_id: string; population_role: string; n: number }[]>` + SELECT mv.vsac_cms_id, mv.population_role, count(*)::int AS n + FROM phm_edw.measure_value_set mv + JOIN phm_edw.measure_definition md ON md.measure_id = mv.measure_id + WHERE md.measure_code = ${measureCode} + GROUP BY mv.vsac_cms_id, mv.population_role + `; + if (rows.length === 0) return null; + const first = rows[0]; + if (!first) return null; + const roles: Record = {}; + let unclassified = 0; + for (const r of rows) { + roles[r.population_role] = (roles[r.population_role] ?? 0) + r.n; + if (r.population_role === 'unclassified') unclassified += r.n; + } + return { + measure_code: measureCode, + vsac_cms_id: first.vsac_cms_id, + version_drift: measureCode !== first.vsac_cms_id, + roles, + unclassified_count: unclassified, + }; +} +``` + +- [ ] **Step 3:** Fix the two pre-existing `resolveMeasureCodes` tests (they now need the role argument — use `'denominator_exclusion'` and keep assertions). Run the file: all green. `npx tsc --noEmit`: the signature change must surface every caller — as of PR #1 there are none outside tests; if tsc reveals new callers added by Phases 5–8, STOP and report NEEDS_CONTEXT listing them. +- [ ] **Step 4:** Live sanity: role-filtered resolution must now separate the populations — + +```bash +psql -h 127.0.0.1 -U claude_dev -d medgnosis -At -c \ + "SELECT mv.population_role, count(DISTINCT vc.code) + FROM phm_edw.measure_value_set mv + JOIN phm_edw.measure_definition md ON md.measure_id = mv.measure_id + JOIN phm_edw.vsac_value_set_code vc ON vc.value_set_oid = mv.value_set_oid + WHERE md.measure_code = 'CMS122v12' AND vc.code_system = 'SNOMEDCT' + GROUP BY 1;" +``` + +Expect `denominator_exclusion` ≈ 2,200 (the contamination, now labeled) clearly separated from the rest. Record actual numbers. + +- [ ] **Step 5:** Commit both files: `git commit -m "feat: role-aware resolveMeasureCodes + getMeasureBridgeStatus"` + +### Task 7: Exclusion engine — retire the hash-seeded exclusions (TDD) + +**Files:** Create `apps/api/src/services/exclusionEngine.ts` + `__tests__/exclusionEngine.test.ts`; Modify `apps/api/src/workers/nightly-scheduler.ts` + +Semantics (conservative, surface-don't-hide): a patient is **clinically excluded** from a measure when they have an active diagnosis whose code is in one of that measure's `denominator_exclusion` value sets. Hash-seeded `excluded` rows with NO clinical justification revert to `'open'` — an unverified gap must be visible, not hidden. Both `phm_edw.care_gap` and `phm_star.fact_patient_bundle_detail` are updated in one transaction so the next measure refresh (which reads bundle_detail) propagates immediately. + +- [ ] **Step 1:** Failing test (mock pattern as elsewhere; mock `sql.begin` to invoke its callback with a `tx` whose `unsafe` is a vi.fn returning `{ count: N }`): + +```typescript +// ============================================================================= +// Unit tests — clinical exclusion engine +// ============================================================================= + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockUnsafe, mockBegin } = vi.hoisted(() => { + const mockUnsafe = vi.fn(async () => ({ count: 0 })); + const mockBegin = vi.fn(async (cb: (tx: { unsafe: typeof mockUnsafe }) => Promise) => + cb({ unsafe: mockUnsafe }), + ); + return { mockUnsafe, mockBegin }; +}); + +vi.mock('@medgnosis/db', () => ({ + sql: Object.assign(vi.fn(), { begin: mockBegin, unsafe: mockUnsafe }), +})); + +import { recomputeClinicalExclusions } from '../exclusionEngine.js'; + +beforeEach(() => { + vi.clearAllMocks(); + mockUnsafe.mockResolvedValue({ count: 0 }); +}); + +describe('recomputeClinicalExclusions', () => { + it('runs exclude + revert + star-sync statements in ONE transaction', async () => { + mockUnsafe + .mockResolvedValueOnce({ count: 12 }) // newly excluded (care_gap) + .mockResolvedValueOnce({ count: 2677 }) // reverted to open (care_gap) + .mockResolvedValueOnce({ count: 9999 }); // bundle_detail sync + const result = await recomputeClinicalExclusions(); + expect(mockBegin).toHaveBeenCalledOnce(); + expect(mockUnsafe.mock.calls.length).toBeGreaterThanOrEqual(3); + expect(result.newlyExcluded).toBe(12); + expect(result.revertedToOpen).toBe(2677); + }); +}); +``` + +Run — FAIL (module missing). + +- [ ] **Step 2:** Implement `apps/api/src/services/exclusionEngine.ts`: + +```typescript +// ============================================================================= +// Medgnosis API — Clinical exclusion engine +// Replaces hash-seeded gap_status='excluded' (migration 017's deterministic +// hash — mechanically valid, clinically meaningless) with exclusions computed +// from the measure's denominator_exclusion value sets (hospice, palliative, +// advanced illness, frailty — imported from VSAC). +// Conservative semantics: an exclusion needs clinical evidence; an excluded +// row WITHOUT evidence reverts to 'open' (surface, never hide, unverified gaps). +// care_gap and fact_patient_bundle_detail update in one transaction so the +// next measure refresh propagates consistently. +// ============================================================================= + +import { sql } from '@medgnosis/db'; + +export interface ExclusionRecomputeResult { + newlyExcluded: number; + revertedToOpen: number; + durationMs: number; +} + +const CLINICAL_EXCLUSION_EVIDENCE = ` + SELECT 1 + FROM phm_edw.condition_diagnosis cd + JOIN phm_edw.condition c ON c.condition_id = cd.condition_id + JOIN phm_edw.vsac_value_set_code vc + ON vc.code = c.condition_code AND vc.code_system = 'SNOMEDCT' + JOIN phm_edw.measure_value_set mv + ON mv.value_set_oid = vc.value_set_oid + AND mv.population_role = 'denominator_exclusion' + WHERE cd.patient_id = cg.patient_id + AND mv.measure_id = cg.measure_id + AND cd.active_ind = 'Y' + AND (cd.resolution_date IS NULL OR cd.resolution_date > CURRENT_DATE) +`; + +export async function recomputeClinicalExclusions(): Promise { + const t0 = performance.now(); + + const { newlyExcluded, revertedToOpen } = await sql.begin(async (tx) => { + const excluded = await tx.unsafe(` + UPDATE phm_edw.care_gap cg + SET gap_status = 'excluded', + comments = COALESCE(comments || ' | ', '') || 'excluded: clinical (VSAC denominator_exclusion)', + updated_at = NOW() + WHERE LOWER(cg.gap_status) <> 'excluded' + AND EXISTS (${CLINICAL_EXCLUSION_EVIDENCE}) + `); + + const reverted = await tx.unsafe(` + UPDATE phm_edw.care_gap cg + SET gap_status = 'open', + comments = COALESCE(comments || ' | ', '') || 'reverted: hash-seeded exclusion without clinical evidence', + updated_at = NOW() + WHERE LOWER(cg.gap_status) = 'excluded' + AND NOT EXISTS (${CLINICAL_EXCLUSION_EVIDENCE}) + `); + + await tx.unsafe(` + UPDATE phm_star.fact_patient_bundle_detail d + SET gap_status = cg.gap_status + FROM phm_edw.care_gap cg + JOIN phm_star.dim_patient dp ON dp.patient_id = cg.patient_id AND dp.is_current + JOIN phm_star.dim_measure dm ON dm.measure_id = cg.measure_id + WHERE d.patient_key = dp.patient_key + AND d.measure_key = dm.measure_key + AND LOWER(d.gap_status) <> LOWER(cg.gap_status) + `); + + return { newlyExcluded: excluded.count ?? 0, revertedToOpen: reverted.count ?? 0 }; + }); + + const durationMs = Math.round(performance.now() - t0); + console.info( + `[exclusions] recomputed: +${newlyExcluded} clinical, ${revertedToOpen} hash-seeded reverted to open (${durationMs}ms)`, + ); + return { newlyExcluded, revertedToOpen, durationMs }; +} +``` + +NOTE before coding: verify the column names used above against the live DB (`care_gap.comments`/`updated_at` exist — confirmed 2026-06-12; `fact_patient_bundle_detail` join keys patient_key/measure_key/gap_status — confirmed; `dim_measure.measure_id` — confirmed). If `tx.unsafe` is typed without `.count`, mirror how `measureCalculatorV2.ts` reads `result.count`. + +- [ ] **Step 3:** Tests green; `npx tsc --noEmit` clean. +- [ ] **Step 4:** Wire into the nightly pipeline in `nightly-scheduler.ts` — exclusions must land BEFORE the measure refresh job is enqueued. Locate where `measureQueue.add(...)` happens in `processNightlyJob` and insert immediately before it: + +```typescript + const exclusions = await recomputeClinicalExclusions(); + console.info( + `[nightly] exclusions recomputed: +${exclusions.newlyExcluded} / reverted ${exclusions.revertedToOpen}`, + ); +``` + +with the import at top: `import { recomputeClinicalExclusions } from '../services/exclusionEngine.js';` + +- [ ] **Step 5:** One-time live run + verification (the backfill IS the nightly function — run it once now): + +```bash +cd apps/api +PGPASSWORD="$(awk -F: '$4=="claude_dev" {print $5; exit}' ~/.pgpass)" \ +DATABASE_URL="postgres://claude_dev@127.0.0.1:5432/medgnosis" npx tsx -e " +import('./src/services/exclusionEngine.ts').then(async (m) => { + console.log(await m.recomputeClinicalExclusions()); + process.exit(0); +});" +``` + +Then verify clinical truth end-to-end: + +```bash +psql -h 127.0.0.1 -U claude_dev -d medgnosis -At <<'EOF' +-- hash-seeded exclusions retired: every remaining excluded row has clinical evidence +SELECT 'excluded w/o evidence (expect 0): ' || count(*) FROM phm_edw.care_gap cg +WHERE LOWER(cg.gap_status)='excluded' AND NOT EXISTS ( + SELECT 1 FROM phm_edw.condition_diagnosis cd + JOIN phm_edw.condition c ON c.condition_id = cd.condition_id + JOIN phm_edw.vsac_value_set_code vc ON vc.code = c.condition_code AND vc.code_system='SNOMEDCT' + JOIN phm_edw.measure_value_set mv ON mv.value_set_oid = vc.value_set_oid AND mv.population_role='denominator_exclusion' + WHERE cd.patient_id = cg.patient_id AND mv.measure_id = cg.measure_id AND cd.active_ind='Y' + AND (cd.resolution_date IS NULL OR cd.resolution_date > CURRENT_DATE)); +SELECT 'care_gap status now: ' || string_agg(gap_status || '=' || n, ', ') FROM (SELECT gap_status, count(*) n FROM phm_edw.care_gap GROUP BY 1) x; +SELECT 'bundle_detail synced (expect 0 mismatches): ' || count(*) FROM phm_star.fact_patient_bundle_detail d +JOIN phm_star.dim_patient dp ON dp.patient_key=d.patient_key AND dp.is_current +JOIN phm_star.dim_measure dm ON dm.measure_key=d.measure_key +JOIN phm_edw.care_gap cg ON cg.patient_id=dp.patient_id AND cg.measure_id=dm.measure_id +WHERE LOWER(d.gap_status) <> LOWER(cg.gap_status); +EOF +``` + +Then re-run the measure refresh (Task 6 Step 5 command from the PR #1 plan) and confirm `fact_measure_result`/strata reflect the new exclusion counts (expect excluded to drop from ~2,689 toward the clinically-evidenced count; open rises correspondingly — rates will DROP; that is correct and honest, note it in the report). + +- [ ] **Step 6:** Commit: `git add apps/api/src/services/exclusionEngine.ts apps/api/src/services/__tests__/exclusionEngine.test.ts apps/api/src/workers/nightly-scheduler.ts && git commit -m "feat: clinical exclusion engine — VSAC-evidenced exclusions replace hash-seeded demo rows"` + +### Task 8: Migration 053 + value-set-driven ACE/ARB safety flag + +**Files:** Create `packages/db/migrations/053_seed_cohort_flag_value_sets.sql`; Modify `apps/api/src/services/cohortFlags.ts` + +- [ ] **Step 1:** Migration — bind the flag to value sets as DATA (rules-as-data doctrine; the transparency endpoint `/rules/COHORT_FLAGS/...` then explains the flag's criteria for free): + +```sql +-- ============================================================================= +-- 053: Cohort safety flags bind to VSAC value sets (logic as data). +-- Replaces the hardcoded ACE/ARB name regex with the authoritative RxNorm +-- value set, and adds allergy/intolerance suppression sets the regex could +-- never express. OIDs resolved by exact value-set name at seed time. +-- ============================================================================= + +INSERT INTO phm_edw.clinical_rule (entity, attribute, value_text, source, notes) +SELECT 'COHORT_FLAGS', 'ACEARB_RXNORM_VALUE_SET_OID', vs.value_set_oid, + 'VSAC', 'ACE Inhibitor or ARB or ARNI — drives NEW_ACEARB_NO_BMP medication match' +FROM phm_edw.vsac_value_set vs WHERE vs.name = 'ACE Inhibitor or ARB or ARNI'; + +INSERT INTO phm_edw.clinical_rule (entity, attribute, value_text, source, notes) +SELECT 'COHORT_FLAGS', 'ACEARB_SUPPRESS_VALUE_SET_OID', vs.value_set_oid, + 'VSAC', vs.name || ' — suppresses NEW_ACEARB_NO_BMP when patient has documented allergy/intolerance' +FROM phm_edw.vsac_value_set vs +WHERE vs.name IN ('Allergy to ACE Inhibitor or ARB', 'Intolerance to ACE Inhibitor or ARB'); + +-- Sanity: all three rows must exist (the SELECTs insert nothing if names drift) +DO $$ +BEGIN + IF (SELECT count(*) FROM phm_edw.clinical_rule + WHERE entity='COHORT_FLAGS' AND attribute LIKE 'ACEARB%' AND active_ind='Y') < 3 THEN + RAISE EXCEPTION 'COHORT_FLAGS seed incomplete — VSAC value-set names not found'; + END IF; +END $$; +``` + +Run it; verify `SELECT attribute, value_text FROM phm_edw.clinical_rule WHERE entity='COHORT_FLAGS';` returns 3 rows. + +- [ ] **Step 2:** Locate the regex in the flag service: `grep -n "ACEARB_RE\|ACEARB" apps/api/src/services/cohortFlags.ts`. Read the surrounding flag-computation function (it matches `medication.medication_name ~* ACEARB_RE` or similar against active orders). +- [ ] **Step 3:** Replace the name-regex medication match with a code match against the value set, and add suppression. The shape (adapt identifiers to the actual function — keep its result contract identical so the worker/routes are untouched): + +```typescript +// Codes come from clinical_rule → VSAC, not a hardcoded regex: one VSAC +// re-ingest updates the flag; allergy/intolerance suppression is impossible +// to express as a name regex. +const acearbOidRows = await sql<{ value_text: string }[]>` + SELECT value_text FROM phm_edw.clinical_rule + WHERE entity = 'COHORT_FLAGS' AND attribute = 'ACEARB_RXNORM_VALUE_SET_OID' + AND active_ind = 'Y' AND expiration_date IS NULL LIMIT 1 +`; +const suppressOidRows = await sql<{ value_text: string }[]>` + SELECT value_text FROM phm_edw.clinical_rule + WHERE entity = 'COHORT_FLAGS' AND attribute = 'ACEARB_SUPPRESS_VALUE_SET_OID' + AND active_ind = 'Y' AND expiration_date IS NULL +`; +const acearbOid = acearbOidRows[0]?.value_text; +if (!acearbOid) { + // Fail loudly: a safety flag silently matching nothing is worse than crashing. + throw new Error('COHORT_FLAGS/ACEARB_RXNORM_VALUE_SET_OID missing — run migration 053'); +} +const suppressOids = suppressOidRows.map((r) => r.value_text); +``` + +…and in the patient-matching SQL replace the `medication_name ~* regex` predicate with: + +```sql +JOIN phm_edw.medication m ON m.medication_id = mo.medication_id +JOIN phm_edw.vsac_value_set_code vc + ON vc.code = m.medication_code AND vc.code_system = 'RXNORM' + AND vc.value_set_oid = ${acearbOid} +``` + +plus the suppression anti-join (only when `suppressOids.length > 0`): + +```sql +AND NOT EXISTS ( + SELECT 1 FROM phm_edw.condition_diagnosis cd + JOIN phm_edw.condition c ON c.condition_id = cd.condition_id + JOIN phm_edw.vsac_value_set_code svc + ON svc.code = c.condition_code AND svc.code_system = 'SNOMEDCT' + AND svc.value_set_oid = ANY(${suppressOids}) + WHERE cd.patient_id = AND cd.active_ind = 'Y' +) +``` + +(`= ANY(${array})` is native postgres.js array binding.) Keep the old regex constant in place but unused for ONE release? No — delete it; the rule row is the new source of truth, and `git log` preserves the regex. + +- [ ] **Step 4:** Run the cohort-flags worker path once live (find how Phase 7 triggers it — `grep -n cohortFlags apps/api/src/workers/data-quality.ts` shows the entry point) and compare flag counts before/after: + +```bash +psql -h 127.0.0.1 -U claude_dev -d medgnosis -At -c \ + "SELECT flag_key, count(*) FROM GROUP BY 1;" +``` + +Expect NEW_ACEARB_NO_BMP count in the same order of magnitude as before (regex matched 12 ingredient names; the value set covers 302 RxNorm codes including combinations — count may legitimately RISE; investigate only if it collapses to 0 or explodes implausibly). Report both numbers. + +- [ ] **Step 5:** Tests: update/extend any existing cohortFlags tests (mock the two rule lookups). Full suite + tsc green. Commit migration + service: `git commit -m "feat: ACE/ARB safety flag driven by VSAC RxNorm value set + allergy suppression (migration 053)"` + +### Task 9: Code-system contract detector (Phase 7 DQ integration) + +**Files:** Modify `apps/api/src/services/vsacService.ts` (map export), `apps/api/src/workers/data-quality.ts` (new detector) + +- [ ] **Step 1:** Export the translation map from `vsacService.ts`: + +```typescript +// EDW code_system column labels → VSAC code_system labels. The EDW CHECK +// constraints allow 'ICD-10','SNOMED','ICD-9','OTHER'; VSAC uses different +// labels. NEVER join the columns directly — translate through this map. +export const EDW_TO_VSAC_CODE_SYSTEM: Record = { + SNOMED: 'SNOMEDCT', + 'ICD-10': 'ICD10CM', + 'ICD-9': null, // VSAC eCQM extracts carry no ICD-9 — unmapped by design + OTHER: null, +}; +``` + +- [ ] **Step 2:** Read `apps/api/src/workers/data-quality.ts` — find how existing detectors (impossible vitals, weight jump, identity) are structured inside `runDqScan` and how they write `dq_finding` rows. Add a detector following the SAME structure, with this logic: + +For each of (`phm_edw.condition`, `phm_edw.procedure`): select `code_system, count(*)` grouped; for each distinct value, if `EDW_TO_VSAC_CODE_SYSTEM[value]` is `undefined` (not in the map at all) OR (maps to a VSAC label but a sampled join yields zero overlap while the EDW has >100 rows of it), write a `dq_finding` (severity `warning`, detector key `code_system_contract`, entity reference the table name, description naming the unmapped/zero-overlap system and row count). Also: one informational finding if `phm_edw.condition` contains rows with the column DEFAULT `'ICD-10'` but codes that match `^[0-9]+$` (SNOMED-shaped — the default-mislabel hazard). + +- [ ] **Step 3:** Run the DQ scan once live (same trigger path Phase 7 uses), then: `psql ... -c "SELECT detector, severity, left(description,80) FROM phm_edw.dq_finding WHERE detector='code_system_contract' ORDER BY 1;"` (confirm table/column names from the Phase 7 migration 042 first — adjust query). On today's data expect ZERO warning findings (everything is 'SNOMED'→SNOMEDCT with overlap) — the detector's value is catching tomorrow's ingest drift; verify it fires by temporarily testing the query with a fabricated unmapped label in a transaction you ROLL BACK, and state in the report that you did so. +- [ ] **Step 4:** Tests (mocked) for the detector function; suite + tsc green; commit: `git commit -m "feat: code-system contract DQ detector — EDW↔VSAC label drift surfaces as dq_finding"` + +### Task 10: Surfacing — version drift, small cells, response bound + +**Files:** Modify `apps/api/src/routes/value-sets/index.ts`, `apps/api/src/routes/measures/index.ts`, `apps/api/src/services/vsacService.ts` (if needed for drift in list response) + +- [ ] **Step 1:** `/value-sets/measure/:measureCode` response: include the bridge status — call `getMeasureBridgeStatus(measureCode)` and return `{ success: true, data: { status, value_sets } }`-shaped payload (adjust the existing 404 branch to key off `status === null`). Consumers now SEE `version_drift: true` and `unclassified_count` on every bridged measure. (This is a response-shape change to a PR-#1 endpoint with no consumers yet — safe; update any tests.) +- [ ] **Step 2:** `/value-sets/:oid/codes`: add `LIMIT 2000` to `getValueSetCodes` (review follow-up; largest loaded expansion is well under it — verify with `SELECT max(c) FROM (SELECT count(*) c FROM phm_edw.vsac_value_set_code GROUP BY value_set_oid) x;` and report the max; if any set exceeds 2000, raise the limit above it and note pagination as future work). +- [ ] **Step 3:** Strata endpoint (`/measures/:id/strata`): add `small_cell: row.denominator > 0 && row.denominator < 11` to each mapped row — display guidance (wide-CI/small-n warning), NOT suppression; this is an internal clinical tool and raw n stays visible. +- [ ] **Step 4:** tsc + suite green; smoke the three endpoints against a local boot (Task 5 pattern from the PR #1 plan: 401-vs-404 proves registration; with a minted token show one response body each). Commit: `git commit -m "feat: surface version drift, small-cell flags, and bound value-set responses"` + +### Task 11: Final Verification + +- [ ] **Step 1: Clinical truth gates** (all must hold): + +```bash +psql -h 127.0.0.1 -U claude_dev -d medgnosis -At <<'EOF' +-- a. exclusion accounting still mechanically correct +SELECT 'a (expect 0): ' || count(*) FROM phm_star.fact_measure_result WHERE exclusion_flag AND (denominator_flag OR numerator_flag); +-- b. every excluded care gap has clinical evidence (Task 7 query) +-- c. no unclassified value set is served as denominator: role-aware resolver only +SELECT 'c roles: ' || string_agg(population_role || '=' || n, ', ') FROM (SELECT population_role, count(*) n FROM phm_edw.measure_value_set GROUP BY 1) x; +EOF +``` + +- [ ] **Step 2:** Full suite + tsc + `npx turbo run typecheck lint` from root — all green locally. +- [ ] **Step 3:** Push, PR, and confirm the FULL CI rollup green (Part 1's deliverable proven on this PR — the first green pipeline since Phase 6). +- [ ] **Step 4:** PR body must state the clinically-visible effect: measure rates and exclusion counts CHANGE with this merge (hash-seeded exclusions retired → excluded drops to evidence-backed count, open gaps rise, rates drop honestly). Reviewers must read that as the point, not a regression. + +--- + +## Deferred (do NOT build in this plan) + +| Item | Why deferred | Where it lands | +|---|---|---| +| Manual role curation from eCQM CQL data criteria (the authoritative role source) | Needs the measure-spec corpus (CQL/HQMF parsing or clinician review per measure); heuristic + loud-failure default is the safe v1 | Future content task; `role_method='manual'` override path is ready | +| v14-aligned `measure_definition` upgrade (retire v12 prose) | Content work across 45 measures; drift is now machine-visible per measure | Measure-content refresh iteration | +| Encounter-domain routing (`EDW_CODE_SYSTEM` has no encounter entry; 41 bridged value sets are CPT/HCPCS-only) | Needs an EDW encounter-coding survey first | With the population-finder consumer | +| Exclusion evidence beyond conditions (hospice encounters, orders) | Conditions are the dominant evidence source in this EDW today | Exclusion engine v2 | +| 400-vs-500 on non-numeric `:id` (codebase-wide pattern) | Touches ~a dozen pre-existing routes | Dedicated chore | +| Hash-seed removal from migration 017 itself | 017 is applied demo history; the engine now corrects its output nightly | Never (engine supersedes it) |