Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -412,10 +412,11 @@ private static List<StratumDef> buildValueOrNonSubjectValueStrata(
* to match function row keys when mixed with function components.</li>
* </ul>
*
* <h3>Mixed Function and Scalar Components</h3>
* <p>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.
* <h3>Mixed Multi-Value and Scalar Components</h3>
* <p>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.
*
* <h4>Example: Stratifier with 3 components</h4>
* <ul>
Expand Down Expand Up @@ -466,12 +467,14 @@ private static Table<StratifierRowKey, StratumValueWrapper, StratifierComponentD
final Table<StratifierRowKey, StratumValueWrapper, StratifierComponentDef> 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<String, Set<StratifierRowKey>> 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<String, Set<StratifierRowKey>> 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);
}
Expand All @@ -481,54 +484,75 @@ private static Table<StratifierRowKey, StratumValueWrapper, StratifierComponentD
}

/**
* Collects all composite row keys (subject|resource) from function components.
* Collects the row keys produced by multi-value components, keyed by subject. These are used
* to expand scalar components so every alignment row carries a value from every declared
* component.
*
* <p>This is used to expand scalar components to match the function row keys when
* stratifiers mix function and scalar components.
* <p>Two kinds of multi-value components contribute keys:
* <ul>
* <li><b>Function (Map) results</b> — composite {@code (subject | functionInput)} row keys,
* one per input resource.</li>
* <li><b>Iterable (List) results</b> — 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.</li>
* </ul>
*
* @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<String, Set<StratifierRowKey>> collectFunctionRowKeys(
private static Map<String, Set<StratifierRowKey>> collectAlignmentRowKeys(
List<StratifierComponentDef> componentDefs) {

final Map<String, Set<StratifierRowKey>> functionRowKeysBySubject = new HashMap<>();
final Map<String, Set<StratifierRowKey>> alignmentRowKeysBySubject = new HashMap<>();

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();

// Only process function results (Map values)
if (rawValue instanceof Map<?, ?> functionResults) {
String qualifiedSubject = FhirResourceUtils.addPatientQualifier(subjectId);
Set<StratifierRowKey> rowKeys =
functionRowKeysBySubject.computeIfAbsent(qualifiedSubject, k -> new HashSet<>());

for (Object key : functionResults.keySet()) {
rowKeys.add(
StratifierRowKey.withInput(qualifiedSubject, StratifierRowValue.ofFunctionInput(key)));
}
}
collectAlignmentRowKeysForEntry(entry.getKey(), entry.getValue(), alignmentRowKeysBySubject);
}
}

return functionRowKeysBySubject;
return alignmentRowKeysBySubject;
}

private static void collectAlignmentRowKeysForEntry(
String subjectId, CriteriaResult result, Map<String, Set<StratifierRowKey>> 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<StratifierRowKey> 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<StratumTableRow> mapToListOfTableEntries(
StratifierComponentDef componentDef, Map<String, Set<StratifierRowKey>> functionRowKeysBySubject) {
StratifierComponentDef componentDef, Map<String, Set<StratifierRowKey>> 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();
}

private record StratumTableRow(StratifierRowKey stratifierRowKey, StratumValueWrapper stratumValueWrapper) {}

private static List<StratumTableRow> mapToListOfTableEntries(
String subjectId, CriteriaResult result, Map<String, Set<StratifierRowKey>> functionRowKeysBySubject) {
String subjectId, CriteriaResult result, Map<String, Set<StratifierRowKey>> alignmentRowKeysBySubject) {

final String qualifiedSubject = FhirResourceUtils.addPatientQualifier(subjectId);
final Object rawValue = result == null ? null : result.rawValue();
Expand All @@ -540,33 +564,41 @@ private static List<StratumTableRow> mapToListOfTableEntries(
return addIterableValueRows(qualifiedSubject, iterableValue);
}

// Scalar value: check if we need to expand to match function row keys
Set<StratifierRowKey> 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<StratifierRowKey> 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).
*
* <p>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.
* <p>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<StratumTableRow> expandScalarToMatchFunctionRowKeys(
Set<StratifierRowKey> functionRowKeys, Object scalarValue) {
private static List<StratumTableRow> expandScalarToAlignmentRowKeys(
Set<StratifierRowKey> alignmentRowKeys, Object scalarValue) {

StratumValueWrapper valueWrapper = new StratumValueWrapper(scalarValue);
return functionRowKeys.stream()
return alignmentRowKeys.stream()
.map(rowKey -> new StratumTableRow(rowKey, valueWrapper))
.toList();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p/>
* 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.
* <p/>
* Patient-9 has two Encounters (statuses {@code finished}, {@code in-progress}) and gender
* {@code male}, so two strata are expected:
* <ul>
* <li>{Encounter Status: finished, Gender: male} — count 1</li>
* <li>{Encounter Status: in-progress, Gender: male} — count 1</li>
* </ul>
*/
@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:
* <pre>
* (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
* </pre>
*/
@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
}
}
Loading
Loading