Skip to content

Support numeric interval results mapped as Range per FHIR-56226 - #110

Open
alexzautke wants to merge 12 commits into
mainfrom
claude/interval-decimal-checks-plan-yw4gjv
Open

Support numeric interval results mapped as Range per FHIR-56226#110
alexzautke wants to merge 12 commits into
mainfrom
claude/interval-decimal-checks-plan-yw4gjv

Conversation

@alexzautke

@alexzautke alexzautke commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements runner support for the proposed FHIR-56226 mapping of Interval<Integer>, Interval<Long>, and Interval<Decimal> results, and fixes the open-vs-closed boundary comparison mismatch for numeric intervals reported in #85.

Under FHIR-56226, a numeric interval is returned as a FHIR Range whose boundaries are unity quantities (code: "1", system: http://unitsofmeasure.org), each optionally carrying a quantity-precision extension. Since 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) arrives as [1.0, 1.3] with precision 1. The runner previously extracted such ranges as quantity intervals and compared boundary flags literally, so these results could never match the expected values.

Note: the comparison change is not gated on FHIR-56226 adoption. resultsEqual delegates every interval-shaped operand to intervalsEqual, so open/closed boundary normalization applies to interval comparison today — including the existing part-based interval representation — independent of whether an engine uses the proposed Range mapping.

Changes

Extraction

  • New NumericIntervalExtractor (registered ahead of QuantityIntervalExtractor) extracts a declared numeric valueRange into the same plain-number {lowClosed, low, highClosed, high} shape the CVL parser produces for expected values. Interval<Long> boundaries extract as BigInt (exact beyond 2^53 when the engine sends the value as a JSON string; a bare JSON number is rounded by the JSON parser before extraction).
  • Detection is strictly declaration-driven: the parameter must carry a cqf-cqlType extension naming Interval<Integer|Long|Decimal> (optional System. prefix). A unity-coded fallback without the extension was implemented and then removed in review — FHIR-56226 only defines the forward mapping, not an inverse detection rule, and unity coding alone is ambiguous with a dimensionless Interval<Quantity>. Ranges without the extension keep their existing quantity-interval behavior.
  • The quantity-precision extension is read from both plausible placements (low.extension as shown in the Jira example, and low._value.extension per FHIR primitive-extension rules) and recorded — together with the point type — as non-enumerable Symbol-keyed metadata (src/shared/interval-utils.ts). This metadata exists only on the live extracted object: any serialization round-trip drops it, so comparison must run before serialization (documented in interval-utils.ts; relevant to the runner-core refactor in Dedupe runners for cli and server paths #108). Non-numeric boundary values are treated as absent.
  • Intervals in the part-based representation (Test Runner Not Detecting Equal Intervals (Open vs Closed Boundaries) #85) get their point type derived from the wire: the parameter's cqf-cqlType extension when it names a numeric interval type, otherwise the FHIR element types of the boundary parts (valueInteger → Integer, valueDecimal → Decimal; valueString and mixed types derive nothing).

Comparison

  • resultsEqual detects interval-shaped operands (strictly: exactly the four interval keys with boolean closed flags — near-miss tuples keep comparing by plain value) and delegates to intervalsEqual, which normalizes open boundaries on both sides to their closed equivalent one step inwards before comparing values, so open and closed forms of the same interval are equal. This fixes the part-based cases from Test Runner Not Detecting Equal Intervals (Open vs Closed Boundaries) #85 ([1.0, 4.0) vs [1.0, 3.99999999], [1, 4) vs [1, 3]).
  • The step is chosen per boundary from recorded metadata: 1 for a declared/wire-derived Integer or Long point type (a precision extension cannot override it); otherwise a validated non-negative-integer quantity-precision (10^-p; precision 0 legitimately yields step 1 for Decimals); otherwise the CQL decimal step (10^-8). Step 1 requires a declared or wire-derived point type — a heuristic that inferred it from integral-looking values was implemented and then dropped in review, because it accepted both the integer-step and the decimal-step answer for the same untyped expected. Untyped intervals always compare at the decimal step.
  • Boundary values compare with a tolerance of half the step (not the fixed 1e-8): with tolerance equal to the step, values a full step apart sit exactly on the comparison boundary and the verdict depends on float rounding. When the step underflows the float's resolution at extreme magnitudes (1e9 - 1e-8 === 1e9), an open and a closed boundary at the same value are unequal by rule — the one-point difference is real even when the value can no longer express it. The comparison stays deliberately not over-lenient: an engine that truncates a predecessor at an undeclared precision still fails.
  • BigInt Long boundaries compare exactly beyond Number.MAX_SAFE_INTEGER; Date/DateTime/Time intervals keep the pre-existing structural semantics byte-for-byte (Test Runner Not Detecting Equal Lists of Interval<DateTime>/Period #80/Test Runner Not Detecting Equal DateTimes (Offset vs No Offset) #84 remain out of scope).
  • The four-key interval-shape predicate lives in one place (interval-utils.ts) and is shared by comparison, part-form extraction, and the results-file rendering that Extract Date/Time intervals from Periods via cqf-cqlType and render interval/quantity actuals in CQL syntax #109 introduced in cql-test-results.ts.

Docs

  • docs/plan-fhir-56226-numeric-interval-checks.md records the full design and the decisions made during review (strict detection, heuristic removal, tolerance and underflow rules, precision-0 semantics, extension URL typo in the ticket, extension placement, string-encoded values).
  • README test-flow section documents the mapping and detection rule.

Testing

Notes for reviewers

  • Because intervalsEqual is ungated (see note above), this PR changes interval comparison behavior for all engines as of merge — most visibly, open/closed boundary forms of the same interval now compare equal, and untyped integer-looking intervals now compare at the decimal step rather than being heuristically matched.
  • Trade-off accepted with strict detection: an engine that follows the FHIR-56226 JSON example verbatim but omits cqf-cqlType will have numeric-interval results extracted as quantity intervals and fail those tests. It may be worth feeding back on FHIR-56226 that the mapping should require the cqlType extension to be invertible.
  • FHIR-56226 is still in Submitted state; if the ballot resolution changes the representation, the extractor is the single place to adjust.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WUJ7RDTBnqwBwFFwGwrR1b

claude added 6 commits July 26, 2026 18:40
Plan for adjusting Interval<Integer|Decimal|Long> extraction and
comparison to the proposed Range + quantity-precision mapping
(FHIR-56226, spun out of issue #85).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WUJ7RDTBnqwBwFFwGwrR1b
…apping

Groundwork for FHIR-56226: a Symbol-keyed, non-enumerable metadata slot
for interval precision/point type shared between the extractor and the
comparison logic, plus README notes on the Range + quantity-precision
mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WUJ7RDTBnqwBwFFwGwrR1b
…-56226)

Extracts Interval<Integer|Long|Decimal> results sent as FHIR Range with
unity-coded Quantity boundaries into plain numeric interval objects,
recording the quantity-precision extension (from either the Quantity or
its primitive value element) and the cqf-cqlType point type as interval
metadata for the comparison step. Registered ahead of the quantity
interval extractor so unity ranges are claimed first; real quantity
ranges are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WUJ7RDTBnqwBwFFwGwrR1b
resultsEqual now detects interval-shaped operands and compares them via
intervalsEqual, which normalizes open boundaries to their closed
equivalent one step inwards before comparing values (FHIR-56226 /
issue #85). The step comes from the actual interval's declared
quantity-precision metadata when present, then the declared point type
(1 for Integer/Long, 10^-8 for Decimal), then an integrality heuristic
for untyped integer intervals, defaulting to the CQL decimal step.
Quantity boundaries never use the integer heuristic (Quantity values
are Decimals), BigInt Long boundaries compare safely, and date/time
intervals keep the pre-existing structural comparison.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WUJ7RDTBnqwBwFFwGwrR1b
Detection is now strictly declaration-driven: a valueRange is extracted
as a numeric interval only when the parameter carries a cqf-cqlType
extension naming Interval<Integer|Long|Decimal>. The unity-quantity
fallback (code '1', UCUM, no unit) was removed after review: FHIR-56226
defines only the forward mapping, not an inverse detection rule, and
unity coding is ambiguous with a dimensionless Interval<Quantity>.
Unity-coded ranges without the extension fall through to the quantity
interval extractor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WUJ7RDTBnqwBwFFwGwrR1b
…al-checks-plan-yw4gjv

# Conflicts:
#	test/results-utils.test.ts
@alexzautke alexzautke changed the title Claude/interval decimal checks plan yw4gjv Support numeric interval results mapped as Range per FHIR-56226 Jul 31, 2026
@alexzautke
alexzautke requested a review from Copilot July 31, 2026 09:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support in the runner for the proposed FHIR-56226 representation of numeric CQL intervals (Integer/Long/Decimal) as FHIR Range, including precision metadata, and updates result comparison so open/closed boundary forms can match (fixing the numeric open-vs-closed mismatch reported in #85).

Changes:

  • Introduces NumericIntervalExtractor to extract declared numeric valueRange results into plain {lowClosed, low, highClosed, high} intervals and attach non-enumerable metadata (point type + precision).
  • Updates resultsEqual to detect interval-shaped operands and delegate to interval-aware comparison that normalizes open boundaries inward by an appropriate step.
  • Adds comprehensive unit tests and documents the mapping/detection/normalization behavior.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/results-utils.test.ts Adds interval comparison unit tests (FHIR-56226 and #85 scenarios, including metadata-based precision handling).
test/extractResults-library_evaluate_operations.test.ts Adds $evaluate extraction fixtures for numeric Range intervals and metadata assertions.
test/extractResults-cql_operations.test.ts Adds $cql extraction fixtures for numeric Range intervals and metadata assertions.
src/shared/results-utils.ts Implements interval-aware equality with boundary normalization and precision/type-based step selection.
src/shared/interval-utils.ts Adds shared Symbol-keyed interval metadata utilities and interval-shape detection.
src/server/extractor-builder.ts Registers NumericIntervalExtractor ahead of QuantityIntervalExtractor.
src/extractors/value-type-extractors/numeric-interval-extractor.ts New extractor for strictly-declared numeric interval Range results, including precision extraction.
README.md Documents FHIR-56226 numeric interval mapping and the strict detection rule via cqf-cqlType.
docs/plan-fhir-56226-numeric-interval-checks.md Design/decision record for numeric interval extraction and comparison.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/extractors/value-type-extractors/numeric-interval-extractor.ts Outdated
Comment thread src/shared/results-utils.ts Outdated
… precisions

A Range boundary whose quantity value is missing or not numeric is now
treated as absent instead of producing a closed boundary with an
unusable value. Step selection ignores negative or fractional
quantity-precision values, and an Integer/Long point type always steps
by one regardless of any declared precision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WUJ7RDTBnqwBwFFwGwrR1b

@bryantaustin13 bryantaustin13 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. boundaryValue must return BigInt for Long boundaries. Right now Interval is advertised, typed, and unusable above 2^53 — and it leaves the BigInt paths in closeBoundary/scalarsEqual unreachable from extraction. Branch on pointType === 'Long', or on the value being an integer-literal string.
  2. Decouple the equality tolerance from the predecessor step. With both at 1e-8, one step inward is one tolerance wide, so decimal open-vs-closed resolves by float magnitude rather than by rule. Either drop the tolerance well below the smallest step (step / 2, or a fixed 1e-12) or compare at the declared precision using scaled integers.

claude added 5 commits August 12, 2026 21:02
Interval<Long> boundaries now extract as BigInt (from integer-literal
JSON strings, or integral numbers within Number's exact range), so
values beyond 2^53 compare exactly against the BigInt expecteds the CVL
parser produces. Interval boundary comparison now uses a tolerance of
half the boundary's step instead of the fixed 1e-8 epsilon: with
tolerance equal to the step, values a full step apart sat exactly on
the comparison boundary and the verdict depended on float rounding of
the subtraction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WUJ7RDTBnqwBwFFwGwrR1b
…ison

The integrality heuristic accepted both the integer-step and the
decimal-step answer for the same untyped expected interval, so it added
leniency without adding a check. It is dropped: step 1 now requires a
recorded point type. For the part-based interval representation, which
carries no Range metadata, the point type is derived from the wire —
the parameter's cqf-cqlType extension when it names a numeric interval
type, otherwise the FHIR element types of the boundary parts
(valueInteger vs valueDecimal). An untyped interval always compares at
the decimal step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WUJ7RDTBnqwBwFFwGwrR1b
isIntervalShaped now requires exactly the four interval keys with
boolean closed flags, so tuples that merely share some of the field
names keep comparing (and rendering) by plain value as on main. The
strict predicate makes the extra-key handling in intervalsEqual dead
code, so it is removed. The duplicate four-key predicate that #109
added in cql-test-results.ts is replaced by the shared one, giving the
comparison and the results-file rendering a single definition.

Also documents that the Symbol-keyed interval metadata exists only on
the live extracted object (serialization drops it, so comparison must
precede any round-trip), and that a declared precision of 0 legitimately
yields step 1 for Decimal intervals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WUJ7RDTBnqwBwFFwGwrR1b
… resolution

At extreme magnitudes the normalization step underflows the double's
resolution (1e9 - 1e-8 === 1e9), so closing an open boundary changed
nothing and an open interval compared equal to its closed form. When
exactly one side is open and normalization cannot move it, the
intervals differ by one point the value can no longer express — they
are now unequal by rule rather than equal by rounding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WUJ7RDTBnqwBwFFwGwrR1b

Copy link
Copy Markdown
Contributor Author

Follow-up: review-driven changes since the initial implementation

Summary of what changed on this branch in response to the review rounds, most recent last:

  • 354b5ff — Boundary and precision guards. A Range boundary whose quantity value is missing or non-numeric ("abc"NaN, booleans) is treated as an absent boundary instead of a closed boundary with an unusable value. Step selection ignores negative or fractional quantity-precision values, and an Integer/Long point type always steps by 1 regardless of any declared precision.
  • 34cfd41 — Exact Longs and decoupled tolerance. Interval<Long> boundaries extract as BigInt (exact beyond 2^53 when the engine sends the value as a JSON string; a bare JSON number is rounded by the JSON parser before extraction), so they compare exactly against the BigInt expecteds the CVL parser produces. Boundary comparison uses a tolerance of half the step instead of the fixed 1e-8: with tolerance equal to the step, values a full step apart sat exactly on the comparison boundary and the verdict depended on float rounding of the subtraction.
  • d66f748 — Integrality heuristic removed. The heuristic accepted both the integer-step and the decimal-step answer for the same untyped expected, so it added leniency without a check. Step-1 comparison now requires a declared or wire-derived point type: the cqf-cqlType extension for a Range, or — for the part-based representation from Test Runner Not Detecting Equal Intervals (Open vs Closed Boundaries) #85, which carries no Range metadata — the FHIR element types of the boundary parts (valueInteger → Integer, valueDecimal → Decimal; valueString and mixed types derive nothing). Untyped intervals always compare at the decimal step; [1.0, 4.0) no longer matches [1.0, 3.0].
  • 625e793 — Interval-shape predicate tightened and consolidated (after merging main to pick up Extract Date/Time intervals from Periods via cqf-cqlType and render interval/quantity actuals in CQL syntax #109). isIntervalShaped requires exactly the four interval keys with boolean closed flags, so tuples that merely share the field names keep main's plain value comparison (and render as JSON, not Interval(...)). The duplicate four-key predicate Extract Date/Time intervals from Periods via cqf-cqlType and render interval/quantity actuals in CQL syntax #109 added in cql-test-results.ts is replaced by the single shared one in interval-utils.ts. Also documented: the Symbol-keyed interval metadata exists only on the live extracted object — serialization drops it, so comparison must precede any round-trip (relevant to the runner-core refactor in Dedupe runners for cli and server paths #108) — and a declared precision of 0 legitimately yields step 1 for Decimals.
  • 52f7fed — Underflow rule. At extreme magnitudes the step underflows the double's resolution (1e9 - 1e-8 === 1e9), so normalization couldn't move an open boundary and [1.5, 1e9) compared equal to [1.5, 1e9]. When exactly one side is open and closing it cannot move the value, the intervals differ by one point the float can no longer express — now unequal by rule.

The PR description reflects all of the above, including the note that intervalsEqual is ungated and changes interval comparison behavior today, independent of FHIR-56226 adoption. Suite: 184 tests passing, tsc --noEmit clean.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants