Evaluating a criterion for one patient¶
You have a patient, and a clinical rule written in CQL. Does the rule fire?
There are two answers, for two different jobs:
- Evaluate the criterion against that patient's resources and read the value back. This is what you want while authoring, testing, or answering a one-off question.
- Compile the criterion to a rule trigger and hand that to a decision-support engine that already holds the chart. This is what you want to deploy.
Both are below. For why compilation happens once at authoring time rather than in the serving path, see Criteria artifacts; to run criteria over a whole panel instead, see Running criteria across a population.
What you need¶
- A
.cqlfile declaring the model this specification publishes:using Nexus. The CQL page is the authoring contract, including which version to declare. - The CQL surface of the client library, which is an opt-in package in every language because
it carries a translator and nobody who does not author criteria should pay for one:
@awaremd/typed-fhir/cql,Well.Services.TypedFhir.Cql,typed-fhir[cql]. Evaluation is a further subpath again —@awaremd/typed-fhir/cql/evaluate— because it loads an engine that an authoring-only consumer has no use for. - Model info on disk for base FHIR and for this specification's model. The library never opens a file itself; you supply both through the resolver its host takes.
Availability, as of 1.21.1
Well.Services.TypedFhir.Cql publishes to the same feed as the rest of the .NET client
library. The TypeScript and Python libraries are not on a public index yet — that includes
@awaremd/typed-fhir itself, not only its CQL surface, and the Python cql extra depends on a
typed-fhir-cql-bundle distribution that ships alongside it rather than from an index. Work
with them from the repository until that changes. The import paths on this page are the ones
that will apply when they publish, and they are exercised by the library's own test suite
today.
Evaluate it¶
A lipid-screening reminder, written against this specification's model:
library LipidScreeningDue version '1.0.0'
using Nexus
include FHIRHelpers version '4.0.1' called FHIRHelpers
codesystem "LOINC": 'http://loinc.org'
codesystem "ObservationCategory": 'http://terminology.hl7.org/CodeSystem/observation-category'
code "LDL": '18262-6' from "LOINC"
code "Laboratory": 'laboratory' from "ObservationCategory"
context Patient
define "Latest LDL":
Last([NexusEmrCoreObservation: "LDL"] O
where exists (O.category C where C ~ "Laboratory")
sort by (effective as FHIR.dateTime).value)
define "Screening due":
"Latest LDL" is null
or ("Latest LDL".effective as FHIR.dateTime).value before Now() - 365 days
The evaluator reads the patient's resources out of a PatientCache. Construction loads model info
and costs about half a second, so build one evaluator and reuse it; the chart is per call.
import { CqlEvaluator, nexusTypeAliases } from "@awaremd/typed-fhir/cql/evaluate";
import { PatientCache } from "@awaremd/typed-fhir";
const evaluator = await CqlEvaluator.create({
modelsDir, librariesDir,
typeAliases: nexusTypeAliases(), // needed for a library written `using Nexus`
});
const library = evaluator.addLibrary(cqlText);
const { values } = evaluator.evaluate({
cache: PatientCache.ofResources(resources),
library,
expressions: ["Latest LDL", "Screening due"],
patientId: "pt-1",
});
values["Screening due"]; // true | false
Run that over four charts for the same patient and you get:
| chart | Screening due |
|---|---|
| no LDL at all | true |
an LDL from 30 days ago, categorised laboratory |
false |
an LDL from 800 days ago, categorised laboratory |
true |
an LDL from 30 days ago, no category |
true |
The first three are the rule working. The fourth is the subject of the last section on this page, and it is the reason to run this against real charts rather than hand-built ones.
Two refusals you will meet immediately¶
A value set with no expansion. There is no terminology provider wired to the evaluator, so a retrieve naming a value set throws rather than quietly matching nothing:
RetrieveNotSupportedError: retrieve(NexusEmrCoreMedicationRequest) needs value set
'https://fhir.apps.health/ValueSet/dm-medications' but no expansion was supplied; there is
no terminology provider wired to this evaluator
Supply the expansion yourself and the same criterion evaluates:
evaluator.defineValueSet("https://fhir.apps.health/ValueSet/dm-medications", [
{ system: "http://snomed.info/sct", code: "325267003" },
]);
The general rule holds for any retrieve shape the provider cannot serve: it is refused by name, never approximated into a retrieve that means something else.
No patient context. context Patient filters nothing on its own — the engine narrows a
retrieve only once a context value is in scope:
Error: evaluate: no patient context. The cache holds no Patient resource and no patientId was
given; without a context value every retrieve returns the whole cache, which reads as a
criterion that fires for everyone.
Pass patientId, or put the Patient in the cache. Do not treat this as boilerplate: the failure
it prevents is a false positive on every patient, which no downstream check would catch.
Compile it to a rule trigger¶
To deploy a criterion rather than test it, compile it to the closed-vocabulary JSON a decision-support engine evaluates. A diabetes follow-up rule — the latest lab HbA1c is over 90 days old, and either it was above target or the medication list changed recently:
define "Latest HbA1c":
Last([NexusEmrCoreObservation: "HbA1c"] O
where exists (O.category C where C ~ "Laboratory")
sort by (effective as FHIR.dateTime).value)
define "Latest DM medication change":
Last([NexusEmrCoreMedicationRequest: medication in "DM Medications"] M
sort by authoredOn.value)
define "Follow-up due":
(("Latest HbA1c".effective as FHIR.dateTime).value before Now() - 90 days)
and (("Latest HbA1c".value > 7.0 '%')
or (("Latest DM medication change".authoredOn).value after Now() - 90 days))
import {
CqlCompiler, assertInProfile, compileToTrigger, nexusTypeResolver,
} from "@awaremd/typed-fhir/cql";
import { MeasurementCatalog, SEED_MEASUREMENTS } from "@awaremd/typed-fhir/measurements";
const compiler = await CqlCompiler.create({ modelsDir, librariesDir });
const result = await compiler.translate(cqlText);
assertInProfile(result); // throws on anything we will not ship
const trigger = compileToTrigger(result.elm!, {
resolveType: nexusTypeResolver, // bind retrieves to this specification
catalog: MeasurementCatalog.from(SEED_MEASUREMENTS), // what the numbers in the chart mean
});
The .NET and Python packages fold that chain into one call — CompileRuleAsync(cql) and
compile_rule(cql) — producing the same JSON from the same implementation. What comes back:
{
"all_of": [
{ "data_type": "lab", "code": "4548-4", "max_age_days": 90, "compare": "older_than" },
{
"any_of": [
{ "data_type": "lab", "code": "4548-4", "compare": "gt", "threshold": 7 },
{ "data_type": "medication_change", "code": "vs:dm-medications",
"max_age_days": 90, "compare": "newer_than" }
]
}
]
}
Read it as three questions the engine will ask the chart. Two properties matter before you store
it: the vocabulary is closed — data_type, compare and the rest are tokens, not free text —
and the serialization is canonical, in the engine's key order with optional fields omitted
rather than nulled, because engines hash it for clinical sign-off. Re-serializing it yourself is
how one rule acquires two hashes.
Three kinds of bad news¶
Compilation reports failures in three buckets, and the distinction is load-bearing:
- errors — the CQL did not translate. A syntax error, an unresolved identifier, a missing model.
- warnings — the translator's own, informational once they have been classified.
- violations — ours: the library is valid CQL and still not something we will ship.
A caller that reads only the error count gets a green build over a broken rule. An out-of-profile criterion always returns a null artifact alongside its violations; it is never approximated into a rule that nearly means the same thing.
It is outside the profile¶
The commonest case is a bare code filter on a resource with no primary code path:
[no-primary-code-path] Retrieve has a terminology target but does not specify a code path
and the type of the retrieve does not have a primary code path defined.
The retrieve filters by code but names no element to filter on, so it matches nothing
determinable and its manifest emits a codeFilter with no path (violating FHIR's drq-1).
Fix in the authoring -- [Type: type ~ "code"] -- or declare a primaryCodePath on the
profile with the cqf-modelInfo-primaryCodePath extension.
The message carries the fix, and the fix is one clause: name the element.
[NexusEmrCoreFamilyMemberHistory: condition.code in "Colorectal Cancer"] compiles, is
self-documenting, and yields a code filter that carries a path. The underlying CQL compiles —
the translator only warns — which is exactly why tooling for this specification promotes that
warning to an error. The CQL page lists which profiles declare a primary code
path and which deliberately do not.
The rule grammar cannot say it¶
The trigger grammar is a strict subset of CQL. Outside it, compilation refuses and names the construct:
TriggerCompileError: the engine's grammar has no negated group; negation lives in the
compare token
TriggerCompileError: clause carries a 'where' the engine's grammar has no clause field for
(ELM 'Equal'). The grammar filters on a code list, a window and a threshold; a criterion
that filters on anything else is a different rule and must be authored as one
TriggerCompileError: a 'context Encounter' library may only ask what is absent from the
encounter; the engine has no encounter-scoped token for ELM 'Greater'. Move this clause to
a Patient-context library, or split the rule
The second is the one to internalise. "An HbA1c ordered by a specialist" has nowhere to put by a
specialist, and quietly dropping that where would leave a clause firing on every HbA1c — a broad
rule wearing a narrow rule's clinical signature, passing every structural check. The refusal is the
compiler working.
The threshold's unit does not match the catalog¶
A trigger clause holds a bare number, so dropping a unit is only safe when something records which unit the stored value is in. A unit-bearing threshold is checked against a measurement catalog and refused when it cannot be proved harmless:
TriggerCompileError: compares Hemoglobin A1c/Hemoglobin.total in Blood in 'mmol/mol', and
this catalog expects '%'. NGSP percent and IFCC mmol/mol are affine, not proportional, so a
threshold in one does not transfer to the other -- and UCUM will convert between them
anyway, because both are dimensionless.
TriggerCompileError: threshold 7 'mmol/L' carries a unit, and the catalog does not list
http://loinc.org|2345-7. The engine's clause holds a bare number, so the unit may only be
dropped when something records which unit the stored value is in. Add an
ObservationDefinition for this code.
Neither is converted. Conversion is the one thing this must never do silently: 55 mmol/mol becomes
5.5 % under UCUM and means 7.2 % in medicine. Write the threshold in the unit the catalog
expects, or extend the catalog with an ObservationDefinition saying what this code's values are
reported in. A bare numeric threshold (value > 7) still compiles unchanged — it simply does not
state its convention, and nobody can check it for you.
The failure nothing reports¶
A retrieve that matches nothing does not error. It returns false, for every patient, forever.
And most useful criteria are absence-based. Against data the retrieve cannot see, those rules do not go quiet — they fire for everyone.
Look again at the fourth row of the table above. The patient has an LDL result from 30 days ago, and
Screening due is true. Nothing warned, nothing threw, and the rule is well-formed. The retrieve
asks for an Observation coded LOINC 18262-6 and categorised laboratory, and that
Observation carries no category at all, so the retrieve returns nothing and "Latest LDL" is null
is satisfied.
Two ordinary facts about real charts produce that, and neither is signalled:
- The code is not the code your data carries. Results arriving under a local lab compendium code, or panel-versus-component coding, do not match. The clause sees no LDL at all.
- The category is missing. An Observation with no
categoryis filed under the genericobservationbucket and cannot answer alabclause — deliberately, so that a clause meaning "a lab result" is never answered by data that cannot be one. Drop the category filter from the criterion above and it compiles to"data_type": "observation"instead of"lab": a genuinely different question, and the one you may have meant.
How to notice, before you deploy. Two checks, and they are cheap:
- Evaluate against real charts, not fixtures. Hand-built charts are built to satisfy the
criterion, so they cannot show you this. Pull a sample of live patients into a
PatientCache, evaluate, and be suspicious of a criterion that istruefor all of them. - Turn each data requirement into a FHIR search and check the count. Compile the criterion to
a pack (see Running criteria across a population), read the
dataRequiremententries out of the module-definition Library, and run each one:GET /Observation?patient=<id>&code=http://loinc.org|18262-6&category=laboratoryA zero count is the signal, and it is the only signal you will get. Treat it as a failing test rather than a quiet pass.