Skip to content
Open
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,8 @@ Key points
Use FHIR Range + cqf-cqlType for intervals.
Avoid string comparison; compare structured values.

Numeric intervals (`Interval<Integer>`, `Interval<Long>`, `Interval<Decimal>`) are mapped per [FHIR-56226](https://jira.hl7.org/browse/FHIR-56226): a FHIR `Range` whose boundaries are unity quantities (`code: "1"`, `system: http://unitsofmeasure.org`) carrying a [`quantity-precision`](http://hl7.org/fhir/StructureDefinition/quantity-precision) extension. Because `Range` cannot express open boundaries, an open boundary is sent as its closed equivalent at the stated precision (e.g., `Interval[1.0, 1.4)` becomes `Range [1.0, 1.3]` with precision 1). The runner identifies numeric intervals by the [`cqf-cqlType`](http://hl7.org/fhir/StructureDefinition/cqf-cqlType) extension on the returned parameter (e.g., `Interval<System.Decimal>`); a `Range` returned without it is treated as a quantity interval, so engines must declare the type for these tests to pass. Declared numeric intervals are extracted as plain numeric intervals and, when comparing, the runner normalizes open and closed boundary forms using the declared precision (falling back to the CQL defaults: step 1 for Integer/Long, 10^-8 for Decimal), so an expected `Interval[1.0, 1.4)` matches both `[1.0, 1.3]` at precision 1 and an open-boundary representation.

```mermaid
flowchart LR
%% Top row (left → right)
Expand Down
155 changes: 155 additions & 0 deletions docs/plan-fhir-56226-numeric-interval-checks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Plan: Numeric interval (`Interval<Integer|Decimal|Long>`) checks per FHIR-56226

Tracker: [FHIR-56226](https://jira.hl7.org/browse/FHIR-56226) — "Add mapping for Intervals of Integer, Decimal, and Long"
Related runner issue: [#85 — Test Runner Not Detecting Equal Intervals (Open vs Closed Boundaries)](https://github.com/cqframework/cql-tests-runner/issues/85)

## Background

The CQL-to-FHIR mapping did not specify how to represent intervals of Integer, Decimal, or
Long. FHIR-56226 (spun out of issue #85 after the US Realm Connectathon 2026 discussion)
proposes mapping these as FHIR `Range`, with each boundary a unity `Quantity`
(`code: "1"`, `system: http://unitsofmeasure.org`) carrying a
`http://hl7.org/fhir/StructureDefinition/quantity-precision` extension (`valueInteger`).
Because `Range` cannot express open boundaries, an open boundary is converted to its
closed equivalent *at the stated precision*, and the precision extension preserves enough
information that this conversion does not force a precision choice:

```
Interval[1.0, 1.4) ⇒ Range {
low: { value: 1.0, precision: 1 }, // code "1", system UCUM
high: { value: 1.3, precision: 1 } // predecessor of 1.4 at precision 1
}
```

(The Jira example writes the extension URL as `quanity-precision`; that is a typo in the
ticket — the real extension is `quantity-precision`.)

## Current state of the runner

| Concern | Where | Behavior today |
|---|---|---|
| Actual: interval extraction | `src/extractors/value-type-extractors/quantity-interval-extractor.ts` | Any `valueRange` becomes `{lowClosed, low: {value, unit}, highClosed, high: {value, unit}}`; boundaries are always closed when present; unity code `"1"` is kept as `unit: "1"`; precision extensions are ignored. |
| Actual: extractor chain | `src/server/extractor-builder.ts` | No numeric-interval extractor exists; unity-coded ranges fall through to `QuantityIntervalExtractor`. |
| Expected: parsing | `cvl/cvl.mjs` (`visitIntervalSelector`) | `Interval[1.0, 1.4)` → `{lowClosed: true, low: 1.0, highClosed: false, high: 1.4}` with plain-number boundaries (`BigInt` for `L` literals). Open/closed flags are preserved. |
| Comparison | `src/shared/results-utils.ts` (`resultsEqual`) | Generic structural compare with a `1e-8` numeric epsilon. An expected open interval never equals an actual closed `Range`, and a numeric expected boundary never equals a `{value, unit}` quantity object. |
| Symptom | `conf/cql-execution-local.json` | Five `CqlIntervalOperatorsTest` skips citing issue #85 (`DecimalIntervalExcept1to3`, `Except12`, `ExceptTime2`, `ExceptTimeInterval`, `IntegerIntervalExcept1to3`). |

Both gaps must be fixed for numeric intervals to pass:

1. **Shape gap** — a unity-coded `Range` must extract to plain numeric boundaries, not
quantity objects.
2. **Boundary-semantics gap** — expected values may be written with open boundaries
(`Interval[1.0, 1.4)`) while the actual `Range` is always closed; the comparison must
equate the two using the declared precision.

## Step 1 — `NumericIntervalExtractor`

New file `src/extractors/value-type-extractors/numeric-interval-extractor.ts`:

- Handles a parameter with `valueRange` only when the interval is *declared* numeric by the
`http://hl7.org/fhir/StructureDefinition/cqf-cqlType` extension on the parameter naming
`Interval<System.Integer>`, `Interval<System.Long>` or `Interval<System.Decimal>` (the
`System.` prefix is optional). Detection is strictly this: no such extension (or a
non-numeric interval type) means the range is not claimed and falls through to
`QuantityIntervalExtractor` unchanged. This also resolves the existing TODO pattern in
`datetime-interval-extractor.ts`.
- A fallback that inferred a numeric interval from boundary quantities with `code === '1'`,
`system === 'http://unitsofmeasure.org'` and no human `unit` was implemented and then
rejected in review: FHIR-56226 only defines the forward mapping (how a numeric interval
is represented), not an inverse detection rule, and unity coding is ambiguous with a
genuinely dimensionless `Interval<Quantity>`.
- Extracts to the same shape CVL produces: `{lowClosed, low, highClosed, high}` with
**plain-number boundaries** (missing boundary → `null` + `*Closed: false`, mirroring
`QuantityIntervalExtractor`).
- Reads the `quantity-precision` extension from both plausible placements and records it:
- `low.extension[]` (placement shown in the Jira example), and
- `low._value.extension[]` (primitive-extension placement, since the extension is
defined on `Quantity.value`).
- Precision must ride along for the comparison step **without breaking structural
equality** (`resultsEqual` compares `Object.keys` counts). Attach it under a shared
`Symbol` key (e.g. `INTERVAL_PRECISION` exported from a small
`src/shared/interval-utils.ts`): symbols are invisible to `Object.keys`/JSON but
readable by the comparator.
- Register it in `src/server/extractor-builder.ts` **before** `QuantityIntervalExtractor`
so declared numeric ranges are claimed first; every other range (including unity-coded
ranges without a cqlType extension) keeps its current behavior.

## Step 2 — Interval-aware comparison

In `src/shared/results-utils.ts` (helpers in `src/shared/interval-utils.ts`):

- In `resultsEqual`, before the generic object walk, detect "interval-shaped" operands
(objects owning `lowClosed` and `highClosed`) and delegate to `intervalsEqual(expected, actual)`.
- `intervalsEqual` normalizes both sides to closed boundaries, then compares numerically
(reusing the existing `1e-8` epsilon):
- **Comparison step size**: `10^-p` from the actual boundary's `quantity-precision`
extension when present; otherwise the CQL default — `1` for Integer/Long boundaries,
`1e-8` for Decimal (this is why suite expecteds are written like
`Interval[1.0, 3.99999999]`).
- **Expected side**: if `highClosed === false && high !== null`, replace `high` with
`high - step` (successor for open low). With the ticket's example, expected
`[1.0, 1.4)` at the actual's declared precision 1 → `[1.0, 1.3]`, matching the
actual Range; expected `[1.0, 3.99999999]` (already closed) still matches an
engine that sends the full-precision predecessor.
- **Actual side**: extracted Ranges are already closed; the flags stay as extracted.
Intervals from other representations (e.g. the `part`-based Tuple form in issue #85)
may carry `highClosed: false` — normalize those the same way so open-vs-closed
equivalence works for every numeric interval representation, not just Range.
- **Null boundaries** compare as today (both null → equal).
- **Long**: CVL yields `BigInt` for `1L` literals; coerce BigInt↔number safely before
the epsilon compare (values inside FHIR decimals are within `Number` range in the
suite; guard with `Number.isSafeInteger` and fall back to `BigInt` equality when both
sides are integral).
- Deliberately *not* over-lenient: sides are compared after normalizing at the **actual's
declared precision** (or CQL default). An engine that truncates a predecessor at a
precision it did not declare (e.g. returns `3.9` with no precision extension for
`[1.0, 4.0)`) still fails against `[1.0, 3.99999999]` — that is a genuinely different
interval.

## Step 3 — Unit tests (vitest, `test/`)

- `test/extractResults-cql_operations.test.ts` (and the `$evaluate` twin):
- cqlType-declared Range with precision extensions (both `extension` and
`_value.extension` placements) → numeric interval with plain-number boundaries;
- integer range (no precision extension) and half-open range (missing `high`);
- real quantity Range (e.g. `ml`), an `Interval<System.Quantity>` cqlType over unity
boundaries, and a unity-coded range with **no** cqlType extension all still extract via
`QuantityIntervalExtractor` (regression guards on chain ordering and strict detection).
- `test/results-utils.test.ts`:
- ticket example: expected `Interval[1.0, 1.4)` vs actual `[1.0, 1.3]` @ precision 1 → pass;
- issue #85 case: expected `Interval[1.0, 3.99999999]` vs actual open `[1.0, 4.0)`
(part-form) and vs closed `[1.0, 3.99999999]` → pass;
- `IntegerIntervalExcept1to3`-style: expected `Interval[1, 4)` vs actual `[1, 3]` → pass;
- negative cases: mismatched precision (`3.9`, no extension, vs `3.99999999`),
differing closed flags with differing values, null vs non-null boundary;
- Long boundaries (`Interval[1L, 4L)`).
- `test/cvl-parser.test.ts`: add open-boundary interval parse cases if not covered.

## Step 4 — Docs and configuration cleanup

- Update the README Test Flow notes (currently "Interval → Range + cqf-cqlType") to
describe the numeric-interval mapping and the precision-based open→closed
normalization.
- Once verified against a live engine, drop (or re-word) the five issue-85 skip entries in
`conf/cql-execution-local.json`; they are engine-specific config, so removal can be a
follow-up commit after a `run-tests` pass against cql-execution.

## Risks / open questions

- **FHIR-56226 is still "Submitted"** — the mapping is proposed, not yet applied to the
CQL IG. Detection *requires* the `cqf-cqlType` extension, so existing representations keep
working unchanged (part-based Tuple form, plain quantity ranges), but an engine that omits
`cqf-cqlType` will have its numeric-interval results extracted as quantity intervals and
will fail those tests until it declares the type.
- **Extension placement ambiguity** — the ticket shows `low.extension`, FHIR primitive
extension rules imply `low._value.extension`; support both until the IG publishes the
normative shape.
- **String decimal values** — the ticket example shows `value: "1.0"` (a string) to keep
trailing zeros; FHIR JSON says `decimal`. Tolerate both (`typeof value === 'string'` →
`Number(value)`, and take digits-after-dot as an implicit precision fallback when no
extension is present).
- **Quantity intervals** — the same open/closed problem exists for `Interval<Quantity>`
(skips `ExceptTime2`/`ExceptTimeInterval` are Time, and quantity ranges can be open
too). The `intervalsEqual` normalization is written boundary-type-agnostic where cheap
(numbers and `{value, unit}` quantities); Date/DateTime/Time predecessor logic is out of
scope here and tracked by issues #80/#84.
127 changes: 127 additions & 0 deletions src/extractors/value-type-extractors/numeric-interval-extractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { BaseExtractor } from '../base-extractor.js';
import { IntervalMeta, IntervalPointType, setIntervalMeta } from '../../shared/interval-utils.js';

const CQL_TYPE_URL = 'http://hl7.org/fhir/StructureDefinition/cqf-cqlType';
const PRECISION_URL = 'http://hl7.org/fhir/StructureDefinition/quantity-precision';
const NUMERIC_INTERVAL_TYPE = /^Interval<(?:System\.)?(Integer|Long|Decimal)>$/;

function cqlType(parameter: any): string | undefined {
if (!Array.isArray(parameter.extension)) {
return undefined;
}
const extension = parameter.extension.find(
(e: any) => e !== null && typeof e === 'object' && e.url === CQL_TYPE_URL
);
return extension !== undefined && typeof extension.valueString === 'string'
? extension.valueString
: undefined;
}

function precisionExtension(extensions: any): number | undefined {
if (!Array.isArray(extensions)) {
return undefined;
}
const extension = extensions.find(
(e: any) => e !== null && typeof e === 'object' && e.url === PRECISION_URL
);
return extension !== undefined && typeof extension.valueInteger === 'number'
? extension.valueInteger
: undefined;
}

/**
* Decimal places declared for a boundary: the quantity-precision extension on the
* Quantity itself (as in the FHIR-56226 example) or on Quantity.value as a primitive
* extension, falling back to the digits of a string-encoded value.
*/
function precisionOf(quantity: any): number | undefined {
const declared =
precisionExtension(quantity.extension) ??
precisionExtension(
quantity._value !== null && typeof quantity._value === 'object'
? quantity._value.extension
: undefined
);
if (declared !== undefined) {
return declared;
}

if (typeof quantity.value === 'string') {
const separator = quantity.value.indexOf('.');
if (separator >= 0) {
return quantity.value.length - separator - 1;
}
}

return undefined;
}

function boundaryValue(quantity: any): number | null {
if (quantity === null || typeof quantity !== 'object' || !quantity.hasOwnProperty('value')) {
return null;
}
const value = typeof quantity.value === 'string' ? Number(quantity.value) : quantity.value;
// A value that is missing or not numeric makes the boundary unusable; treat it as
// absent so the closed flag stays consistent and no NaN/undefined reaches comparison.
return typeof value === 'number' && !Number.isNaN(value) ? value : null;
}

/**
* Extracts a `valueRange` that represents a numeric CQL interval
* (`Interval<Integer|Long|Decimal>`, FHIR-56226) into the plain-number shape CVL
* produces: `{lowClosed, low, highClosed, high}`.
*
* Detection is strict: the parameter must carry a `cqf-cqlType` extension naming
* `Interval<Integer>`, `Interval<Long>` or `Interval<Decimal>` (the `System.` prefix is
* optional). Any other `valueRange` — including one whose boundaries are unity quantities
* (`code: "1"`, UCUM) but which declares no cqlType — is left to
* `QuantityIntervalExtractor`. FHIR-56226 only defines the forward mapping, and unity
* coding alone is ambiguous with a dimensionless `Interval<Quantity>`.
*/
export class NumericIntervalExtractor extends BaseExtractor {
protected _process(parameter: any): any {
if (!parameter.hasOwnProperty('valueRange')) {
return undefined;
}

const range = parameter.valueRange;
if (range === null || typeof range !== 'object') {
return undefined;
}

// The cqlType extension is required and authoritative: without a numeric interval
// type this range is not ours, whatever its boundaries look like.
const declaredType = cqlType(parameter);
const match = declaredType !== undefined ? NUMERIC_INTERVAL_TYPE.exec(declaredType) : null;
if (match === null) {
return undefined;
}
const pointType = match[1] as IntervalPointType;

const lowQuantity = range.hasOwnProperty('low') ? range.low : undefined;
const highQuantity = range.hasOwnProperty('high') ? range.high : undefined;

const low = lowQuantity !== undefined ? boundaryValue(lowQuantity) : null;
const high = highQuantity !== undefined ? boundaryValue(highQuantity) : null;

const result = {
lowClosed: low !== null,
low: low,
highClosed: high !== null,
high: high,
};

const meta: IntervalMeta = { pointType: pointType };
const lowPrecision = low !== null ? precisionOf(lowQuantity) : undefined;
if (lowPrecision !== undefined) {
meta.lowPrecision = lowPrecision;
}
const highPrecision = high !== null ? precisionOf(highQuantity) : undefined;
if (highPrecision !== undefined) {
meta.highPrecision = highPrecision;
}
setIntervalMeta(result, meta);

return result;
}
}
40 changes: 21 additions & 19 deletions src/server/extractor-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { TimeExtractor } from '../extractors/value-type-extractors/time-extracto
import { QuantityExtractor } from '../extractors/value-type-extractors/quantity-extractor.js';
import { RatioExtractor } from '../extractors/value-type-extractors/ratio-extractor.js';
import { DateTimeIntervalExtractor } from '../extractors/value-type-extractors/datetime-interval-extractor.js';
import { NumericIntervalExtractor } from '../extractors/value-type-extractors/numeric-interval-extractor.js';
import { QuantityIntervalExtractor } from '../extractors/value-type-extractors/quantity-interval-extractor.js';
import { CodeExtractor } from '../extractors/value-type-extractors/code-extractor.js';
import { ConceptExtractor } from '../extractors/value-type-extractors/concept-extractor.js';
Expand All @@ -20,23 +21,24 @@ import { ResultExtractor } from '../extractors/result-extractor.js';
* Builds a ResultExtractor with the full chain of extractors
*/
export function buildExtractor(): ResultExtractor {
const extractors = new EvaluationErrorExtractor();
extractors
.setNextExtractor(new NullEmptyExtractor())
.setNextExtractor(new UndefinedExtractor())
.setNextExtractor(new StringExtractor())
.setNextExtractor(new BooleanExtractor())
.setNextExtractor(new IntegerExtractor())
.setNextExtractor(new DecimalExtractor())
.setNextExtractor(new DateExtractor())
.setNextExtractor(new DateTimeExtractor())
.setNextExtractor(new TimeExtractor())
.setNextExtractor(new QuantityExtractor())
.setNextExtractor(new RatioExtractor())
.setNextExtractor(new DateTimeIntervalExtractor())
.setNextExtractor(new QuantityIntervalExtractor())
.setNextExtractor(new CodeExtractor())
.setNextExtractor(new ConceptExtractor());

return new ResultExtractor(extractors);
const extractors = new EvaluationErrorExtractor();
extractors
.setNextExtractor(new NullEmptyExtractor())
.setNextExtractor(new UndefinedExtractor())
.setNextExtractor(new StringExtractor())
.setNextExtractor(new BooleanExtractor())
.setNextExtractor(new IntegerExtractor())
.setNextExtractor(new DecimalExtractor())
.setNextExtractor(new DateExtractor())
.setNextExtractor(new DateTimeExtractor())
.setNextExtractor(new TimeExtractor())
.setNextExtractor(new QuantityExtractor())
.setNextExtractor(new RatioExtractor())
.setNextExtractor(new DateTimeIntervalExtractor())
.setNextExtractor(new NumericIntervalExtractor())
.setNextExtractor(new QuantityIntervalExtractor())
.setNextExtractor(new CodeExtractor())
.setNextExtractor(new ConceptExtractor());

return new ResultExtractor(extractors);
}
Loading