Skip to content

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 .cql file 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 closeddata_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 category is filed under the generic observation bucket and cannot answer a lab clause — 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:

  1. 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 is true for all of them.
  2. 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 dataRequirement entries out of the module-definition Library, and run each one:
    GET /Observation?patient=<id>&code=http://loinc.org|18262-6&category=laboratory
    

    A 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.