Support numeric interval results mapped as Range per FHIR-56226 - #110
Support numeric interval results mapped as Range per FHIR-56226#110alexzautke wants to merge 12 commits into
Conversation
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
There was a problem hiding this comment.
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
NumericIntervalExtractorto extract declared numericvalueRangeresults into plain{lowClosed, low, highClosed, high}intervals and attach non-enumerable metadata (point type + precision). - Updates
resultsEqualto 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.
… 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
left a comment
There was a problem hiding this comment.
- 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.
- 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.
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
…al-checks-plan-yw4gjv
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
|
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:
The PR description reflects all of the above, including the note that Generated by Claude Code |
Summary
Implements runner support for the proposed FHIR-56226 mapping of
Interval<Integer>,Interval<Long>, andInterval<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
Rangewhose boundaries are unity quantities (code: "1",system: http://unitsofmeasure.org), each optionally carrying aquantity-precisionextension. SinceRangecannot 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.Changes
Extraction
NumericIntervalExtractor(registered ahead ofQuantityIntervalExtractor) extracts a declared numericvalueRangeinto 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).cqf-cqlTypeextension namingInterval<Integer|Long|Decimal>(optionalSystem.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 dimensionlessInterval<Quantity>. Ranges without the extension keep their existing quantity-interval behavior.quantity-precisionextension is read from both plausible placements (low.extensionas shown in the Jira example, andlow._value.extensionper FHIR primitive-extension rules) and recorded — together with the point type — as non-enumerableSymbol-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 ininterval-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.part-based representation (Test Runner Not Detecting Equal Intervals (Open vs Closed Boundaries) #85) get their point type derived from the wire: the parameter'scqf-cqlTypeextension when it names a numeric interval type, otherwise the FHIR element types of the boundary parts (valueInteger→ Integer,valueDecimal→ Decimal;valueStringand mixed types derive nothing).Comparison
resultsEqualdetects interval-shaped operands (strictly: exactly the four interval keys with boolean closed flags — near-miss tuples keep comparing by plain value) and delegates tointervalsEqual, 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 thepart-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]).1for a declared/wire-derived Integer or Long point type (a precision extension cannot override it); otherwise a validated non-negative-integerquantity-precision(10^-p; precision0legitimately yields step1for 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.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.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).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 incql-test-results.ts.Docs
docs/plan-fhir-56226-numeric-interval-checks.mdrecords 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).Testing
npx vitest run), including ~50 new tests: extractor fixtures for both$cqland$evaluateshapes (Range and part-form), the FHIR-56226 ticket example, the Test Runner Not Detecting Equal Intervals (Open vs Closed Boundaries) #85 cases, BigInt Long round-trips, strictness and near-miss-tuple regressions, and negative precision/truncation/underflow cases.tsc --noEmitclean.conf/cql-execution-local.jsonciting Test Runner Not Detecting Equal Intervals (Open vs Closed Boundaries) #85 are intentionally left in place; removing them should follow a verified run against a live engine.Notes for reviewers
intervalsEqualis 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.cqf-cqlTypewill 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.🤖 Generated with Claude Code
https://claude.ai/code/session_01WUJ7RDTBnqwBwFFwGwrR1b