Skip to content

Multi-component stratifier loses components that share a scalar value#1045

Merged
lukedegruchy merged 4 commits into
mainfrom
ld-20260603-stratifier-multi-component-patient-same-value-diff-expressions
Jun 4, 2026
Merged

Multi-component stratifier loses components that share a scalar value#1045
lukedegruchy merged 4 commits into
mainfrom
ld-20260603-stratifier-multi-component-patient-same-value-diff-expressions

Conversation

@lukedegruchy

@lukedegruchy lukedegruchy commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Fixes a silent data-loss bug in measure-report assembly where stratifier components could be dropped from the output stratum when two of them happened to evaluate to the same scalar value for the same subject. The bug was reported against a real-world RDM measure where a three-component RaceEthnicity stratifier (ProductLine, Race, Ethnicity) produced strata missing the Race component whenever Race and Ethnicity both resolved to AskedButNoAnswer for the same patient. The CQL output already contained all values; the loss happened inside MeasureMultiSubjectEvaluator because its accumulation table was keyed on the value alone, so the second put for the same (row, value) cell overwrote the first.

  1. Root-cause fix in the multi-subject stratifier accumulator. Promote the table's column key from StratumValueWrapper to a small StratumTableColumnKey(component, value) record so that two distinct components emitting equal values for the same row key land in distinct cells instead of overwriting each other.
  2. Failing-first regression coverage at three levels. Added an integration test in MeasureStratifierTest (driven by a new CohortBooleanStratSameValueComponents measure fixture), two evaluator-level unit tests in MeasureMultiSubjectEvaluatorTest, and a defense-in-depth check in MultiMeasureServiceTest to confirm the multi-measure orchestration path inherits the fix.

Example: the RDM-Reporting scenario end-to-end

The snippets below are the colleague's original reproduction for patient 2025.hpdi.0.115003, walking from CQL output → Measure definition → buggy MeasureReport → corrected MeasureReport. They show why the assembly-layer key change matters: the CQL already computes all three component values; the loss is purely in MeasureMultiSubjectEvaluator.

1. CQL results for the patient (input to assembly)

Initial population=true
Product lines=[Code { code: PPO, system: https://ncqa.org/fhir/CodeSystem/hedis-coverage-type },
               Code { code: POS, system: https://ncqa.org/fhir/CodeSystem/hedis-coverage-type }]
Race=AskedButNoAnswer
Ethnicity=AskedButNoAnswer

Note the collision: Race and Ethnicity are distinct stratifier components but both resolve to the same scalar AskedButNoAnswer for this patient. Product lines is multi-valued, so each value yields its own stratum (PPO and POS).

2. Measure stratifier definition (RDM-Reporting.json, three components)

"stratifier": [{
  "id": "RaceEthnicity-Stratifier",
  "code": { "text": "RaceEthnicity-Stratifier" },
  "component": [
    {
      "id": "RaceEthnicity-StratifierComponent-ProductLine",
      "code": { "text": "ProductLine" },
      "criteria": { "language": "text/cql-identifier", "expression": "Product lines" }
    },
    {
      "id": "RaceEthnicity-StratifierComponent-Race",
      "code": { "text": "Race" },
      "criteria": { "language": "text/cql-identifier", "expression": "Race" }
    },
    {
      "id": "RaceEthnicity-StratifierComponent-Ethnicity",
      "code": { "text": "Ethnicity" },
      "criteria": { "language": "text/cql-identifier", "expression": "Ethnicity" }
    }
  ]
}]

3. Before the fix — MeasureReport stratum is missing the Race component

"stratum": [
  {
    "component": [
      { "id": "RaceEthnicity-StratifierComponent-ProductLine",
        "code": { "text": "ProductLine" }, "value": { "text": "POS" } },
      { "id": "RaceEthnicity-StratifierComponent-Ethnicity",
        "code": { "text": "Ethnicity" },   "value": { "text": "AskedButNoAnswer" } }
    ],
    "population": [
      { "id": "RaceEthnicity-initial-population", "count": 1 }
    ]
  },
  {
    "component": [
      { "id": "RaceEthnicity-StratifierComponent-Ethnicity",
        "code": { "text": "Ethnicity" },   "value": { "text": "AskedButNoAnswer" } },
      { "id": "RaceEthnicity-StratifierComponent-ProductLine",
        "code": { "text": "ProductLine" }, "value": { "text": "PPO" } }
    ],
    "population": [
      { "id": "RaceEthnicity-initial-population", "count": 1 }
    ]
  }
]

Race is silently absent from both strata because, inside buildSubjectResultsTable, the cell (rowKey, "AskedButNoAnswer") → Race got overwritten by (rowKey, "AskedButNoAnswer") → Ethnicity when only the value was used as the column key.

4. After the fix — all three components survive in each stratum

"stratum": [
  {
    "component": [
      { "code": { "text": "ProductLine" }, "value": { "text": "PPO" } },
      { "code": { "text": "Race" },        "value": { "text": "AskedButNoAnswer" } },
      { "code": { "text": "Ethnicity" },   "value": { "text": "AskedButNoAnswer" } }
    ],
    "population": [
      { "code": { "coding": [{ "system": "http://hl7.org/fhir/CodeSystem/measure-population",
                               "code": "initial-population" }] },
        "count": 1 }
    ]
  },
  {
    "component": [
      { "code": { "text": "ProductLine" }, "value": { "text": "POS" } },
      { "code": { "text": "Race" },        "value": { "text": "AskedButNoAnswer" } },
      { "code": { "text": "Ethnicity" },   "value": { "text": "AskedButNoAnswer" } }
    ],
    "population": [
      { "code": { "coding": [{ "system": "http://hl7.org/fhir/CodeSystem/measure-population",
                               "code": "initial-population" }] },
        "count": 1 }
    ]
  }
]

lukedegruchy and others added 2 commits June 3, 2026 15:10
…(CDO-789)

Captures the bug where a stratifier with multiple components silently drops
components whose value equals another component's value for the same row key,
because MeasureMultiSubjectEvaluator keys its assembly table on value alone.

- New fixture CohortBooleanStratSameValueComponents.json with three components
  (two using "Gender Stratification", one using "Age").
- Integration test in MeasureStratifierTest asserting each stratum carries all
  three components when two share a value.
- Unit tests in MeasureMultiSubjectEvaluatorTest (DuplicateValueAcrossComponents)
  pinning the evaluator-level behavior directly.
- MultiMeasure variant in MultiMeasureServiceTest reusing the same fixture via
  the multi-measure orchestration path.

All four tests fail on the current branch; the fix follows in a subsequent commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MeasureMultiSubjectEvaluator's subject-results table was keyed on
StratumValueWrapper alone, so when two components of the same stratifier
resolved to equal values for the same row key (e.g. Race and Ethnicity both
AskedButNoAnswer), the second put() overwrote the first cell and the losing
component silently vanished from the MeasureReport stratum.

Introduce a private StratumTableColumnKey(component, value) record and use it
as the table's column key. Two distinct components with equal values now land
in distinct cells; same-component idempotent puts still collapse. The change
is fully encapsulated inside MeasureMultiSubjectEvaluator — no callers of the
existing public surface are affected.

Also: align integration test M/F expectations with the actual fixture data
(females are 38, males are 35 against the default 2024 measurement period)
and adopt spotless formatting on the Phase 1 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown

Formatting check succeeded!

@lukedegruchy lukedegruchy changed the title Ld 20260603 stratifier multi component patient same value diff expressions Multi-component stratifier loses components that share a scalar value Jun 3, 2026
@lukedegruchy lukedegruchy marked this pull request as ready for review June 3, 2026 21:26
@lukedegruchy lukedegruchy enabled auto-merge (squash) June 4, 2026 12:51
@sonarqubecloud

sonarqubecloud Bot commented Jun 4, 2026

Copy link
Copy Markdown

@lukedegruchy lukedegruchy merged commit b7d1438 into main Jun 4, 2026
9 of 11 checks passed
@lukedegruchy lukedegruchy deleted the ld-20260603-stratifier-multi-component-patient-same-value-diff-expressions branch June 4, 2026 13:06
lukedegruchy added a commit that referenced this pull request Jun 11, 2026
Integrates the latest main (incl. the multi-component-stratifier fixes
#1042 and #1045) into the update-cql-4-7-0 CQL-5 refactor.

Conflict resolution (MeasureMultiSubjectEvaluator.java):
- Re-applied #1042's collectAlignmentRowKeys generalization (function +
  iterable alignment) using the refactor's typed wrapper API
  (asFunctionResultAccumulator / isIterable) instead of raw instanceof Map.
- Adopted #1045's StratumTableColumnKey column-key type.

Completing the incomplete refactor's stratifier value/grouping path so the
full cqf-fhir-cr suite passes (was "measures are broken again" on
update-cql-4-7-0; ~18 pre-existing MeasureStratifierTest failures):
- R4StratifierBuilder: render stratum value via getValueClass/getValueAsString
  instead of StratumValueWrapper.toString.
- StratumValueWrapper: normalize CQL-5 ClassInstance values to FHIR R4.
- CqlExpressionValue + MeasureMultiSubjectEvaluator: handle function results
  arriving as raw Map as well as FunctionResultAccumulator; handle
  MEASUREOBSERVATION ObservationAccumulator/Map inputs.
- StratifierRowValue: normalize CQL-5 ClassInstance FHIR-resource keys.
- PopulationBasisValidator: allow CQL runtime primitive value types.

cqf-fhir-cr: 1777 tests, 0 failures, 0 errors (18 skipped). CQL v5 SNAPSHOT
dependency unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants