Skip to content

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.
  • relativeDatetrue means membership can change through the passage of time alone ("no screening in twelve months"), so this criterion needs a periodic sweep. false means 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 .cql the artifacts were built from. Verify it before trusting a pack: shasum -a 256 src/diabetes-follow-up.cql must 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-readrepeats, 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 in dataRequirement rather than reading them as Type.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 an include could not tell them apart, so the pack refuses.

A checklist before you trust a pack

  1. sourceHash matches every .cql on disk.
  2. model.name and model.version are the ones you meant.
  3. Every value set in valueSets resolves and expands in your terminology service.
  4. 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.