From c6ecdc3d97a5df8ae38edf17de39687df635f5e5 Mon Sep 17 00:00:00 2001 From: Luke deGruchy Date: Fri, 29 May 2026 17:17:20 -0400 Subject: [PATCH 1/2] Fix multi-component stratifier alignment for list + scalar (CDO-715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A boolean (subject) basis stratifier with two components — one returning a per-patient List and one returning a per-patient scalar — emitted one stratum per component value with a single component, instead of one stratum per unique (list-element, scalar) tuple with both components. Root cause: collectFunctionRowKeys only collected alignment row keys from function (Map) results, so a scalar component sharing a subject with an iterable component fell back to a subject-only row key. The iterable's composite per-element keys and the scalar's subject-only key never intersected, producing disjoint single-component strata. Generalize the collector (renamed collectAlignmentRowKeys) to also gather row keys from iterable results, so scalars expand alongside list elements via the existing expandScalarToAlignmentRowKeys path. Tighten the scalar fallback: when the stratifier has multi-value components but a given subject produced none (empty Map or List), the subject contributes no stratum — matching the existing empty-list semantic. Adds single-subject and multi-subject MeasureStratifierTest cases plus a CohortBooleanMultiComponentListScalarStrat fixture that pairs the existing "Distinct Encounter Statuses" list expression with the "Gender Stratification String" scalar. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../common/MeasureMultiSubjectEvaluator.java | 100 ++++++++----- .../cr/measure/r4/MeasureStratifierTest.java | 135 ++++++++++++++++++ ...tBooleanMultiComponentListScalarStrat.json | 75 ++++++++++ 3 files changed, 275 insertions(+), 35 deletions(-) create mode 100644 cqf-fhir-cr/src/test/resources/org/opencds/cqf/fhir/cr/measure/r4/MeasureStratifierTest/input/resources/measure/CohortBooleanMultiComponentListScalarStrat.json diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureMultiSubjectEvaluator.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureMultiSubjectEvaluator.java index 2e1a2adc7..2d1140e1a 100644 --- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureMultiSubjectEvaluator.java +++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureMultiSubjectEvaluator.java @@ -412,10 +412,11 @@ private static List buildValueOrNonSubjectValueStrata( * to match function row keys when mixed with function components. * * - *

Mixed Function and Scalar Components

- *

When a stratifier has both function components (per-resource) and scalar components (per-subject), - * the scalar values are expanded to match the function row keys. This ensures all components align - * for proper grouping. + *

Mixed Multi-Value and Scalar Components

+ *

When a stratifier has both multi-value components (function results per-resource OR iterable + * results per-subject) and scalar components (per-subject), the scalar values are expanded to match + * the multi-value row keys. This ensures all components align for proper grouping so that each + * resulting stratum contains a value from every declared component. * *

Example: Stratifier with 3 components

*
    @@ -466,12 +467,14 @@ private static Table subjectResultTable = HashBasedTable.create(); - // First pass: Collect all composite row keys (subject|resource) from function components - // These are needed to expand scalar components to match function row keys - final Map> functionRowKeysBySubject = collectFunctionRowKeys(componentDefs); + // First pass: Collect alignment row keys from multi-value components (function Maps and + // iterable Lists). These are needed to expand scalar components so every alignment row + // carries a value from every declared component, producing one stratum per alignment row + // rather than one stratum per (subject, component) pair. + final Map> alignmentRowKeysBySubject = collectAlignmentRowKeys(componentDefs); for (StratifierComponentDef componentDef : componentDefs) { - for (StratumTableRow stratumTableRow : mapToListOfTableEntries(componentDef, functionRowKeysBySubject)) { + for (StratumTableRow stratumTableRow : mapToListOfTableEntries(componentDef, alignmentRowKeysBySubject)) { subjectResultTable.put( stratumTableRow.stratifierRowKey(), stratumTableRow.stratumValueWrapper(), componentDef); } @@ -481,17 +484,26 @@ private static TableThis is used to expand scalar components to match the function row keys when - * stratifiers mix function and scalar components. + *

    Two kinds of multi-value components contribute keys: + *

      + *
    • Function (Map) results — composite {@code (subject | functionInput)} row keys, + * one per input resource.
    • + *
    • Iterable (List) results — composite {@code (subject | iterableElement(value, index))} + * row keys, one per list element. Aligning scalars to these keys is what allows a stratifier + * declared with one iterable component and one scalar component to emit strata that contain + * both component values, instead of one stratum per component value.
    • + *
    * - * @return Map from subject (e.g., "Patient/123") to set of composite row keys + * @return Map from subject (e.g., "Patient/123") to set of alignment row keys */ - private static Map> collectFunctionRowKeys( + private static Map> collectAlignmentRowKeys( List componentDefs) { - final Map> functionRowKeysBySubject = new HashMap<>(); + final Map> alignmentRowKeysBySubject = new HashMap<>(); for (StratifierComponentDef componentDef : componentDefs) { for (var entry : componentDef.getResults().entrySet()) { @@ -499,28 +511,38 @@ private static Map> collectFunctionRowKeys( CriteriaResult result = entry.getValue(); Object rawValue = result == null ? null : result.rawValue(); - // Only process function results (Map values) if (rawValue instanceof Map functionResults) { String qualifiedSubject = FhirResourceUtils.addPatientQualifier(subjectId); Set rowKeys = - functionRowKeysBySubject.computeIfAbsent(qualifiedSubject, k -> new HashSet<>()); + alignmentRowKeysBySubject.computeIfAbsent(qualifiedSubject, k -> new HashSet<>()); for (Object key : functionResults.keySet()) { rowKeys.add( StratifierRowKey.withInput(qualifiedSubject, StratifierRowValue.ofFunctionInput(key))); } + } else if (rawValue instanceof Iterable iterableResults) { + String qualifiedSubject = FhirResourceUtils.addPatientQualifier(subjectId); + Set rowKeys = + alignmentRowKeysBySubject.computeIfAbsent(qualifiedSubject, k -> new HashSet<>()); + + int index = 0; + for (Object value : iterableResults) { + rowKeys.add(StratifierRowKey.withInput( + qualifiedSubject, StratifierRowValue.ofIterableElement(value, index))); + index++; + } } } } - return functionRowKeysBySubject; + return alignmentRowKeysBySubject; } private static List mapToListOfTableEntries( - StratifierComponentDef componentDef, Map> functionRowKeysBySubject) { + StratifierComponentDef componentDef, Map> alignmentRowKeysBySubject) { return componentDef.getResults().entrySet().stream() - .map(entry -> mapToListOfTableEntries(entry.getKey(), entry.getValue(), functionRowKeysBySubject)) + .map(entry -> mapToListOfTableEntries(entry.getKey(), entry.getValue(), alignmentRowKeysBySubject)) .flatMap(Collection::stream) .toList(); } @@ -528,7 +550,7 @@ private static List mapToListOfTableEntries( private record StratumTableRow(StratifierRowKey stratifierRowKey, StratumValueWrapper stratumValueWrapper) {} private static List mapToListOfTableEntries( - String subjectId, CriteriaResult result, Map> functionRowKeysBySubject) { + String subjectId, CriteriaResult result, Map> alignmentRowKeysBySubject) { final String qualifiedSubject = FhirResourceUtils.addPatientQualifier(subjectId); final Object rawValue = result == null ? null : result.rawValue(); @@ -540,33 +562,41 @@ private static List mapToListOfTableEntries( return addIterableValueRows(qualifiedSubject, iterableValue); } - // Scalar value: check if we need to expand to match function row keys - Set functionRowKeys = functionRowKeysBySubject.get(qualifiedSubject); - if (CollectionUtils.isNotEmpty(functionRowKeys)) { - // Expand scalar to match function row keys for this subject - return expandScalarToMatchFunctionRowKeys(functionRowKeys, rawValue); + // Scalar value: expand to match the alignment row keys produced by any multi-value + // components (functions or iterables) on this subject, so the scalar lands in every stratum. + Set alignmentRowKeys = alignmentRowKeysBySubject.get(qualifiedSubject); + if (CollectionUtils.isNotEmpty(alignmentRowKeys)) { + return expandScalarToAlignmentRowKeys(alignmentRowKeys, rawValue); } - // No function row keys - use simple subject-only row key + // If the stratifier as a whole has multi-value components but this particular subject + // produced no alignment rows (empty Map / empty List), the subject contributes no + // stratum at all — same semantic as the single-component empty-list case. Otherwise + // (purely scalar stratifier), fall back to a subject-only row key. + if (!alignmentRowKeysBySubject.isEmpty()) { + return List.of(); + } return List.of(addScalarValueRow(qualifiedSubject, rawValue)); } /** - * Expands a scalar value to match the row keys from function components. + * Expands a scalar value to match the alignment row keys produced by multi-value components + * (function Maps or iterable Lists). * - *

    When stratifiers mix function and scalar components, the scalar value applies - * to all resources for that subject. This method creates one row per function row key, - * all with the same scalar value. + *

    When stratifiers mix multi-value and scalar components, the scalar value applies to every + * alignment row for that subject. Emitting one table row per alignment key, all with the same + * scalar value, lets the downstream group-by-value-set step produce strata where each stratum + * contains a value from every declared component. * - * @param functionRowKeys the row keys from function components for this subject + * @param alignmentRowKeys the row keys from multi-value components for this subject * @param scalarValue the scalar value to expand - * @return list of table rows, one per function row key + * @return list of table rows, one per alignment row key */ - private static List expandScalarToMatchFunctionRowKeys( - Set functionRowKeys, Object scalarValue) { + private static List expandScalarToAlignmentRowKeys( + Set alignmentRowKeys, Object scalarValue) { StratumValueWrapper valueWrapper = new StratumValueWrapper(scalarValue); - return functionRowKeys.stream() + return alignmentRowKeys.stream() .map(rowKey -> new StratumTableRow(rowKey, valueWrapper)) .toList(); } diff --git a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MeasureStratifierTest.java b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MeasureStratifierTest.java index 4b0b46303..96bb89d16 100644 --- a/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MeasureStratifierTest.java +++ b/cqf-fhir-cr/src/test/java/org/opencds/cqf/fhir/cr/measure/r4/MeasureStratifierTest.java @@ -1505,4 +1505,139 @@ void cohortBooleanValueStratListExpressionInvalid() { .hasContainedOperationOutcomeMsg( "value stratifier is invalid for expression: [All Encounters] with result types: [Encounter] for measure URL: http://example.com/Measure/CohortBooleanValueStratListExpression. Expected a scalar type"); } + + /** + * boolean (subject) basis stratifier with two components — one returning a + * per-patient {@code List} ("Distinct Encounter Statuses") and one returning a per-patient + * scalar ("Gender Stratification String") — must emit one stratum per unique + * (list-element, scalar) tuple, with each stratum carrying BOTH component values. + *

    + * Before the fix, scalar components were only expanded to align with function (Map) row keys, + * never iterable row keys, so the iterable's composite row key and the scalar's subject-only + * row key landed in disjoint groups — producing one stratum per component value with only that + * single component, instead of strata with both components. + *

    + * Patient-9 has two Encounters (statuses {@code finished}, {@code in-progress}) and gender + * {@code male}, so two strata are expected: + *

      + *
    • {Encounter Status: finished, Gender: male} — count 1
    • + *
    • {Encounter Status: in-progress, Gender: male} — count 1
    • + *
    + */ + @Test + void cohortBooleanMultiComponentListScalarStratPatient9() { + GIVEN_MEASURE_STRATIFIER_TEST + .when() + .measureId("CohortBooleanMultiComponentListScalarStrat") + .subject("Patient/patient-9") + .evaluate() + .then() + .firstGroup() + .firstStratifier() + .hasCodeText("Encounter Status and Gender") + .hasStratumCount(2) + .stratum(s -> s.getStratum().stream() + .filter(st -> st.getComponent().stream() + .anyMatch(c -> c.hasValue() + && "finished".equals(c.getValue().getText()))) + .findFirst() + .orElse(null)) + .hasComponentStratifierCount(2) + .stratumComponentWithCodeText("Encounter Status") + .hasValueText("finished") + .up() + .stratumComponentWithCodeText("Gender") + .hasValueText("male") + .up() + .firstPopulation() + .hasCount(1) + .up() + .up() + .stratum(s -> s.getStratum().stream() + .filter(st -> st.getComponent().stream() + .anyMatch(c -> c.hasValue() + && "in-progress".equals(c.getValue().getText()))) + .findFirst() + .orElse(null)) + .hasComponentStratifierCount(2) + .stratumComponentWithCodeText("Encounter Status") + .hasValueText("in-progress") + .up() + .stratumComponentWithCodeText("Gender") + .hasValueText("male") + .up() + .firstPopulation() + .hasCount(1); + } + + /** + * multi-subject variant of {@link #cohortBooleanMultiComponentListScalarStratPatient9()}. + * Across all patients (data per {@code cohortBooleanValueStratMultiValueListOverlapping}), + * the stratifier must produce one stratum per unique (encounter-status, gender) tuple, each + * with two components. The expected tuples are: + *
    +     * (in-progress, female)  → patient-0
    +     * (in-progress, male)    → patient-1, patient-9
    +     * (finished,   female)   → patient-0, patient-8
    +     * (finished,   male)     → patient-1, patient-9
    +     * (arrived,    female)   → patient-2
    +     * (arrived,    male)     → patient-3
    +     * (triaged,    female)   → patient-4
    +     * (triaged,    male)     → patient-5
    +     * (cancelled,  female)   → patient-6
    +     * (cancelled,  male)     → patient-7
    +     * 
    + */ + @Test + void cohortBooleanMultiComponentListScalarStratAllPatients() { + GIVEN_MEASURE_STRATIFIER_TEST + .when() + .measureId("CohortBooleanMultiComponentListScalarStrat") + .evaluate() + .then() + .firstGroup() + .firstStratifier() + .hasCodeText("Encounter Status and Gender") + .hasStratumCount(10) + .stratum(s -> s.getStratum().stream() + .filter(st -> st.getComponent().stream() + .anyMatch(c -> c.hasValue() + && "finished".equals(c.getValue().getText()))) + .filter(st -> st.getComponent().stream() + .anyMatch(c -> c.hasValue() + && "male".equals(c.getValue().getText()))) + .findFirst() + .orElse(null)) + .hasComponentStratifierCount(2) + .firstPopulation() + .hasCount(2) // patient-1, patient-9 + .up() + .up() + .stratum(s -> s.getStratum().stream() + .filter(st -> st.getComponent().stream() + .anyMatch(c -> c.hasValue() + && "finished".equals(c.getValue().getText()))) + .filter(st -> st.getComponent().stream() + .anyMatch(c -> c.hasValue() + && "female".equals(c.getValue().getText()))) + .findFirst() + .orElse(null)) + .hasComponentStratifierCount(2) + .firstPopulation() + .hasCount(2) // patient-0, patient-8 + .up() + .up() + .stratum(s -> s.getStratum().stream() + .filter(st -> st.getComponent().stream() + .anyMatch(c -> c.hasValue() + && "cancelled".equals(c.getValue().getText()))) + .filter(st -> st.getComponent().stream() + .anyMatch(c -> c.hasValue() + && "male".equals(c.getValue().getText()))) + .findFirst() + .orElse(null)) + .hasComponentStratifierCount(2) + .firstPopulation() + .hasCount(1); // patient-7 + } } diff --git a/cqf-fhir-cr/src/test/resources/org/opencds/cqf/fhir/cr/measure/r4/MeasureStratifierTest/input/resources/measure/CohortBooleanMultiComponentListScalarStrat.json b/cqf-fhir-cr/src/test/resources/org/opencds/cqf/fhir/cr/measure/r4/MeasureStratifierTest/input/resources/measure/CohortBooleanMultiComponentListScalarStrat.json new file mode 100644 index 000000000..61e1b4a35 --- /dev/null +++ b/cqf-fhir-cr/src/test/resources/org/opencds/cqf/fhir/cr/measure/r4/MeasureStratifierTest/input/resources/measure/CohortBooleanMultiComponentListScalarStrat.json @@ -0,0 +1,75 @@ +{ + "id": "CohortBooleanMultiComponentListScalarStrat", + "resourceType": "Measure", + "url": "http://example.com/Measure/CohortBooleanMultiComponentListScalarStrat", + "library": [ + "http://example.com/Library/LibrarySimple" + ], + "extension": [ + { + "url": "http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/cqfm-populationBasis", + "valueCode": "boolean" + } + ], + "scoring": { + "coding": [ + { + "system": "http://hl7.org/fhir/measure-scoring", + "code": "cohort" + } + ] + }, + "group": [ + { + "id": "group-1", + "population": [ + { + "id": "initial-population", + "code": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/measure-population", + "code": "initial-population", + "display": "Initial Population" + } + ] + }, + "criteria": { + "language": "text/cql-identifier", + "expression": "Initial Population Boolean" + } + } + ], + "stratifier": [ + { + "id": "stratifier-1", + "code": { + "text": "Encounter Status and Gender" + }, + "component": [ + { + "id": "stratifier-comp-1", + "code": { + "text": "Encounter Status" + }, + "criteria": { + "language": "text/cql.identifier", + "expression": "Distinct Encounter Statuses" + } + }, + { + "id": "stratifier-comp-2", + "code": { + "text": "Gender" + }, + "criteria": { + "language": "text/cql.identifier", + "expression": "Gender Stratification String" + } + } + ] + } + ] + } + ] +} From bf4e6e0f2e3c5e5d99b0df754befbb5640916528 Mon Sep 17 00:00:00 2001 From: Luke deGruchy Date: Mon, 1 Jun 2026 14:36:30 -0400 Subject: [PATCH 2/2] Reduce cognitive complexity of collectAlignmentRowKeys Extract per-entry work into a helper method to flatten nesting and remove duplicated qualifiedSubject/computeIfAbsent setup across the Map and Iterable branches. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../common/MeasureMultiSubjectEvaluator.java | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureMultiSubjectEvaluator.java b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureMultiSubjectEvaluator.java index 2d1140e1a..96ceaa34f 100644 --- a/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureMultiSubjectEvaluator.java +++ b/cqf-fhir-cr/src/main/java/org/opencds/cqf/fhir/cr/measure/common/MeasureMultiSubjectEvaluator.java @@ -507,37 +507,39 @@ private static Map> collectAlignmentRowKeys( for (StratifierComponentDef componentDef : componentDefs) { for (var entry : componentDef.getResults().entrySet()) { - String subjectId = entry.getKey(); - CriteriaResult result = entry.getValue(); - Object rawValue = result == null ? null : result.rawValue(); - - if (rawValue instanceof Map functionResults) { - String qualifiedSubject = FhirResourceUtils.addPatientQualifier(subjectId); - Set rowKeys = - alignmentRowKeysBySubject.computeIfAbsent(qualifiedSubject, k -> new HashSet<>()); - - for (Object key : functionResults.keySet()) { - rowKeys.add( - StratifierRowKey.withInput(qualifiedSubject, StratifierRowValue.ofFunctionInput(key))); - } - } else if (rawValue instanceof Iterable iterableResults) { - String qualifiedSubject = FhirResourceUtils.addPatientQualifier(subjectId); - Set rowKeys = - alignmentRowKeysBySubject.computeIfAbsent(qualifiedSubject, k -> new HashSet<>()); - - int index = 0; - for (Object value : iterableResults) { - rowKeys.add(StratifierRowKey.withInput( - qualifiedSubject, StratifierRowValue.ofIterableElement(value, index))); - index++; - } - } + collectAlignmentRowKeysForEntry(entry.getKey(), entry.getValue(), alignmentRowKeysBySubject); } } return alignmentRowKeysBySubject; } + private static void collectAlignmentRowKeysForEntry( + String subjectId, CriteriaResult result, Map> alignmentRowKeysBySubject) { + + final Object rawValue = result == null ? null : result.rawValue(); + if (!(rawValue instanceof Map) && !(rawValue instanceof Iterable)) { + return; + } + + final String qualifiedSubject = FhirResourceUtils.addPatientQualifier(subjectId); + final Set rowKeys = + alignmentRowKeysBySubject.computeIfAbsent(qualifiedSubject, k -> new HashSet<>()); + + if (rawValue instanceof Map functionResults) { + for (Object key : functionResults.keySet()) { + rowKeys.add(StratifierRowKey.withInput(qualifiedSubject, StratifierRowValue.ofFunctionInput(key))); + } + } else { + int index = 0; + for (Object value : (Iterable) rawValue) { + rowKeys.add(StratifierRowKey.withInput( + qualifiedSubject, StratifierRowValue.ofIterableElement(value, index))); + index++; + } + } + } + private static List mapToListOfTableEntries( StratifierComponentDef componentDef, Map> alignmentRowKeysBySubject) {