From 71d4f64808074ac946923bd49d1975702bb917b7 Mon Sep 17 00:00:00 2001 From: Taleef Date: Mon, 8 Jun 2026 11:08:37 -0400 Subject: [PATCH] feat(measure): promote CMS125 + CMS122 to Active with full CQL evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CMS125 (Breast Cancer Screening) and CMS122 (Diabetes HbA1c Poor Control) are now runnable Active measures with full CQL libraries, synthetic employee seed data covering all 5 outcome buckets, and test coverage in CqlEvaluationServiceTest. Both measures are seeded before ensureCmsEcqmCatalogSeed() so the catalog seed leaves the Active v1.0 intact. - cms125.cql: 820-day mammogram compliance window (27 months); COMPLIANT / DUE_SOON / OVERDUE / MISSING_DATA / EXCLUDED using inline code-filter pattern - cms122.cql: numeric Observation-based — HbA1c > 9% → OVERDUE (poor control); ≤ 9% → COMPLIANT; no result → MISSING_DATA - SyntheticFhirBundleBuilder: new observationValue field on ExamConfig for numeric lab values (null-safe; existing Procedure/Immunization path unchanged) - CqlEvaluationService: two new MeasureSeedSpec cases + observationBased dispatch for Observation-type measures - MeasureService: ensureCms125Seed() + ensureCms122Seed() with idempotent upsert (updates CQL text if v1.0 already exists; inserts Active v1.0 if not) - docs/MEASURES.md: added Category 3b; updated catalog summary (10 runnable, 47 Draft catalog entries; total remains 60) Co-Authored-By: Claude Sonnet 4.6 --- .../compile/CqlEvaluationService.java | 59 ++++++++- .../compile/SyntheticFhirBundleBuilder.java | 40 +++++- .../com/workwell/measure/MeasureService.java | 123 ++++++++++++++++++ .../src/main/resources/measures/cms122.cql | 67 ++++++++++ .../src/main/resources/measures/cms125.cql | 65 +++++++++ .../compile/CqlEvaluationServiceTest.java | 76 +++++++++++ docs/MEASURES.md | 37 +++++- 7 files changed, 460 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/resources/measures/cms122.cql create mode 100644 backend/src/main/resources/measures/cms125.cql diff --git a/backend/src/main/java/com/workwell/compile/CqlEvaluationService.java b/backend/src/main/java/com/workwell/compile/CqlEvaluationService.java index 9e629b5..68e56d7 100644 --- a/backend/src/main/java/com/workwell/compile/CqlEvaluationService.java +++ b/backend/src/main/java/com/workwell/compile/CqlEvaluationService.java @@ -566,6 +566,29 @@ private MeasureSeedSpec measureSeedSpecFor(String measureName) { "urn:workwell:vs:ldl-labs", false ); + case "Breast Cancer Screening" -> new MeasureSeedSpec( + "cms125", + "cms125-eligible", + "urn:workwell:vs:cms125-eligible", + "cms125-excluded", + "urn:workwell:vs:cms125-excluded", + "mammogram", + "urn:workwell:vs:cms125-mammogram", + false, + 820 + ); + case "Diabetes: Hemoglobin A1c (HbA1c) Poor Control (> 9%)" -> new MeasureSeedSpec( + "cms122", + "cms122-diabetes", + "urn:workwell:vs:cms122-diabetes", + "cms122-excluded", + "urn:workwell:vs:cms122-excluded", + "hba1c-obs", + "urn:workwell:vs:cms122-hba1c", + false, + 365, + true + ); default -> null; }; } @@ -576,6 +599,29 @@ private SeededInput input( SeededOutcome targetOutcome ) { int window = spec.complianceWindowDays(); + boolean hasWaiver = targetOutcome == SeededOutcome.EXCLUDED; + + // Observation-based measures (e.g. CMS122 HbA1c) derive compliance from a + // numeric value, not a recency window. Use observationValue; daysSinceLastExam + // is set to a recent value so the observation has a timestamp. + if (spec.observationBased()) { + Float observationValue = switch (targetOutcome) { + case COMPLIANT -> 7.5f; + case OVERDUE -> 10.5f; + case MISSING_DATA, DUE_SOON -> null; + case EXCLUDED -> 7.5f; + }; + Integer daysAgo = observationValue != null ? 30 : null; + SyntheticFhirBundleBuilder.ExamConfig config = new SyntheticFhirBundleBuilder.ExamConfig( + daysAgo, hasWaiver, true, + spec.enrollmentCode(), spec.enrollmentVs(), + spec.waiverCode(), spec.waiverVs(), + spec.examCode(), spec.examVs(), false, + observationValue + ); + return new SeededInput(employee, config, targetOutcome.name()); + } + Integer daysSinceLastExam = switch (targetOutcome) { case COMPLIANT -> window / 3; case DUE_SOON -> window - 10; @@ -583,7 +629,6 @@ private SeededInput input( case MISSING_DATA -> null; case EXCLUDED -> window + 150; }; - boolean hasWaiver = targetOutcome == SeededOutcome.EXCLUDED; SyntheticFhirBundleBuilder.ExamConfig config = new SyntheticFhirBundleBuilder.ExamConfig( daysSinceLastExam, hasWaiver, @@ -629,13 +674,21 @@ private record MeasureSeedSpec( String examCode, String examVs, boolean useImmunization, - int complianceWindowDays + int complianceWindowDays, + boolean observationBased ) { MeasureSeedSpec(String rateKey, String enrollmentCode, String enrollmentVs, String waiverCode, String waiverVs, String examCode, String examVs, boolean useImmunization) { this(rateKey, enrollmentCode, enrollmentVs, waiverCode, waiverVs, - examCode, examVs, useImmunization, 365); + examCode, examVs, useImmunization, 365, false); + } + + MeasureSeedSpec(String rateKey, String enrollmentCode, String enrollmentVs, + String waiverCode, String waiverVs, String examCode, String examVs, + boolean useImmunization, int complianceWindowDays) { + this(rateKey, enrollmentCode, enrollmentVs, waiverCode, waiverVs, + examCode, examVs, useImmunization, complianceWindowDays, false); } } } diff --git a/backend/src/main/java/com/workwell/compile/SyntheticFhirBundleBuilder.java b/backend/src/main/java/com/workwell/compile/SyntheticFhirBundleBuilder.java index f137861..41c1e25 100644 --- a/backend/src/main/java/com/workwell/compile/SyntheticFhirBundleBuilder.java +++ b/backend/src/main/java/com/workwell/compile/SyntheticFhirBundleBuilder.java @@ -10,8 +10,10 @@ import org.hl7.fhir.r4.model.Enumerations; import org.hl7.fhir.r4.model.HumanName; import org.hl7.fhir.r4.model.Immunization; +import org.hl7.fhir.r4.model.Observation; import org.hl7.fhir.r4.model.Patient; import org.hl7.fhir.r4.model.Procedure; +import org.hl7.fhir.r4.model.Quantity; import org.hl7.fhir.r4.model.Reference; public class SyntheticFhirBundleBuilder { @@ -49,7 +51,31 @@ public Bundle buildBundle( )); } - if (config.daysSinceLastExam() != null) { + if (config.observationValue() != null) { + Observation observation = new Observation(); + observation.setId(employee.externalId() + "-observation"); + observation.setStatus(Observation.ObservationStatus.FINAL); + observation.setSubject(new Reference("Patient/" + employee.externalId())); + observation.getCode().addCoding() + .setSystem(config.examValueSet()) + .setCode(config.examCode()) + .setDisplay(config.examCode()); + if (config.daysSinceLastExam() != null) { + String effectiveDateTime = evaluationDate + .minusDays(config.daysSinceLastExam()) + .atStartOfDay() + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + observation.setEffective(new org.hl7.fhir.r4.model.DateTimeType(effectiveDateTime)); + } + observation.setValue(new Quantity() + .setValue(java.math.BigDecimal.valueOf(config.observationValue())) + .setUnit("%") + .setSystem("http://unitsofmeasure.org") + .setCode("%")); + bundle.addEntry().setResource(observation); + } + + if (config.daysSinceLastExam() != null && config.observationValue() == null) { String performedDateTime = evaluationDate .minusDays(config.daysSinceLastExam()) .atStartOfDay() @@ -106,7 +132,17 @@ public record ExamConfig( String waiverValueSet, String examCode, String examValueSet, - boolean useImmunization + boolean useImmunization, + Float observationValue ) { + public ExamConfig(Integer daysSinceLastExam, boolean hasWaiver, boolean programEnrolled, + String programEnrollmentCode, String programEnrollmentValueSet, + String waiverCode, String waiverValueSet, + String examCode, String examValueSet, boolean useImmunization) { + this(daysSinceLastExam, hasWaiver, programEnrolled, + programEnrollmentCode, programEnrollmentValueSet, + waiverCode, waiverValueSet, examCode, examValueSet, + useImmunization, null); + } } } diff --git a/backend/src/main/java/com/workwell/measure/MeasureService.java b/backend/src/main/java/com/workwell/measure/MeasureService.java index 23a2a8b..3a2581f 100644 --- a/backend/src/main/java/com/workwell/measure/MeasureService.java +++ b/backend/src/main/java/com/workwell/measure/MeasureService.java @@ -127,6 +127,8 @@ private void ensureInstanceSeeds() { ensureDiabetesHbA1cSeed(); ensureObesityBmiSeed(); ensureCholesterolLdlSeed(); + ensureCms125Seed(); + ensureCms122Seed(); ensureCmsEcqmCatalogSeed(); } } @@ -1151,6 +1153,127 @@ private void ensureCmsEcqmCatalogSeed() { } } + private void ensureCms125Seed() { + // CMS125 — Breast Cancer Screening (mammogram within 27 months / 820 days). + // We seed this as Active with full CQL BEFORE ensureCmsEcqmCatalogSeed() runs. + // Because that method skips existing v1.0 versions, the Active record persists. + // policy_ref must start with "CMS125v" so the catalog seed can match by prefix. + UUID measureId; + try { + measureId = jdbcTemplate.queryForObject( + "SELECT id FROM measures WHERE policy_ref LIKE 'CMS125v%'", + UUID.class + ); + } catch (EmptyResultDataAccessException ex) { + measureId = UUID.randomUUID(); + jdbcTemplate.update( + "INSERT INTO measures (id, name, policy_ref, owner, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?::text[], NOW(), NOW())", + measureId, + "Breast Cancer Screening", + "CMS125v14", + "WorkWell Studio", + "{ecqm,cms,cancer-screening,preventive}" + ); + } + + Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM measure_versions WHERE measure_id = ? AND version = ?", + Integer.class, measureId, "v1.0" + ); + if (existing != null && existing > 0) { + jdbcTemplate.update( + "UPDATE measure_versions SET cql_text = ?, compile_status = 'COMPILED', compile_result = ?::jsonb WHERE measure_id = ? AND version = ?", + loadSeedCql("cms125.cql"), + toJson(Map.of("status", "COMPILED", "warnings", List.of(), "errors", List.of())), + measureId, "v1.0" + ); + return; + } + + Map spec = new LinkedHashMap<>(); + spec.put("description", "Breast Cancer Screening (CMS125v14 / MIPS 112): women 50–74 who had a mammogram in the measurement period or 26 months prior."); + spec.put("eligibilityCriteria", Map.of( + "roleFilter", "All", + "siteFilter", "All Sites", + "programEnrollmentText", "Breast Cancer Screening Eligible" + )); + spec.put("exclusions", List.of(Map.of("label", "Clinical Exclusion", "criteriaText", "Bilateral mastectomy or history of breast cancer — documented exclusion on file"))); + spec.put("complianceWindow", "27 months (820 days)"); + spec.put("requiredDataElements", List.of("Last mammogram date", "Eligible population flag", "Exclusion status")); + spec.put("testFixtures", List.of()); + spec.put("cmsEcqmId", "CMS125v14"); + spec.put("mipsQualityId", "112"); + + jdbcTemplate.update( + "INSERT INTO measure_versions (id, measure_id, version, status, spec_json, cql_text, compile_status, compile_result, change_summary, approved_by, activated_at, created_at) VALUES (?, ?, ?, ?, ?::jsonb, ?, ?, ?::jsonb, ?, ?, NOW(), NOW())", + UUID.randomUUID(), measureId, "v1.0", "Active", + toJson(spec), loadSeedCql("cms125.cql"), "COMPILED", + toJson(Map.of("status", "COMPILED", "warnings", List.of(), "errors", List.of())), + "CMS125v14 Breast Cancer Screening — Active seed with full CQL (27-month mammogram window)", + "system" + ); + } + + private void ensureCms122Seed() { + // CMS122 — Diabetes: HbA1c Poor Control (> 9%). + // Numeric Observation-based measure. An HbA1c value > 9% → OVERDUE (poor control). + // policy_ref must start with "CMS122v" so the catalog seed can match by prefix. + UUID measureId; + try { + measureId = jdbcTemplate.queryForObject( + "SELECT id FROM measures WHERE policy_ref LIKE 'CMS122v%'", + UUID.class + ); + } catch (EmptyResultDataAccessException ex) { + measureId = UUID.randomUUID(); + jdbcTemplate.update( + "INSERT INTO measures (id, name, policy_ref, owner, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?::text[], NOW(), NOW())", + measureId, + "Diabetes: Hemoglobin A1c (HbA1c) Poor Control (> 9%)", + "CMS122v14", + "WorkWell Studio", + "{ecqm,cms,diabetes}" + ); + } + + Integer existing = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM measure_versions WHERE measure_id = ? AND version = ?", + Integer.class, measureId, "v1.0" + ); + if (existing != null && existing > 0) { + jdbcTemplate.update( + "UPDATE measure_versions SET cql_text = ?, compile_status = 'COMPILED', compile_result = ?::jsonb WHERE measure_id = ? AND version = ?", + loadSeedCql("cms122.cql"), + toJson(Map.of("status", "COMPILED", "warnings", List.of(), "errors", List.of())), + measureId, "v1.0" + ); + return; + } + + Map spec = new LinkedHashMap<>(); + spec.put("description", "Diabetes: HbA1c Poor Control (CMS122v14 / MIPS 1): patients 18–75 with diabetes whose most recent HbA1c result is > 9% (poor control). OVERDUE indicates intervention is needed."); + spec.put("eligibilityCriteria", Map.of( + "roleFilter", "All", + "siteFilter", "All Sites", + "programEnrollmentText", "Diabetes Diagnosis" + )); + spec.put("exclusions", List.of(Map.of("label", "Clinical Exclusion", "criteriaText", "Hospice care, advanced illness, or other clinical exclusion"))); + spec.put("complianceWindow", "Annual — based on HbA1c value, not recency"); + spec.put("requiredDataElements", List.of("Most recent HbA1c lab value", "Diabetes diagnosis", "Exclusion status")); + spec.put("testFixtures", List.of()); + spec.put("cmsEcqmId", "CMS122v14"); + spec.put("mipsQualityId", "1"); + + jdbcTemplate.update( + "INSERT INTO measure_versions (id, measure_id, version, status, spec_json, cql_text, compile_status, compile_result, change_summary, approved_by, activated_at, created_at) VALUES (?, ?, ?, ?, ?::jsonb, ?, ?, ?::jsonb, ?, ?, NOW(), NOW())", + UUID.randomUUID(), measureId, "v1.0", "Active", + toJson(spec), loadSeedCql("cms122.cql"), "COMPILED", + toJson(Map.of("status", "COMPILED", "warnings", List.of(), "errors", List.of())), + "CMS122v14 Diabetes HbA1c Poor Control — Active seed with numeric Observation CQL", + "system" + ); + } + private void ensureHypertensionSeed() { UUID measureId; try { diff --git a/backend/src/main/resources/measures/cms122.cql b/backend/src/main/resources/measures/cms122.cql new file mode 100644 index 0000000..2d4fa0f --- /dev/null +++ b/backend/src/main/resources/measures/cms122.cql @@ -0,0 +1,67 @@ +library DiabetesHbA1cPoorControlCQL version '1.0.0' +using FHIR version '4.0.1' +include FHIRHelpers version '4.0.1' called FHIRHelpers + +valueset "HbA1c Lab Tests": 'urn:workwell:vs:cms122-hba1c' +valueset "Diabetes Diagnosis": 'urn:workwell:vs:cms122-diabetes' +valueset "CMS122 Exclusions": 'urn:workwell:vs:cms122-excluded' + +parameter "Measurement Period" Interval +context Patient + +define "Has Diabetes Diagnosis": + exists([Condition] C + where exists(C.code.coding x where x.system = 'urn:workwell:vs:cms122-diabetes' and x.code = 'cms122-diabetes')) + +define "Has Exclusion": + exists([Condition] C + where exists(C.code.coding x where x.system = 'urn:workwell:vs:cms122-excluded' and x.code = 'cms122-excluded')) + +define "Most Recent HbA1c Observation": + Last( + [Observation] O + where exists(O.code.coding C where C.system = 'urn:workwell:vs:cms122-hba1c' and C.code = 'hba1c-obs') + sort by (effective as FHIR.dateTime) + ) + +define "Most Recent HbA1c Value": + "Most Recent HbA1c Observation".value as FHIR.Quantity + +define "HbA1c Poor Control": + "Most Recent HbA1c Value".value > 9 + +define "Has Recent HbA1c Result": + "Most Recent HbA1c Observation" is not null + +define "Compliant": + "Has Diabetes Diagnosis" + and not "Has Exclusion" + and "Has Recent HbA1c Result" + and not "HbA1c Poor Control" + +define "Due Soon": + false + +define "Overdue": + "Has Diabetes Diagnosis" + and not "Has Exclusion" + and "Has Recent HbA1c Result" + and "HbA1c Poor Control" + +define "Missing Data": + "Has Diabetes Diagnosis" + and not "Has Exclusion" + and not "Has Recent HbA1c Result" + +define "Excluded": + "Has Exclusion" + +define "Initial Population": + "Has Diabetes Diagnosis" or "Has Exclusion" + +define "Outcome Status": + if "Excluded" then 'EXCLUDED' + else if "Missing Data" then 'MISSING_DATA' + else if "Overdue" then 'OVERDUE' + else if "Compliant" then 'COMPLIANT' + else 'MISSING_DATA' diff --git a/backend/src/main/resources/measures/cms125.cql b/backend/src/main/resources/measures/cms125.cql new file mode 100644 index 0000000..9994a20 --- /dev/null +++ b/backend/src/main/resources/measures/cms125.cql @@ -0,0 +1,65 @@ +library BreastCancerScreeningCQL version '1.0.0' +using FHIR version '4.0.1' +include FHIRHelpers version '4.0.1' called FHIRHelpers + +valueset "Mammography Procedures": 'urn:workwell:vs:cms125-mammogram' +valueset "Breast Cancer Screening Eligible": 'urn:workwell:vs:cms125-eligible' +valueset "Breast Cancer Screening Exclusions": 'urn:workwell:vs:cms125-excluded' + +parameter "Measurement Period" Interval +context Patient + +define "In Screening Population": + exists([Condition] C + where exists(C.code.coding x where x.system = 'urn:workwell:vs:cms125-eligible' and x.code = 'cms125-eligible')) + +define "Has Exclusion": + exists([Condition] C + where exists(C.code.coding x where x.system = 'urn:workwell:vs:cms125-excluded' and x.code = 'cms125-excluded')) + +define "Most Recent Mammogram Date": + Last( + [Procedure] P + where exists(P.code.coding C where C.system = 'urn:workwell:vs:cms125-mammogram' and C.code = 'mammogram') + sort by (performed as FHIR.dateTime) + ).performed as FHIR.dateTime + +define "Days Since Last Mammogram": + difference in days between + Coalesce("Most Recent Mammogram Date", @1900-01-01T00:00:00.0) + and Now() + +define "Compliant": + "In Screening Population" + and not "Has Exclusion" + and "Days Since Last Mammogram" <= 790 + +define "Due Soon": + "In Screening Population" + and not "Has Exclusion" + and "Days Since Last Mammogram" > 790 + and "Days Since Last Mammogram" <= 820 + +define "Overdue": + "In Screening Population" + and not "Has Exclusion" + and "Days Since Last Mammogram" > 820 + +define "Missing Data": + "In Screening Population" + and not "Has Exclusion" + and "Most Recent Mammogram Date" is null + +define "Excluded": + "Has Exclusion" + +define "Initial Population": + "In Screening Population" or "Has Exclusion" + +define "Outcome Status": + if "Excluded" then 'EXCLUDED' + else if "Missing Data" then 'MISSING_DATA' + else if "Overdue" then 'OVERDUE' + else if "Due Soon" then 'DUE_SOON' + else if "Compliant" then 'COMPLIANT' + else 'MISSING_DATA' diff --git a/backend/src/test/java/com/workwell/compile/CqlEvaluationServiceTest.java b/backend/src/test/java/com/workwell/compile/CqlEvaluationServiceTest.java index 1a2329a..1bae04c 100644 --- a/backend/src/test/java/com/workwell/compile/CqlEvaluationServiceTest.java +++ b/backend/src/test/java/com/workwell/compile/CqlEvaluationServiceTest.java @@ -173,6 +173,82 @@ void tbHazwoperAndFluEvaluationsProduceStructuredOutcomes() throws Exception { } } + @Test + @SuppressWarnings("unchecked") + void cms125BreastCancerScreeningProducesAllFiveOutcomeBuckets() throws Exception { + CqlEvaluationService service = new CqlEvaluationService(defaultPopulationProperties()); + String cqlText = readClasspathText("measures/cms125.cql"); + + DemoRunPayload payload = service.evaluate( + "44444444-4444-4444-4444-444444444444", + "Breast Cancer Screening", + "v1.0", + cqlText, + LocalDate.now() + ); + + assertEquals(100, payload.outcomes().size(), "CMS125 should evaluate every seeded employee"); + + long compliant = payload.outcomes().stream().filter(o -> "COMPLIANT".equals(o.outcome())).count(); + long dueSoon = payload.outcomes().stream().filter(o -> "DUE_SOON".equals(o.outcome())).count(); + long overdue = payload.outcomes().stream().filter(o -> "OVERDUE".equals(o.outcome())).count(); + long missingData = payload.outcomes().stream().filter(o -> "MISSING_DATA".equals(o.outcome())).count(); + long excluded = payload.outcomes().stream().filter(o -> "EXCLUDED".equals(o.outcome())).count(); + + assertTrue(compliant > 0, "CMS125 should produce COMPLIANT outcomes"); + assertTrue(dueSoon > 0, "CMS125 should produce DUE_SOON outcomes"); + assertTrue(overdue > 0, "CMS125 should produce OVERDUE outcomes"); + assertTrue(missingData > 0, "CMS125 should produce MISSING_DATA outcomes"); + assertTrue(excluded > 0, "CMS125 should produce EXCLUDED outcomes"); + + DemoOutcome overdueOutcome = payload.outcomes().stream() + .filter(o -> "OVERDUE".equals(o.outcome())) + .findFirst() + .orElseThrow(); + List> expressionResults = (List>) overdueOutcome.evidenceJson().get("expressionResults"); + assertNotNull(expressionResults); + assertTrue(expressionResults.stream().anyMatch(r -> "Outcome Status".equals(r.get("define"))), + "CMS125 evidence should contain Outcome Status define"); + } + + @Test + @SuppressWarnings("unchecked") + void cms122DiabetesHbA1cProducesStructuredOutcomes() throws Exception { + CqlEvaluationService service = new CqlEvaluationService(defaultPopulationProperties()); + String cqlText = readClasspathText("measures/cms122.cql"); + + DemoRunPayload payload = service.evaluate( + "55555555-5555-5555-5555-555555555555", + "Diabetes: Hemoglobin A1c (HbA1c) Poor Control (> 9%)", + "v1.0", + cqlText, + LocalDate.now() + ); + + assertEquals(100, payload.outcomes().size(), "CMS122 should evaluate every seeded employee"); + + long compliant = payload.outcomes().stream().filter(o -> "COMPLIANT".equals(o.outcome())).count(); + long overdue = payload.outcomes().stream().filter(o -> "OVERDUE".equals(o.outcome())).count(); + long missingData = payload.outcomes().stream().filter(o -> "MISSING_DATA".equals(o.outcome())).count(); + long excluded = payload.outcomes().stream().filter(o -> "EXCLUDED".equals(o.outcome())).count(); + + assertTrue(compliant > 0, "CMS122 should produce COMPLIANT outcomes (good HbA1c control)"); + assertTrue(overdue > 0, "CMS122 should produce OVERDUE outcomes (poor HbA1c control > 9%)"); + assertTrue(missingData > 0, "CMS122 should produce MISSING_DATA outcomes (no HbA1c result)"); + assertTrue(excluded > 0, "CMS122 should produce EXCLUDED outcomes"); + + DemoOutcome overdueOutcome = payload.outcomes().stream() + .filter(o -> "OVERDUE".equals(o.outcome())) + .findFirst() + .orElseThrow(); + List> expressionResults = (List>) overdueOutcome.evidenceJson().get("expressionResults"); + assertNotNull(expressionResults, "CMS122 OVERDUE evidence should contain expressionResults"); + assertTrue( + expressionResults.stream().anyMatch(r -> "HbA1c Poor Control".equals(r.get("define")) && "true".equalsIgnoreCase(String.valueOf(r.get("result")))), + "CMS122 OVERDUE evidence should show HbA1c Poor Control=true, got: " + expressionResults + ); + } + private String resourceName(String measureName) { return switch (measureName) { case "TB Surveillance" -> "tb_surveillance"; diff --git a/docs/MEASURES.md b/docs/MEASURES.md index b20a766..9fa4a82 100644 --- a/docs/MEASURES.md +++ b/docs/MEASURES.md @@ -9,7 +9,8 @@ WorkWell Measure Studio implements the **Total Worker Health (TWH)** model: OSHA | OSHA occupational safety — fully evaluated | 4 | Active | Full CQL, runnable | | OSHA occupational safety — catalog only | 3 | Draft / Approved / Deprecated | Partial or no CQL | | HEDIS wellness — fully evaluated | 4 | Active | Full CQL, runnable | -| CMS eCQM catalog (2026 performance period) | 49 | Draft | Catalog entry only — CQL authoring pending | +| CMS eCQM — fully evaluated | 2 | Active | Full CQL, runnable (CMS125v14, CMS122v14) | +| CMS eCQM catalog (2026 performance period) | 47 | Draft | Catalog entry only — CQL authoring pending | | **Total** | **60** | | | Outcome buckets (all measures): `COMPLIANT`, `DUE_SOON`, `OVERDUE`, `MISSING_DATA`, `EXCLUDED`. @@ -145,9 +146,41 @@ All four wellness measures use the same outcome pattern: --- +## Category 3b — CMS eCQM (Full CQL) + +Two CMS eCQM measures promoted from Draft catalog to Active with full CQL evaluation: + +### 3b.1 Breast Cancer Screening (CMS125v14 / MIPS 112) +- Policy reference: CMS125v14 +- CQL file: `backend/src/main/resources/measures/cms125.cql` +- Tags: `ecqm`, `cms`, `cancer-screening`, `preventive` +- Compliance window: 27 months (820 days — mammogram within the measurement period or 26 months prior) + +Outcome mapping: +- `EXCLUDED` when bilateral mastectomy or documented clinical exclusion +- `MISSING_DATA` when enrolled, not excluded, no mammogram date found +- `OVERDUE` when enrolled, not excluded, days since last mammogram > 820 +- `DUE_SOON` when enrolled, not excluded, days in (790..820] +- `COMPLIANT` when enrolled, not excluded, days <= 790 + +### 3b.2 Diabetes: HbA1c Poor Control (CMS122v14 / MIPS 1) +- Policy reference: CMS122v14 +- CQL file: `backend/src/main/resources/measures/cms122.cql` +- Tags: `ecqm`, `cms`, `diabetes` +- Value-based (numeric): outcome is driven by HbA1c lab value, not recency + +Outcome mapping: +- `EXCLUDED` when documented clinical exclusion +- `MISSING_DATA` when diabetes diagnosis, not excluded, no recent HbA1c result +- `OVERDUE` when diabetes diagnosis, not excluded, HbA1c value > 9% (poor control — intervention needed) +- `COMPLIANT` when diabetes diagnosis, not excluded, HbA1c value ≤ 9% (adequate control) +- `DUE_SOON` — not applicable (hard-coded false; control status drives outcome, not recency) + +--- + ## Category 4 — CMS eCQM Catalog (2026 Performance Period) -49 official CMS electronic Clinical Quality Measures seeded as Draft v1.0 catalog entries. The `policy_ref` field stores the CMS eCQM ID (e.g., `CMS128v14`). The `spec_json` stores `cmsEcqmId` and `mipsQualityId` for downstream tooling. CQL authoring for these measures is future work. +47 official CMS electronic Clinical Quality Measures seeded as Draft v1.0 catalog entries (CMS125v14 and CMS122v14 are now Active with full CQL — see Category 3b). The `policy_ref` field stores the CMS eCQM ID (e.g., `CMS128v14`). The `spec_json` stores `cmsEcqmId` and `mipsQualityId` for downstream tooling. CQL authoring for the remaining catalog entries is future work. The measures page renders CMS IDs as blue mono badges to distinguish them from OSHA CFR citations and HEDIS references.