Skip to content

feat: clinical fidelity hardening — population roles, real exclusions, value-set safety flags#3

Merged
sudoshi merged 42 commits into
mainfrom
feature/clinical-fidelity-hardening
Jun 13, 2026
Merged

feat: clinical fidelity hardening — population roles, real exclusions, value-set safety flags#3
sudoshi merged 42 commits into
mainfrom
feature/clinical-fidelity-hardening

Conversation

@sudoshi

@sudoshi sudoshi commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Summary

Makes the measure/VSAC layer clinically TRUE, per the adversarial safety review of PR #1:

  • Population roles on the bridge (migration 052): every measure↔value-set link classified denominator_exclusion / initial_population / supplemental / unclassified (conservative name heuristic, role_method provenance, manual-override ready). resolveMeasureCodes now requires a role — the 82% exclusion-code contamination found in review (CMS122: 2,114 hospice/advanced-illness/frailty codes unioned with 30 encounter codes) is structurally impossible to consume by accident. During implementation a heuristic bug was caught pre-run: nursing-facility encounter sets would have been misclassified as exclusions, silently suppressing care gaps for nursing-facility patients across 9 measures.
  • Clinical exclusion engine: gap_status='excluded' was fabricated by a deterministic hash (migration 017) — 2,689 rows with zero clinical input. Now computed nightly from the imported exclusion-family value sets with provenance comments; all 2,689 unevidenced rows reverted to open. Measure rates change with this merge — downward, honestly (care_gap now: closed=6,697 / open=20,270 / excluded=0 until real exclusion evidence exists). That is the point, not a regression.
  • ACE/ARB safety flag (migration 053): Phase 7's 12-name regex replaced by the ACE Inhibitor or ARB or ARNI RxNorm value set (151 codes) bound through clinical_rule (transparency endpoint explains it), plus allergy/intolerance suppression the regex could never express. Flag counts stable (367→367) — convergence evidence, with indexed query plans verified.
  • Code-system contract DQ detector: EDW_TO_VSAC_CODE_SYSTEM translation map (SNOMED→SNOMEDCT, ICD-10→ICD10CM) + a Phase 7-style detector that flags unmapped/zero-overlap code systems and SNOMED-shaped codes mislabeled by the 'ICD-10' column default. Inert on today's data; fire-tested via rolled-back probe.
  • Surfacing: GET /value-sets/measure/:code now returns bridge status (version_drift: true for all 44 v12↔v14 bridges, role coverage, unclassified count); strata rows carry small_cell (n<11) display guidance; value-set codes endpoint bounded (LIMIT 12,000 — largest real expansion is 11,539).

Contains PR #1 and PR #2

This branch merges feature/cds-vsac-value-sets (#1, VSAC asset) and fix/ci-pipeline (#2, CI restoration) — merging this PR lands all three. Alternatively merge in order #2#1 → this for granular history; #1/#2 then reduce to no-ops.

Test plan

  • 175/175 tests (22 files), turbo typecheck + turbo lint green locally
  • Clinical gates: 0 exclusion-accounting violations; 0 excluded rows without clinical evidence; strata reconcile exactly with facts; 3 COHORT_FLAGS rules seeded
  • Live smoke: role-separated resolution (CMS122: 2,114 excl / 30 IP / 564 unclassified), bridge-status + small_cell response bodies verified against a booted worktree server
  • Full migration chain validated on a scratch database (via fix: restore CI pipeline (red since Phase 6 merge) #2's work — ten previously-unrunnable migrations guarded)
  • CI rollup green on this PR (contains fix: restore CI pipeline (red since Phase 6 merge) #2's fixes, so checks should pass standalone)

🤖 Generated with Claude Code

sudoshi and others added 30 commits June 12, 2026 18:44
…dge 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.
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.
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.
… /:oid/codes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n 051)

- 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
…terface

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.
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.
… review)

- 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)
Adds population_role (NOT NULL, default 'unclassified') and role_method
columns to phm_edw.measure_value_set with CHECK constraints. Heuristic
seeds 518 of 1,015 rows:

  denominator_exclusion: 138 rows (hospice, palliative, advanced illness,
    frailty, dementia medications)
  initial_population:    248 rows (office visits, encounter types, wellness,
    telehealth, preventive care)
  supplemental:          132 rows (race, ethnicity, payer type)
  unclassified:          497 rows (clinical condition/lab/medication sets
    where role cannot be inferred from name alone)

CLINICAL SAFETY FIX: 'nursing facility' and 'long.term care' removed from
the exclusion regex — in this VSAC dataset those patterns match only
qdm_category='Encounter' types (Nursing Facility Visit, Discharge Services
Nursing Facility, Care Services in Long Term Residential Facility), which
are qualifying encounters for CMS128/135/139/142/143/144/145/149/156.
Labeling them denominator_exclusion would incorrectly suppress care gaps
for patients whose qualifying visit was in a nursing facility setting.
These three sets are now correctly classified as initial_population via
explicit name matching in the encounter UPDATE.

Hospice Encounter, Palliative Care Encounter, and Frailty Encounter match
BOTH heuristics but are protected by the 'AND population_role = unclassified'
guard on the encounter UPDATE — they stay denominator_exclusion (correct per
eCQM specs). Migration also annotates the role column with a clinical safety
comment.
resolveMeasureCodes now requires a PopulationRole third argument and adds
AND mv.population_role = ${role} to the query — callers must consciously
choose a role; silent denominator+exclusion union is impossible.

getMeasureBridgeStatus returns version_drift, per-role code counts, and
unclassified_count for a measure, enabling consumers to detect bridging gaps.

Export PopulationRole type and MeasureBridgeStatus interface.

Replace the pre-migration SAFETY warning comment with the post-052 contract:
role-aware resolver; 'unclassified' is audit-only, never a denominator.

Live CMS122v12 SNOMEDCT role distribution:
  denominator_exclusion: 2114 (the contamination, now labeled and filterable)
  initial_population:      30
  unclassified:           564

Tests: RED (3 failing) → GREEN (10 passing). tsc --noEmit clean.
No callers of resolveMeasureCodes outside tests on this branch.
…ash-seeded demo rows

Live run result: newlyExcluded=0, revertedToOpen=2689, durationMs=66344
- Demo data has 8 distinct condition codes matching denominator_exclusion VSAC
  sets but none are linked via active condition_diagnosis rows to care gap
  patients — so 0 newly excluded (correct: no clinical evidence).
- All 2,689 hash-seeded excluded rows (migration 017) reverted to 'open'.
- fact_patient_bundle_detail synced in same transaction (0 mismatches after).
- Measure refresh: 26,967 rows, 305ms; exclusion_flag=0 in fact_measure_result.
- Regression gate: 0 rows with exclusion_flag AND (denominator_flag OR numerator_flag).
- care_gap: closed=6697, open=20270 (no excluded rows — honest, not suppressed).
- Wired into nightly-scheduler.ts BEFORE measureQueue.add so each nightly
  refresh reads corrected bundle_detail statuses.

Note: nightly-scheduler.ts is PR #1-era (predates Phases 5–8 additions on main).
Rebase onto main will need trivial conflict resolution at the measureQueue.add site.
…lity-hardening

# Conflicts:
#	apps/api/src/workers/nightly-scheduler.ts
…c a no-op on fresh checkouts

On a clean clone tsc saw stale tsbuildinfo files with matching hashes and
emitted nothing, so packages/db and packages/shared dist/ never existed.
packages/solr then failed with TS2307 Cannot find module '@medgnosis/db'.

- git rm --cached the three committed .tsbuildinfo files
- Add *.tsbuildinfo to .gitignore to prevent re-committing
…y when ohdsi source unreachable

The Synthea ETL pulls from a local 'ohdsi' database that only exists on
the dev host. On CI (and any fresh environment) the dblink connection fails,
aborting the migration. Restructured all dblink-dependent INSERTs into a
single DO $$ ... EXCEPTION WHEN OTHERS THEN RAISE NOTICE ... END $$; block
so that a connection failure skips the data load with a clear notice instead
of failing. The TRUNCATE statements run unconditionally (they are idempotent
on an empty DB). All SQL logic preserved exactly — only the exception envelope
was added.
…T EXISTS

Migration 010 already created phm_edw.clinical_note with a SERIAL PK and base
SOAP columns. 012 used CREATE TABLE IF NOT EXISTS (silently skipped on fresh DB)
then tried to create an index on author_user_id which doesn't exist in the 010
schema, causing a hard failure on every fresh checkout. Converted to idempotent
ALTER TABLE ... ADD COLUMN IF NOT EXISTS for the 8 columns 012 adds over 010.
The provider_schedule INSERT (and downstream care_team, order_set, etc.)
all reference provider_id=2816 which only exists after the Synthea ETL runs.
On CI / fresh DB this causes a FK violation that aborts the migration. Added
EXCEPTION WHEN OTHERS to the existing DO block to skip gracefully with a NOTICE.
…ns on empty DB

Parts A-D and Part H both INSERT into tables with FK to phm_edw.provider(2816)
which doesn't exist on CI/fresh DB. Added EXCEPTION WHEN OTHERS guards to both
DO blocks. Parts E-G and I-J are top-level INSERTs filtered by pcp_provider_id=2816
— they return 0 rows on empty DB so no FK error there.
…uppression (migration 053)

Migration 053 seeds 3 clinical_rule rows binding NEW_ACEARB_NO_BMP to VSAC:
- ACEARB_RXNORM_VALUE_SET_OID: 2.16.840.1.113883.3.526.2.39 (ACE Inhibitor or ARB or ARNI, 151 RXNORM codes)
- ACEARB_SUPPRESS_VALUE_SET_OID x2: Allergy + Intolerance to ACE Inhibitor or ARB (SNOMEDCT)

cohortFlags.ts: replaces 12-drug name regex with code join via VSAC value set OID loaded
from clinical_rule at runtime. Adds allergy/intolerance suppression anti-join (0 patients
suppressed in demo data — machinery is the deliverable). Fails loudly if rule rows missing.

Before/after flag counts: NEW_ACEARB_NO_BMP 367→367, GFR_LOW 110→110 (count stable —
RxNorm code join covers same medications as the regex in this demo dataset).
Suppression evidence: 0 patients with documented ACE/ARB allergy/intolerance in demo data.

Tests: 166 passing (21 files), up from 162 baseline (+4 new runCohortFlags tests).
tsc: clean.
… doesn't exist)

billing_claim table (defined in 011) has no org_id column. The INSERT in
Part E included it causing a hard failure on every fresh checkout. Removed
org_id from the column list and the corresponding subquery value.
…rder_datetime→start_datetime, etc.)

medication_order (defined in 001) has no sig, quantity_dispensed, days_supply,
refills, or order_datetime columns. Map to actual columns: dosage, refill_count,
start_datetime. Literal defaults used for quantity/days_supply (not in schema).
Patient lookups by pcp_provider_id=2816 return NULL on CI/fresh DB,
causing NOT NULL violations on cancer_staging and related tables.
Added EXCEPTION WHEN OTHERS guard to the single DO block.
sudoshi added 12 commits June 12, 2026 21:10
…s migration runner

\echo and \i are psql-only meta-commands; the Node.js postgres driver throws
a syntax error on them. This is a validation/reporting migration with no DDL.
Removed all \echo output lines and the \i re-include of 014 (already ran as
its own migration). All SELECT validation queries preserved — they return 0
rows on empty DB which is correct and harmless.
CONCURRENTLY cannot run inside a transaction block and the migration runner
wraps each file in a transaction. Drop-in replacement: same DDL, no lock
semantics needed on a fresh/empty DB. Production already has these indexes
so this only affects fresh checkouts.
…to _migrations

The runner (migrate.ts) already inserts the migration name after executing the
SQL in the same transaction. 030's own INSERT into _migrations caused a unique
constraint violation. Removed the redundant self-registration.
clinical_note.note_id is SERIAL (INT) per migration 010. The FK reference
from note_coded_diagnosis(note_id UUID) caused a type mismatch that prevented
constraint creation. Changed to INT to match the PK type.
clinical_note.note_id is SERIAL (INT); inserting gen_random_uuid() caused a
type mismatch. Removed note_id from the column list and gen_random_uuid()
from the SELECT — the SERIAL generates the PK automatically and RETURNING
note_id still returns the correct integer value.
… rejects WITH...INSERT)

The data-modifying CTE (WITH distinct_addresses AS (...) INSERT INTO...)
is not valid PL/pgSQL syntax; it caused syntax error at position 2976 on CI's
PostgreSQL. Rewrote as a plain INSERT INTO...SELECT with the multi-source UNION
dblink query inlined directly using string concatenation to avoid multi-line
$$ conflicts inside the $etl$ DO block.
web package has no unit tests yet; vitest exits 1 on empty test suite
by default. Add passWithNoTests: true so CI passes until tests are added.
…s as dq_finding

Adds EDW_TO_VSAC_CODE_SYSTEM map to vsacService.ts and a new
code_system_contract detector in dqDetectors.ts that runs as part of
runDqScan(). The detector checks phm_edw.condition and phm_edw.procedure
(small tables; full scans safe) against the map and fires:

  - warning: code_system value absent from EDW_TO_VSAC_CODE_SYSTEM entirely
    (unknown label — schema drift or mis-ingest)
  - warning: code_system is null-mapped (ICD-9, OTHER) and rows exist
    (cannot reconcile against VSAC eCQM extracts)
  - warning: code_system maps to a VSAC label but LIMIT-500 sample join
    against vsac_value_set_code yields zero overlap with >100 EDW rows
  - info: condition rows with code_system='ICD-10' (column DEFAULT) but
    condition_code matching ^[0-9]+$ (SNOMED-shaped — default mislabel hazard)

phm_edw.observation is explicitly out of scope (~1B rows, no code_system
column). entity_id=0 is a sentinel for aggregate findings so ON CONFLICT
dedup works across re-runs without a real PK.

Live scan on 2026-06-12 data: zero warnings (SNOMED→SNOMEDCT overlaps fine).
Rollback test confirmed: inserting OTHER row inside a BEGIN/ROLLBACK shows
code_system=OTHER|1 in the detector SQL; live table unchanged (SNOMED|324).

Tests: 9 new mocked-unit tests covering all firing/non-firing paths.
Suite: 175/22 (was 166/21). tsc: clean.
…sponses

- GET /value-sets/measure/:measureCode now calls getMeasureBridgeStatus()
  alongside getMeasureValueSets(); 404 gates on status===null; response
  shape is { status, value_sets } so consumers see version_drift and
  unclassified_count on every bridged measure.

- getValueSetCodes: adds LIMIT 12000 (max loaded expansion is 11,539 codes,
  verified 2026-06-12); pagination deferred as future work.

- GET /measures/:id/strata: adds small_cell: denominator > 0 && denominator < 11
  to every row — display guidance for wide-CI small-n strata, not suppression;
  raw n remains visible (internal clinical tool).
…aded

Fresh environments (CI) run migrations before the manual VSAC load; the
hard gate now fires only on the real failure mode (data loaded but value-set
names drifted). Full chain re-validated on a scratch database.
@sudoshi sudoshi merged commit 4346b24 into main Jun 13, 2026
6 checks passed
@sudoshi sudoshi deleted the feature/clinical-fidelity-hardening branch June 13, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant