Running criteria across a population¶
You want to run clinical criteria over many patients at once — a cohort, a report, a recall list — against a database rather than a chart at a time. You need query, not per-patient evaluation.
The artifact that lets you do that is a criteria pack: for each criterion, its compiled logic plus a machine-readable statement of what data that logic reads. Generate the pack once, at authoring time, and everything downstream is JSON. No consumer of a pack needs a CQL compiler; see Criteria artifacts for why that boundary sits where it does.
Generate a pack¶
Point the generator at a directory of .cql files and an output directory. It runs from the client
library's TypeScript workspace, where the script is also wired as
npm run gen:criteria-pack -- <src-dir> <out-dir>:
npx tsx codegen/cql/gen-criteria-pack.ts <src-dir> <out-dir> [--model nexus|base]
<src-dir> directory of .cql files (searched one level deep)
<out-dir> written fresh; one .elm.json + .requirements.json per library, plus manifest.json
--model nexus (default) binds retrieves to this IG's profiles; base binds to FHIR R4
Two criteria in, and what it reports:
gen-criteria-pack: 2 librar(ies) against the Nexus model -> out/
1 clock-relative (need a periodic sweep), 1 change-driven only
4 distinct resource type(s), 4 distinct element path(s)
out/
consult-note-on-file.elm.json
consult-note-on-file.requirements.json
diabetes-follow-up.elm.json
diabetes-follow-up.requirements.json
manifest.json
The same two artifacts for a single library are available in-process, in each language, from the
host seam: compilePack(cql), CompilePackAsync(cql), compile_pack(cql). Use those when
criteria are authored at runtime — a report builder compiling a user's template on save — and the
directory generator when you own the corpus.
Read the manifest first¶
manifest.json indexes the pack, and it is the file to load before any of the others. One of its
two entries, in full:
{
"model": { "name": "Nexus", "version": "1.21.1", "canonical": "https://fhir.apps.health" },
"libraryCount": 2,
"libraries": [
{
"source": "diabetes-follow-up.cql",
"sourceHash": "sha256:1b55ad84aa8d4557e8facf4bd4953e066f236478bada624fe7e747cf25ba8187",
"name": "DiabetesFollowUp",
"version": "1.0.0",
"elm": "diabetes-follow-up.elm.json",
"requirements": "diabetes-follow-up.requirements.json",
"resourceTypes": ["MedicationRequest", "Observation", "Patient"],
"valueSets": ["https://fhir.apps.health/ValueSet/dm-medications"],
"elementsRead": ["authoredOn", "category", "effective", "value"],
"relativeDate": true,
"known": []
}
]
}
Each field is there to answer a planning question without parsing any ELM:
resourceTypes— union them across the pack and you have the bound on what your initial load has to cover. Nothing outside that set is read by any deployed criterion.valueSets— the expansions you must have resolved before a query can run. An unexpanded value set is a criterion that silently matches nothing.elementsRead— the element paths to materialise as columns. It is a lower bound: derived from retrieves and property accesses without inference across included libraries, so an absent path is not proof the logic never reads it.relativeDate—truemeans membership can change through the passage of time alone ("no screening in twelve months"), so this criterion needs a periodic sweep.falsemeans membership changes only when data changes, so a change feed is sufficient. Split your scheduling on this field and you stop re-scoring the whole estate nightly.sourceHash— SHA-256 of the.cqlthe artifacts were built from. Verify it before trusting a pack:shasum -a 256 src/diabetes-follow-up.cqlmust match. A mismatch means someone edited a criterion and did not regenerate, and serving stale clinical logic is worse than failing to serve it, because it succeeds quietly.model— which model the retrieves were bound against, and at which version. This is how you see, without reading a byte of ELM, that a pack meant for this specification was built against base FHIR instead.
The requirements Library is your WHERE clause¶
Each <name>.requirements.json is a FHIR Library with type = module-definition, derived from
the compiled logic rather than authored beside it. Its dataRequirement array is the part a query
generator consumes:
"dataRequirement": [
{ "type": "Patient",
"profile": ["https://fhir.apps.health/StructureDefinition/nexus-emr-core-patient"] },
{ "type": "Observation",
"profile": ["https://fhir.apps.health/StructureDefinition/nexus-emr-core-observation"],
"codeFilter": [
{ "path": "code",
"code": [{ "system": "http://loinc.org", "code": "4548-4", "display": "HbA1c" }] }
] },
{ "type": "MedicationRequest",
"profile": ["https://fhir.apps.health/StructureDefinition/nexus-emr-core-medicationrequest"],
"codeFilter": [
{ "path": "medication", "valueSet": "https://fhir.apps.health/ValueSet/dm-medications" }
] }
]
Each entry maps directly onto a scan: type picks the table, codeFilter.path names the column,
and codeFilter.code or codeFilter.valueSet is the predicate — a literal code list in one case, a
value set to expand in the other. profile tells you which shape the rows must satisfy; it names
this specification's profiles rather than base R4, and that only happens with the model bound
(see --model below).
The two extensions carry the rest:
criteria-relative-date— the same boolean as the manifest, and it is always written, even when false. An absent extension means the analysis did not run, not that the answer is no; those have opposite consequences for whether you schedule a sweep.criteria-elements-read— repeats, one element path per occurrence. Do not expect a delimited list in a single value. The values are path SEGMENTS as the logic names them (value,effective,code), not resource-rooted paths, and one flat set covers every resource type the library reads. To size a per-resource projection, intersect them with the types indataRequirementrather than reading them asType.path.
The ELM is the logic itself¶
<name>.elm.json is the criterion's fully resolved syntax tree: every code and value set bound, no
lookups left to do. It is what an ELM-to-SQL compiler consumes. Retrieve nodes carry the data
access:
{
"type": "Retrieve",
"dataType": "{https://fhir.apps.health}NexusEmrCoreObservation",
"templateId": "https://fhir.apps.health/StructureDefinition/nexus-emr-core-observation",
"codeProperty": "code",
"codeComparator": "~",
"codes": { "type": "ToList", "operand": { "type": "CodeRef", "name": "HbA1c" } }
}
Note dataType is namespaced. The requirements Library has already mapped it back to the FHIR
resource type for you, which is the usual reason to read that file rather than walk the ELM
yourself.
Bind the model, or the manifest is worth less¶
--model nexus is the default and is what makes dataRequirement.profile name this
specification. --model base binds retrieves to plain FHIR R4 — a supported degraded mode for a
consumer that genuinely has no implementation guide, and what it gives up is the manifest's profile
information, not the logic.
The two are not interchangeable. Packing a library written using Nexus … with --model base
fails rather than guessing:
gen-criteria-pack: REFUSED -- 2 of 2 librar(ies) failed.
diabetes-follow-up.cql: retrieve names '{https://fhir.apps.health}NexusEmrCorePatient',
which is not base FHIR and is not in the supplied model. Pass a resolveType (see
./generated/nexus-types.ts) or the manifest would claim a resource type that does not
exist.
When the pack refuses to build¶
One bad library and nothing is written — not the good libraries, not the manifest.
A partial pack cannot be told apart from a complete one by its consumer. "Absent because nobody wrote it" and "absent because it failed to compile and we shipped anyway" look identical, and the second silently drops clinical logic somebody believes is running.
So the generator collects every failure, reports them together, and exits without touching the output directory:
gen-criteria-pack: REFUSED -- 1 of 3 librar(ies) failed.
unpathed-codefilter.cql: criteria library is outside the constrained profile:
[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.
Nothing was written.
The three refusals you are most likely to meet:
- A bare code filter on a type with no primary code path, above. The fix is in the authoring and
the message names it: write
[NexusEmrCoreDocumentReference: type ~ "ConsultNote"]rather than[NexusEmrCoreDocumentReference: "ConsultNote"]. The CQL page lists which profiles declare a primary code path and which deliberately do not. - An unclassified translator warning. The translator warns about things that are fatal to a
criterion's meaning beside things that are cosmetic, so a warning with no recorded decision is
treated as an error rather than assumed harmless. Warnings that have been decided about travel
into the manifest as
known, with the evidence attached, rather than being swallowed. - Two libraries claiming one identity:
b.cql and a.cql both declare library NexusDM01 version 1.0.0. A consumer resolving anincludecould not tell them apart, so the pack refuses.
A checklist before you trust a pack¶
sourceHashmatches every.cqlon disk.model.nameandmodel.versionare the ones you meant.- Every value set in
valueSetsresolves and expands in your terminology service. - For each
dataRequirement, the equivalent FHIR search returns non-zero against real patients. Nothing reports a retrieve that matches nothing — see Evaluating a criterion for one patient for why that failure is both silent and, for absence-based criteria, backwards.