Skip to content

LOINC: an in filter is the union of = each value - #386

Merged
grahamegrieve merged 7 commits into
HealthIntersections:mainfrom
jmandel:fix/loinc-in-filter
Nov 14, 2026
Merged

grahamegrieve merged 7 commits into
HealthIntersections:mainfrom
jmandel:fix/loinc-in-filter

Conversation

@jmandel

@jmandel jmandel commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Builds on #384. This branch merges perf/loinc-filter-part-lookup (#384). A part name in a relationship in is looked up with #384's #partKeysByName, and the name lookup uses #384's idx_codes_description index. Merge #384 first, and this PR's own diff is the last commit.

Problem

doesFilter accepts in for every LOINC property and relationship, and for answers-for. But in didn't read its values the way = does:

  • Property in. #executeFilterQuery kept only the values that are LOINC codes (#commaListOfCodes). Property values (CLASSTYPE, ORDER_OBS, ...) are never LOINC codes, so a property in filter always returned an empty expansion, with no error. ORDER_OBS = Order and CLASSTYPE = 3 worked.
  • Relationship in with part names. SCALE_TYP = Doc takes a part's name, a legacy form that servers are required to accept, but SCALE_TYP in Doc,Nar took part codes only and returned nothing, with no error.
  • answers-for in. answers-for is a relationship type too, so in fell into the generic relationship branch. It returned the answer lists for the listed terms instead of their answers (and nothing when the values were answer lists).

$expand of an inline compose against LOINC 2.83 (before = main):

filter before after union of = each value
CLASSTYPE in 3,4 0 13968 13968
CLASSTYPE in Claims attachment,Surveys 0 13968 13968
ORDER_OBS in Order,Both 0 61770 61770
ORDER_OBS in order,BOTH 0 61770 61770
SCALE_TYP in LP32888-7,LP7749-7 (part codes) 14521 14521 14521
SCALE_TYP in Doc,Nar (part names) 0 14521 14521
SCALE_TYP in doc,NAR,LP7753-9 (names in other case, and a code) 44574 (the code only) 59095 59095
answers-for in LL1000-0,LL1001-8 (answer lists) 0 15 15
answers-for in 100002-5,10153-5 (terms) 1 (an answer list) 4 4
answers-for in LL1000-0,10153-5 (a list and a term) 0 3 3

The "after" and union counts in the rows from SCALE_TYP in Doc,Nar down come from the provider on 2.83, not a running server: the filter's codes are listed, and one of them is also found by filterLocate, the $validate-code path. Their "before" counts come from main's SQL for the filter, run against the same database. Every "after" set is equal to the union, not just the same size.

Spec

Core CodeSystem Filters table, the same rows in R4 (https://hl7.org/fhir/R4/codesystem.html#filters), R4B and R5 (https://hl7.org/fhir/R5/codesystem.html#filters):

[property] = [string]: "Includes all codes that have a property value equal to the specified string, where [property] is the code for any defined property"

[property] in [string,string...]: "Includes all codes that have a property value equal to one of the specified strings, where [property] is the code for any defined property". Notes: "The values cannot include ",", since it is being used as a delimiter"

THO LOINC, Case Sensitivity (https://terminology.hl7.org/7.5.0/en/LOINC.html):

"For comparison purposes, LOINC codes, displays, and property values are not case sensitive"

tx-ecosystem IG requirements (https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig/en/requirements.html):

"A filter the server does not understand, or a filter with no value where one is required, SHALL produce an error - not an empty or a partial expansion"

Change

tx/cs/cs-loinc.js: X in a,b is X = a or X = b for every X that takes in. Each value is trimmed, and empty values are dropped.

  • Property in: the values are compared as property values, case-insensitively, as = does. = already takes the CLASSTYPE numbers 1-4, so in maps them to their names too, and CLASSTYPE in 3,4 is the union of CLASSTYPE = 3 and CLASSTYPE = 4.
  • Relationship in: a value that is a LOINC code is a part code, as for =. Any other value is a part name: #partKeysByName (LOINC: look up a filter's part name or regex once per factory; index Codes.Description #384) looks it up, in any case, once per factory. The filter's SQL lists the union of the parts' keys (TargetKey IN (...)). A value that matches no part adds nothing, and if none match, the filter is empty.
  • answers-for in: each value is read as answers-for = reads it. A value starting LL is an answer list, and any other value is a term that answer lists are for. The filter is the union of their answers.

CHANGELOG.md: a Fixed entry under Unreleased.

Tests

  • tests/cs/cs-loinc.test.js, "X in a,b is the union of X = a and X = b, listed and checked": 12 cases, covering CLASSTYPE numbers and names, ORDER_OBS (also in other case, with a space), SYSTEM, SCALE_TYP part codes, part names, names in mixed case (Doc,NAR), a name and a code, and answers-for by answer lists, by terms, and by a list and a term. For each case, = each value is non-empty, and listing and checking give the same set for =. For in, doesFilter says yes, and both the codes listed and the codes checked without listing (getPrepContext(false), the $validate-code path: filterLocate on every code) equal the union.
  • "values of an in that = finds nothing for add nothing": an unknown name and an unknown code beside Qn give SCALE_TYP = Qn's codes, and an in whose values are all unknown (SCALE_TYP, answers-for) is empty when listed and when checked.
  • The "answers-for in is refused" test from the previous commit is gone.
  • LOINC suites (cs-loinc, cs-loinc-classtype, sqlite-callback-throws, sqlite-provider-close, cs-worker-readonly, ancestors-of, tx/loinc-import-answer-lists): 118 passed, 2 skipped (7 suites)
  • Full tests/cs and tests/tx: 171 suites pass (5 skipped): 3,262 tests pass, 87 skipped (npx jest tests/cs tests/tx, test-cases run separately below)
  • tests/tx/test-cases.test.js: 5,251 of 5,251 pass (validator jar via FHIRSMITH_VALIDATOR_JAR)
  • eslint is clean on the changed files.

🤖 Generated with Claude Code

jmandel and others added 3 commits November 12, 2026 23:21
A relationship filter that names its part (SCALE_TYP = Doc) found the
part with Codes.Description = ? COLLATE NOCASE inside every membership
query; Description has no index, so each $validate-code read all
257,267 codes. A regex filter (CLASS regex) re-scanned the property's
values on every request. Both answers depend only on the database, so
the factory now keeps them (most recently used 256, at most 1,000,000
keys in all), and the filter's SQL uses the keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Property in filters kept only the values that were LOINC codes, so
ORDER_OBS in Order,Both or CLASSTYPE in 3,4 matched nothing; relationship
in filters took part codes only, so SCALE_TYP in Doc,Nar matched nothing;
and answers-for in was read as the answers-for relationship (answer lists
for a term), not the answers. Each value is now read as = reads it: a
property value (CLASSTYPE also by number), a part code or else a part
name, an answer list or a term.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core defines [property] in as "a property value equal to one of the
specified strings", so property in keeps the union of = each value
(case-insensitive, CLASSTYPE numbers included). No source defines part
names in a relationship in, so relationship in takes part codes again,
as before. answers-for is not a property, so answers-for in is not
defined: doesFilter now says no, and filter() refuses it with a
not-supported 422 instead of returning a wrong set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jmandel jmandel changed the title LOINC: an in filter is the union of = each value LOINC: a property in filter matches any of the listed values Nov 13, 2026
@jmandel

jmandel commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

I've narrowed this PR to what core FHIR's CodeSystem Filters table defines (https://hl7.org/fhir/R4/codesystem.html#filters; the same row is in R5): [property] in "Includes all codes that have a property value equal to one of the specified strings". So a property in (CLASSTYPE, ORDER_OBS, ...) now matches any of the listed values, case-insensitively as THO's LOINC page requires.

I dropped part names in relationship in because no source defines them, so relationship in still takes part codes, as on main. answers-for is not a property, so core's row doesn't cover answers-for in. It is now refused (not-supported, 422) instead of returning the wrong set, per the tx-ecosystem IG: "A filter the server does not understand ... SHALL produce an error - not an empty or a partial expansion".

@grahamegrieve

Copy link
Copy Markdown
Contributor

why is answers-for treated differently? I don't think it should be

Also, in on a LOINC axis (SCALE_TYP in Doc,Nar, using names instead of Part codes) still returns an empty expansion with no error. But all servers are required to accept axis names with = as a legacy form, and the IG says a filter a server can’t handle must fail rather than return an empty expansion. So in with names should either work, matching =, or be refused. As it stands it does neither. Refusing is ok, it's =, but = is required because there's lots of value set out there using the names. So I'd prefer to get it right

jmandel and others added 4 commits November 14, 2026 07:29
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The name lookup of a relationship filter (SCALE_TYP = Doc) was the only
filter query that read a whole table. The importer now creates
idx_codes_description ON Codes(Description COLLATE NOCASE), which the
query uses (SEARCH ... USING COVERING INDEX): on LOINC 2.83 the lookup
takes 0.04 ms instead of 18 ms, and the index is 14 MB. The CHANGELOG
gives the sqlite3 command that adds it to an existing database.

The factory's cache keys a name as NOCASE compares it (ASCII letters
lower-cased), so Doc, DOC and doc are looked up once. A provider built
without the factory's shared data (sqlite-callback-throws) makes its own
cache instead of failing with a TypeError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…x/loinc-in-filter

HealthIntersections#386 builds on HealthIntersections#384: a part name in a relationship in is looked up with
HealthIntersections#384's #partKeysByName.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An in filter is now the union of = each value for every filter that
takes in: answers-for in (each value an answer list or a term, as
answers-for = reads it), and a relationship in takes part names as well
as part codes (SCALE_TYP in Doc,Nar), as = does - a legacy form servers
must accept. Each name is looked up with HealthIntersections#384's #partKeysByName (one
indexed query, kept by the factory), and the filter uses the union of
the keys. answers-for in is no longer refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@jmandel jmandel changed the title LOINC: a property in filter matches any of the listed values LOINC: an in filter is the union of = each value Nov 14, 2026
@jmandel

jmandel commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

Agreed. in is now = for each value everywhere (0f8d126):

  • answers-for: answers-for in is the union of answers-for = for each value. Each value is an answer list or a term, the same as =. It is no longer refused.
  • Part names: SCALE_TYP in Doc,Nar works the way = does. The branch merges LOINC: look up a filter's part name or regex once per factory; index Codes.Description #384, and each name is looked up with #partKeysByName (any case, indexed, kept by the factory). The filter uses the union of the keys. On 2.83, SCALE_TYP in Doc,Nar gives 14,521 codes, the union of = Doc and = Nar.

Tests check that in equals the union of = for property values, part codes, part names in mixed case, CLASSTYPE numbers, and answers-for by list and by term, both listed and on the $validate-code path. #386 now builds on #384, so merge #384 first.

@grahamegrieve grahamegrieve 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.

ok thanks

@grahamegrieve
grahamegrieve merged commit 38d3ffe into HealthIntersections:main Nov 14, 2026
4 of 5 checks passed
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.

2 participants