Skip to content

Commit bc5ffe2

Browse files
committed
Wire federated terminology into CQL evaluation with bundle-first semantics
1 parent dd10e21 commit bc5ffe2

8 files changed

Lines changed: 931 additions & 3 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# Federation verification procedure
2+
3+
Manual verification procedure for the federated-terminology wire-up in
4+
`MeasureEvaluator`. The automated tests
5+
(`MeasureEvaluatorFederationTests`) protect against regressions with small
6+
in-code fixtures; this procedure verifies the same invariants against
7+
production-shaped IG bundles.
8+
9+
## What is being verified
10+
11+
Three invariants of the composition in
12+
`MeasureEvaluator.buildRepository()`:
13+
14+
1. **Federation preserves results.** For a bundle that carries its own
15+
ValueSets, evaluating with federation on and off must produce the same
16+
MeasureReport. If they diverge, terminology is being resolved
17+
differently in the two paths — the wire-up has a bug.
18+
2. **Federation consults the bundle first.** When the bundle carries the
19+
ValueSets it needs, no remote calls should be made. If the remote is
20+
consulted at all, the federation is routing wholesale rather than
21+
falling through, and IG-embedded ValueSet versions would be silently
22+
ignored.
23+
3. **Federation falls through when the bundle is empty.** When ValueSets
24+
are stripped from the bundle, the remote must be consulted and the
25+
report must still match the reference result. Confirms the fallback
26+
half of the composition actually works.
27+
28+
## Prerequisites
29+
30+
- The `nhsn-measures` repository checked out and built. Bundles are read
31+
from its `bundles/` and example subject data from its `output/`
32+
directory.
33+
- `mvn` on `PATH`. The verifier runs against the test-scope classpath, so
34+
no separate WireMock or NHSN-specific dependency setup is needed — the
35+
project pom already includes them for test scope.
36+
37+
## Invocation
38+
39+
```bash
40+
cd Java
41+
42+
mvn -pl measureeval exec:java -Dexec.classpathScope=test \
43+
-Dexec.mainClass=com.lantanagroup.link.measureeval.audit.FederatedTerminologyVerifier \
44+
-Dexec.args="\
45+
--measure-bundle /path/to/nhsn-measures/bundles/measure/NHSNAcuteCareHospitalDailyInitialPopulation/NHSNAcuteCareHospitalDailyInitialPopulation-bundle.json \
46+
--subjects-dir /path/to/nhsn-measures/output \
47+
--period-start 2024-01-01 \
48+
--period-end 2024-12-31"
49+
```
50+
51+
Flags:
52+
53+
| Flag | Required | Default | Purpose |
54+
|------|----------|---------|---------|
55+
| `--measure-bundle <path>` | yes || Path to the measure bundle (Measure + Library + ValueSets). |
56+
| `--subjects-dir <dir>` | yes || Directory containing subject bundles. The verifier picks up JSON files whose name contains `subject`. |
57+
| `--ts-port <port>` | no | 8089 | Port for the embedded WireMock terminology server. Change if 8089 is in use. |
58+
| `--period-start <yyyy-mm-dd>` | no | 2024-01-01 | Measurement period start passed to CQL evaluation. |
59+
| `--period-end <yyyy-mm-dd>` | no | 2024-12-31 | Measurement period end. |
60+
61+
## Expected output
62+
63+
```
64+
Measure bundle: /path/to/.../NHSNAcuteCareHospitalDailyInitialPopulation-bundle.json
65+
Subjects dir: /path/to/nhsn-measures/output
66+
Mock TS port: 8089
67+
Period: 2024-01-01 .. 2024-12-31
68+
69+
Measure bundle carries 34 ValueSet(s); stripped variant contains 4 entries
70+
Found 4 subject bundle(s):
71+
- Bundle-bundle-example-ach-daily-subject-influenzatherapeutic.json
72+
- Bundle-bundle-example-ach-daily-subject-initialpopulationpass.json
73+
- Bundle-bundle-example-ach-daily-subject-negativepcr.json
74+
- Bundle-bundle-example-ach-daily-subject-rsvlabbtg.json
75+
76+
Subject | A | B | C | Bcalls | Ccalls | Verdict
77+
----------------------------------------------|------|------|------|--------|--------|--------
78+
Bundle-bundle-example-ach-daily-subject-... | 1 | 1 | 1 | 0 | 12 | ✓ PASS
79+
Bundle-bundle-example-ach-daily-subject-... | 1 | 1 | 1 | 0 | 12 | ✓ PASS
80+
Bundle-bundle-example-ach-daily-subject-... | 1 | 1 | 1 | 0 | 12 | ✓ PASS
81+
Bundle-bundle-example-ach-daily-subject-... | 0 | 0 | 0 | 0 | 12 | ✓ PASS
82+
83+
Overall: 4/4 PASS
84+
85+
Legend:
86+
A = initial-population, no federation, full bundle
87+
B = initial-population, federation on, full bundle (must equal A)
88+
C = initial-population, federation on, stripped bundle (must equal A)
89+
Bcalls= mock TS requests during scenario B (must be 0)
90+
Ccalls= mock TS requests during scenario C (must be > 0)
91+
```
92+
93+
## Interpreting the output
94+
95+
### PASS
96+
97+
All three columns (A, B, C) match; Bcalls is 0; Ccalls is greater than 0.
98+
This means:
99+
100+
- The initial-population count is stable across the federated and
101+
non-federated paths for this measure and subject.
102+
- The bundle-first behavior held: when the bundle had the ValueSets, the
103+
remote TS was never touched.
104+
- The fallback behavior held: when the ValueSets were removed, the mock
105+
TS served them and the result came out the same.
106+
107+
### FAIL: results diverge
108+
109+
Columns A, B, C are not all equal. Someone in the chain is resolving
110+
terminology differently in the federated path. Common causes:
111+
112+
- `Repositories.proxy(...)` invocation is wrong — check the arg order
113+
`(data, content, terminology)` in `MeasureEvaluator.buildRepository()`.
114+
- A CQF upgrade changed the semantics of `FederatedRepository` or
115+
`ProxyRepository` and our composition no longer behaves as before.
116+
Re-read the CQF `Repositories.proxy` bytecode notes in
117+
`MeasureEvaluator.buildRepository()`'s Javadoc.
118+
119+
### FAIL: scenario B hit remote
120+
121+
Bcalls is not 0. Federation is not consulting the bundle first — every
122+
terminology lookup is going straight to the remote TS, and any bundle-
123+
embedded ValueSet version is being silently ignored. This is the failure
124+
mode our composition was specifically designed to prevent, so a Bcalls
125+
count above 0 always warrants investigation.
126+
127+
- Likeliest cause: `FederatedRepository` is being constructed with the
128+
arguments in the wrong order (remote first, bundle second). Check
129+
`buildRepository()`.
130+
131+
### FAIL: scenario C did not use remote
132+
133+
Ccalls is 0. Stripping the ValueSets from the bundle didn't force any
134+
remote calls, which means either:
135+
136+
- The measure being evaluated doesn't actually reference the stripped
137+
ValueSets in its CQL — try a different measure.
138+
- CQF isn't reaching the remote tier at all — the composition is broken.
139+
140+
## Cross-referencing against the shipped MeasureReport
141+
142+
Each of the NHSN example subject bundles also contains a pre-computed
143+
`MeasureReport`. Reading the initial-population count from that reference
144+
report and comparing to columns A / B / C is a stronger check than
145+
comparing A / B / C against each other alone. If the verifier reports
146+
PASS but the initial-population value differs from the shipped
147+
MeasureReport, the numeric result is wrong regardless of federation
148+
behavior — that's a measure-evaluation bug, not a federation bug, but
149+
worth surfacing.
150+
151+
Reading the shipped MeasureReport by hand:
152+
153+
```bash
154+
jq '.entry[] | select(.resource.resourceType=="MeasureReport")
155+
| .resource.group[0].population[0].count' \
156+
/path/to/nhsn-measures/output/Bundle-bundle-example-ach-daily-subject-initialpopulationpass.json
157+
```
158+
159+
Should return an integer that agrees with column A above.
160+
161+
## When to re-run
162+
163+
- After any change to `MeasureEvaluator.buildRepository()`.
164+
- After a `cqf-fhir` version bump — behavior of `FederatedRepository`,
165+
`ProxyRepository`, or `Repositories.proxy(...)` could shift.
166+
- Before shipping any change that touches `LinkConfig.fhirTerminologyServiceUrl`,
167+
the `remoteTerminologyClient` bean, or the `MeasureDefinitionController`
168+
wire-up.
169+
170+
## Related
171+
172+
- `Java/measureeval/src/test/.../MeasureEvaluatorFederationTests.java`
173+
— the CI-friendly regression tests. Same invariants, smaller fixtures.
174+
- `Java/measureeval/src/main/.../services/MeasureEvaluator.java`
175+
`buildRepository()` and its Javadoc explain the composition choice.
176+
- `Java/validation/src/main/.../providers/REMOTE-TERM-COST-ANALYSIS.md`
177+
— cost profile and open items for the validation service's remote
178+
terminology client.

Java/measureeval/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@
168168
<artifactId>mockito-core</artifactId>
169169
<scope>test</scope>
170170
</dependency>
171+
172+
<dependency>
173+
<groupId>org.wiremock</groupId>
174+
<artifactId>wiremock-standalone</artifactId>
175+
<version>3.3.1</version>
176+
<scope>test</scope>
177+
</dependency>
171178
</dependencies>
172179

173180
<build>

Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/configs/LinkConfig.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.lantanagroup.link.measureeval.configs;
22

3+
import ca.uhn.fhir.context.FhirContext;
4+
import ca.uhn.fhir.rest.client.api.IGenericClient;
35
import com.fasterxml.jackson.databind.ObjectMapper;
46
import com.fasterxml.jackson.databind.SerializationFeature;
57
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
@@ -9,6 +11,7 @@
911
import lombok.Getter;
1012
import lombok.Setter;
1113
import org.hl7.fhir.r4.model.MeasureReport;
14+
import org.springframework.beans.factory.annotation.Qualifier;
1215
import org.springframework.boot.context.properties.ConfigurationProperties;
1316
import org.springframework.context.annotation.Bean;
1417
import org.springframework.context.annotation.Configuration;
@@ -26,6 +29,31 @@ public class LinkConfig {
2629
private String reportabilityPredicate;
2730
private boolean cqlDebug = false;
2831

32+
/**
33+
* Optional remote FHIR terminology service URL. When set, CQL evaluation federates
34+
* ValueSet / CodeSystem lookups: bundle-embedded resources are consulted first (as
35+
* before), then unresolved lookups fall through to this remote TS. Leaving this
36+
* unset preserves the existing in-memory-only behavior.
37+
*/
38+
private String fhirTerminologyServiceUrl;
39+
40+
/**
41+
* Remote FHIR terminology client. Consumed by {@code MeasureEvaluator} to build a
42+
* CQF {@code ProxyRepository} whose terminology tier federates the bundle with this
43+
* client (via {@code FederatedRepository + RestRepository}). Returns {@code null}
44+
* when {@link #fhirTerminologyServiceUrl} is not configured; callers must treat
45+
* null as "no remote terminology available" and use the plain in-memory repository
46+
* instead.
47+
*/
48+
@Bean
49+
@Qualifier("remoteTerminologyClient")
50+
public IGenericClient remoteTerminologyClient(FhirContext fhirContext) {
51+
if (fhirTerminologyServiceUrl == null || fhirTerminologyServiceUrl.isBlank()) {
52+
return null;
53+
}
54+
return fhirContext.newRestfulGenericClient(fhirTerminologyServiceUrl);
55+
}
56+
2957
@Bean
3058
@Primary
3159
public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {

Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/controllers/MeasureDefinitionController.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.lantanagroup.link.measureeval.controllers;
22

33
import ca.uhn.fhir.context.FhirContext;
4+
import ca.uhn.fhir.rest.client.api.IGenericClient;
45
import com.fasterxml.jackson.annotation.JsonView;
56
import com.lantanagroup.link.measureeval.entities.MeasureDefinition;
67
import com.lantanagroup.link.measureeval.models.DebugSections;
@@ -22,6 +23,8 @@
2223
import org.hl7.fhir.r4.model.Parameters;
2324
import org.slf4j.Logger;
2425
import org.slf4j.LoggerFactory;
26+
import org.springframework.beans.factory.annotation.Autowired;
27+
import org.springframework.beans.factory.annotation.Qualifier;
2528
import org.springframework.http.HttpStatus;
2629
import org.springframework.security.access.prepost.PreAuthorize;
2730
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -43,6 +46,16 @@ public class MeasureDefinitionController {
4346
private final MeasureEvaluatorCache evaluatorCache;
4447
private final MeasureValidationService validationService;
4548

49+
/**
50+
* Optional remote FHIR terminology client bean produced by
51+
* {@link com.lantanagroup.link.measureeval.configs.LinkConfig#remoteTerminologyClient}.
52+
* {@code null} when {@code link.fhirTerminologyServiceUrl} is not configured — in that
53+
* case {@link MeasureEvaluator} uses the plain in-memory repository and behavior is unchanged.
54+
*/
55+
@Autowired(required = false)
56+
@Qualifier("remoteTerminologyClient")
57+
private IGenericClient remoteTerminologyClient;
58+
4659
final String[] DISALLOWED_FIELDS = new String[]{};
4760
@InitBinder
4861
public void initBinder(WebDataBinder binder) {
@@ -189,7 +202,7 @@ public Object evaluate(
189202

190203
try {
191204
MeasureEvaluationResult result = MeasureEvaluator.compileAndEvaluate(
192-
FhirContext.forR4(), evaluator.getBundle(), parameters, debug);
205+
FhirContext.forR4(), evaluator.getBundle(), parameters, debug, remoteTerminologyClient);
193206
// Preserve the original wire contract: when no debug sections are
194207
// requested, return a bare MeasureReport. Only emit the wrapper for
195208
// callers who opted in via the `debug` query parameter.

0 commit comments

Comments
 (0)