Skip to content

$validate-code: a code not in an enumerated include is looked up once, not once per listed code - #339

Merged
grahamegrieve merged 2 commits into
HealthIntersections:mainfrom
jmandel:fix/m2-locate-once
Nov 4, 2026
Merged

grahamegrieve merged 2 commits into
HealthIntersections:mainfrom
jmandel:fix/m2-locate-once

Conversation

@jmandel

@jmandel jmandel commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Register row M2.

What was wrong

When the code being validated is not in a value set's list, the answer takes about 90 ms for each code in the list (RxNorm on tx-dev). That's 13.6 s for a 148-code VSAC list, 99 s for a 1,185-code list, and no answer within 600 s for VSAC Medication Brand Name (33,341 codes).

curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' -H 'Accept: application/fhir+json' \
  'https://tx-dev.fhir.org/r4/ValueSet/$validate-code?url=http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.526.2.39&system=http://www.nlm.nih.gov/research/umls/rxnorm&code=999999999'

On tx-dev (3 Nov) this gives result false after 13.6 s. With code=1000001, which is in the list, it gives result true in 0.1 s. After this change a miss costs about as much as a hit, and the answers are the same.

Code path

tx/workers/validate.js on main:

  • ValueSetChecker.checkConceptSet (line 1643). When the include has a concept list without the code, it tries each listed concept as the code written in another form, at lines 1729–1737:

    if (cset.concept && !cset.concept.some(cc => cc.code === code)) {
      for (const cc of cset.concept) {
        if (await this.sameConceptDifferentForm(cs, code, cc.code)) { altMatch = cc; break; }
      }
    }
  • ValueSetChecker.sameConceptDifferentForm(cs, code, enumerated) (line 1609):

    if (code === enumerated) return false;                                   // (1)
    const a = await cs.locate(code);                                         // (2)
    if (!a.context || !(await cs.classificationCode(a.context))) return false;
    const b = await cs.locate(enumerated);                                   // (3)
    return !!b.context && (await cs.code(a.context)) === (await cs.code(b.context));

So for each listed concept, the function asks two questions:

  • Does the code being validated have a second spelling at all? (2) locates the code being validated and asks for its classification code. Neither call is given the listed concept. So the answer is the same for every listed concept in the loop: same cs, same code.
  • Is this listed concept that same concept? (3) locates the listed concept and compares canonical codes. This answer does depend on the listed concept, and it is still asked for each one.

Step (1) never fires inside the loop, since the loop runs only when no listed code equals code.

For any code system other than ICD-11, (2) always says no, because classificationCode is null (cs-api.js:100). Only cs-icd11.js:307 overrides it, and it reads only the located concept: ctxt.code for a concept, or ctxt.classificationCode for an expression. Nothing in it can see the listed concept. So for RxNorm, LOINC, SNOMED CT and the rest, main does a full locate(code) per listed code and then returns false every time.

Change

A new otherFormMatch(cs, code, concepts) asks (2) once. It runs the same loop over sameConceptDifferentForm only when the answer is yes, and it returns the same listed concept, or null:

  • If (2) says no, every call in main's loop would have returned false at (2), so main's altMatch is null. otherFormMatch returns null.
  • If (2) says yes, the loop is main's loop, unchanged.
  • An empty concept list, or one that contains the code, returns null without a lookup, as main did. (An empty list matters because ICD-11's locate can throw for a malformed expression. Main never located the code there, and this doesn't either.)

checkConceptSet calls otherFormMatch in place of the inline loop. Nothing else changes.

ICD-11 behaves exactly as before. One thing I noticed but did not change: an ICD-11 postcoordinated expression with a part that has no short code gets no classification code, so (2) says no and other orderings of it aren't tried. That is main's rule, unchanged here.

Test

tests/tx/validate-enumerated-miss.test.js:

  • $validate-code misses against a 300-code enumerated include, once for a code in the code system and once for a code not in it. Each locates the code fewer than 10 times. Main locates it 301 times. A listed code still validates.
  • otherFormMatch against main's loop on a fake two-spelling code system (entity ids, most with a short code, one without). Every spelling, plus unknown codes, is checked against 200 random lists. It picks the same listed concept (or none) every time, and more than 50 of those are real matches.
  • A code with one spelling is located once against a 1,000-code list, and an empty list locates nothing.

The integration and unit tests fail on main and pass with the change. The HL7 tx-ecosystem ICD-11 cases that validate one spelling against a value set that lists the other (vs-validate-adhoc-id, vs-validate-adhoc-uri, vs-validate-coding-cc-short) pass unchanged.

Results

The touched suites, run in band (validate-enumerated-miss, validate, validate-issues): 35 of 35 pass. HL7 tx-ecosystem test cases (validator 7.0.3), with this branch merged with the other three from #332's split: 5,048 of 5,061 pass. The 13 failures are the same 13 main 9059fb9 fails: cs-batch-validate, -bad and -codings (R4 and R5, and R5 cached), and translate-reverse-r5+ and -a (R5, plain and cached).

Added: a time-limit check in this loop

The loop that compares each listed code with the code being validated had no deadline check. With a slow code system it ran to the end, however long that took, before the next loop noticed the time limit had passed.

  • After this PR's change, the loop only runs when the code has another form (ICD-11).
  • On main it runs for every code system: an unknown RxNorm code against Medication Brand Name never got an answer within 150 s, about 24 minutes estimated.

otherFormMatch now calls deadCheck('checkConceptSet#3') for each listed code, in the same style as the other checks. tests/tx/validate-deadline.test.js uses a fake slow code system whose codes have another form. It checks that the request is refused at checkConceptSet#3 well before it would have finished, and it fails without the check.

🤖 Generated with Claude Code

…ce per listed code

When the code is not in an include's concept list, checkConceptSet tries each listed concept as
the same concept written in another form (ICD-11's short code and entity id), calling
sameConceptDifferentForm for each. That function first locates the code and asks whether it has
a classification code at all; only if so does it locate the listed concept and compare. The
first part depends only on the code, so it was repeated, with the same answer, once per listed
concept: a locate per listed code on every miss.

otherFormMatch asks that part once, and compares the listed concepts only when the code has
another form. For every code and list it picks the same listed concept (or none) as the old
loop; the new test checks that over 200 lists on a two-spelling code system. An empty list
still locates nothing.

On tx-dev.fhir.org a miss against a 1,185-code RxNorm list took 99 s, and one against a
33,341-code list got no answer in 600 s. A miss against a 300-code list located the code 301
times; it now locates it once or twice.

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

jmandel commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

More numbers. These are local, comparing main 9059fb9 with this branch on the same RxNorm and VSAC data. The request is a $validate-code of RxNorm code 161, which is not in the list, against VSAC lists of different sizes. Each case ran 10 times; the answers are identical, word for word.

Listed RxNorm codes main, typical / worst this PR
33,341 (Medication Brand Name) 2,896 / 3,124 ms 15 / 28 ms
1,185 107 / 153 ms 3.1 / 3.3 ms
100 12.5 / 13.8 ms 2.8 / 4.0 ms
10 4.6 / 4.9 ms 1.9 / 2.2 ms

On main, the cost grows with the length of the list. The loop at validate.js:1729–1736 calls sameConceptDifferentForm for each listed code. Each call starts with cs.locate(code) on the code being validated, which here is about 87 µs for one RxNorm query.

When the code is unknown to RxNorm altogether (999999999, as in the tx-dev example), each locate misses rxnconso and falls back to RXNATOMARCHIVE. That table has no index on RXCUI, so each fallback scans all of it, about 40–50 ms.

  • On main, a 10-code list then takes 1.4 s and a 100-code list 5.3 s.
  • The 1,185-code list ran 51 s and then answered too-costly. The time limit is only checked in the loop that comes after this one.
  • With this PR, the same request takes 90–140 ms at every list size, 33,341 codes included, and gives the correct "not in the value set" answer.

…of the code

When the code is not one of an include's listed codes, checkConceptSet
asks the code system, for each listed code, whether it is the same
concept written another way (sameConceptDifferentForm). That is a
locate() per listed code - 33,341 for VSAC Medication Brand Name - and
the loop had no deadline check, so it ran to the end however long that
took. It now calls deadCheck like the loop after it.

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

@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 33726ab into HealthIntersections:main Nov 4, 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