<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Thiago Longo Moraes</title>
    <description>The latest articles on DEV Community by Thiago Longo Moraes (@thiagolongom).</description>
    <link>https://dev.to/thiagolongom</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4136304%2Fdd2debcf-ac29-4264-9223-45bd957dffac.png</url>
      <title>DEV Community: Thiago Longo Moraes</title>
      <link>https://dev.to/thiagolongom</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thiagolongom"/>
    <language>en</language>
    <item>
      <title>Grading a wrong answer by how wrong it is: ICD-10 proximity scoring in Postgres</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Mon, 21 Sep 2026 22:01:43 +0000</pubDate>
      <link>https://dev.to/thiagolongom/grading-a-wrong-answer-by-how-wrong-it-is-icd-10-proximity-scoring-in-postgres-5cia</link>
      <guid>https://dev.to/thiagolongom/grading-a-wrong-answer-by-how-wrong-it-is-icd-10-proximity-scoring-in-postgres-5cia</guid>
      <description>&lt;p&gt;Most quiz apps have one bit of output: right or wrong. For a medical diagnosis game, one bit throws away almost everything interesting.&lt;/p&gt;

&lt;p&gt;A physician who answers &lt;em&gt;unstable angina&lt;/em&gt; when the case is a &lt;em&gt;myocardial infarction&lt;/em&gt; has done something very different from one who answers &lt;em&gt;bacterial meningitis&lt;/em&gt;. The first is a neighbour on the differential. The second is a different organ system entirely. Collapse both into "wrong" and the learner gets no signal about which mistake they made.&lt;/p&gt;

&lt;p&gt;We build &lt;a href="https://nexo.wiki.br" rel="noopener noreferrer"&gt;NEXO&lt;/a&gt;, a daily clinical reasoning game. Players answer with an ICD-10 diagnosis, and we score the answer by &lt;strong&gt;how far it sits from the correct code on the ICD-10 tree&lt;/strong&gt;. This post is about how that is implemented, why it lives in the database, and a tautology that quietly killed one of the five levels for a while.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ICD-10 code is already a tree
&lt;/h2&gt;

&lt;p&gt;An ICD-10 code carries its own hierarchy in its characters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;G40.1
│││ └── subdivision
││└──── category    (G40, epilepsy)
│└───── block       (G4x, episodic and paroxysmal disorders)
└────── chapter     (G, diseases of the nervous system)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So you do not need a graph traversal or a distance matrix to know that &lt;code&gt;G40&lt;/code&gt; and &lt;code&gt;G43&lt;/code&gt; are closer than &lt;code&gt;G40&lt;/code&gt; and &lt;code&gt;J18&lt;/code&gt;. You need a prefix comparison. Walk up from the full code, one character at a time, and the first level where the two codes agree is the proximity.&lt;/p&gt;

&lt;p&gt;That gives five outcomes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefix match&lt;/th&gt;
&lt;th&gt;Feedback&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;full code&lt;/td&gt;
&lt;td&gt;exact&lt;/td&gt;
&lt;td&gt;correct answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 characters&lt;/td&gt;
&lt;td&gt;category&lt;/td&gt;
&lt;td&gt;right category, wrong subdivision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 characters&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;right block of related conditions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 character&lt;/td&gt;
&lt;td&gt;chapter&lt;/td&gt;
&lt;td&gt;right chapter, wrong disease&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;distant&lt;/td&gt;
&lt;td&gt;wrong branch entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why this runs on the server
&lt;/h2&gt;

&lt;p&gt;The scoring is a Postgres function, not client code. That is deliberate.&lt;/p&gt;

&lt;p&gt;The client never holds the correct answer and never decides whether a guess was right. It sends a string; it gets back a verdict. Putting the comparison in the app would mean shipping the answer to the device, which for a daily game with a global leaderboard means shipping tomorrow's answers to anyone willing to read a memory dump.&lt;/p&gt;

&lt;p&gt;The function is &lt;code&gt;SECURITY DEFINER&lt;/code&gt; with a pinned &lt;code&gt;search_path&lt;/code&gt;, and it takes a row lock on the game session before it touches anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;v_sessao&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sessoes_jogo&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_sessao_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;usuario_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v_uid&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;FOR UPDATE&lt;/code&gt; matters more than it looks. Without it, two submissions racing from a flaky connection can both read the same attempt count, both pass the six-attempt cap, and both append to the attempts array.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalising before comparing
&lt;/h2&gt;

&lt;p&gt;ICD-10 codes reach us in several shapes. &lt;code&gt;G40.1&lt;/code&gt;, &lt;code&gt;G401&lt;/code&gt;, &lt;code&gt;g40.1&lt;/code&gt;. Before any comparison, both sides are stripped to alphanumerics and upper-cased:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;   &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;regexp_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_tentativa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[^A-Za-z0-9]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'g'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;v_correto_norm&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;regexp_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_caso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cid10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'[^A-Za-z0-9]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'g'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Normalising here and not at the edge means the rule holds no matter which client is calling. It also means the prefix comparison below can be plain &lt;code&gt;left()&lt;/code&gt; on a clean string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;v_guess_norm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v_correto_norm&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'exato'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ELSIF&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'categoria'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ELSIF&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'grupo'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ELSIF&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
  &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'capitulo'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ELSE&lt;/span&gt;
  &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'distante'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four comparisons, no lookup table, no ICD-10 reference data at query time. The hierarchy is in the string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: a condition that was always true
&lt;/h2&gt;

&lt;p&gt;The version before this one tried to express "same numeric block" as a range check instead of a prefix check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;ELSIF&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'0'&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'9'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'grupo'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ELSIF&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_guess_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_correto_norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;v_feedback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'capitulo'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the range condition carefully. If the first characters already match, then &lt;code&gt;left(guess, 2)&lt;/code&gt; is that letter followed by one more character. The bounds are that same letter followed by &lt;code&gt;0&lt;/code&gt; and by &lt;code&gt;9&lt;/code&gt;. Every digit falls inside. So for any two codes in the same chapter, the &lt;code&gt;grupo&lt;/code&gt; branch always fired.&lt;/p&gt;

&lt;p&gt;Which made the &lt;code&gt;capitulo&lt;/code&gt; branch &lt;strong&gt;dead code&lt;/strong&gt;. The fifth level existed in the schema, in the client, in the copy on the website, and never once reached a player.&lt;/p&gt;

&lt;p&gt;Nothing crashed. No error was logged. Players got a slightly-too-generous verdict and there was no way to notice from the outside, because "same block" and "same chapter" both render as an amber near miss. It only surfaced when someone went looking for how often each level fires and found one of them at exactly zero.&lt;/p&gt;

&lt;p&gt;The fix was to delete the cleverness and compare prefixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A tautology is the quietest kind of bug.&lt;/strong&gt; It does not throw. It does not return null. It returns a plausible answer, forever. The only signal was a distribution with a zero in it, and you only see that if you go looking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put the hierarchy in the identifier when you can.&lt;/strong&gt; ICD-10, and a lot of other clinical coding, is designed so that the code is the path. That turns "how related are these two diagnoses" from a graph problem into string slicing, which is fast enough to run inside the transaction that records the attempt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boolean ranges deserve a truth table.&lt;/strong&gt; Both branches of that &lt;code&gt;ELSIF&lt;/code&gt; chain read fine in review. The failure only shows up if you write down what the range actually evaluates to when the first character matches.&lt;/p&gt;




&lt;p&gt;NEXO is a daily clinical reasoning game for physicians and medical students. One fictional case a day, six clues in order, and an answer given as an ICD-10 code. The daily case is free.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://nexo.wiki.br" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt; · &lt;a href="https://apps.apple.com/app/id6761273295" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every case is fictional and written for teaching. NEXO is not medical advice and is not for use with real patients.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>healthcare</category>
      <category>supabase</category>
    </item>
    <item>
      <title>I used the ICD-10 tree as a scoring function</title>
      <dc:creator>Thiago Longo Moraes</dc:creator>
      <pubDate>Mon, 21 Sep 2026 18:41:33 +0000</pubDate>
      <link>https://dev.to/thiagolongom/i-used-the-icd-10-tree-as-a-scoring-function-1h5b</link>
      <guid>https://dev.to/thiagolongom/i-used-the-icd-10-tree-as-a-scoring-function-1h5b</guid>
      <description>&lt;p&gt;Most medical quiz apps score you as right or wrong. That throws away the part that matters. In clinical reasoning, mistaking cholecystitis for appendicitis and mistaking it for a myocardial infarction are not the same mistake. One is a near miss inside the acute abdomen. The other is a different organ system.&lt;/p&gt;

&lt;p&gt;So when we built NEXO, a daily diagnosis game for physicians and medical students, we made the scoring reflect that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;ICD-10 is a tree. A code like I21.0 sits inside category I21, inside group I20-I25, inside the chapter for diseases of the circulatory system. That hierarchy is already a distance metric. We just had to use it.&lt;/p&gt;

&lt;p&gt;When a player submits a guess, we compare the guessed code against the answer and return the deepest node they share:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;exact code         I21.0  vs  I21.0
same category      I21.9  vs  I21.0
same group         I20    vs  I21.0
same chapter       I50    vs  I21.0
different chapter  J18    vs  I21.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five levels instead of two. A player who lands on "same chapter" learns they were reasoning in the right system and missed the specific entity. A player who lands on "different chapter" learns something else entirely. Both are wrong answers, and they are not worth the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the answer secret
&lt;/h2&gt;

&lt;p&gt;The daily case is the same for every player worldwide, and the answer has to stay hidden until the day rolls over. The comparison runs server side against the stored code. The client never receives the answer, only the level. That kept the API small: send a code, receive an enum.&lt;/p&gt;

&lt;h2&gt;
  
  
  The harder problem: cases that hold up
&lt;/h2&gt;

&lt;p&gt;Generating a plausible clinical case with an LLM takes seconds. Generating one a physician will not tear apart is a different problem. A clue that sounds right but does not belong to the diagnosis is worse than no clue at all.&lt;/p&gt;

&lt;p&gt;We built NEXO Core for that. Every disease, syndrome, exam, clinical finding and ICD-10 code is a node. Edges connect what clinical practice treats as neighbors: pneumonia connects to its etiological agent, its radiological pattern, its differentials. The graph currently holds around 11,500 concepts and 26,000 edges, drawn from 27 reference textbooks.&lt;/p&gt;

&lt;p&gt;Every generated case crosses that graph before approval. Each clue has to appear among the neighboring concepts of the proposed diagnosis. A clue that does not, fails. It is a structural check, not a human reading it over and nodding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;React Native with Expo for the app&lt;/li&gt;
&lt;li&gt;Supabase for Postgres, auth, storage and edge functions&lt;/li&gt;
&lt;li&gt;Next.js on Vercel for the marketing site and the case management backoffice&lt;/li&gt;
&lt;li&gt;RevenueCat and StoreKit 2 for subscriptions&lt;/li&gt;
&lt;li&gt;Sentry for crash reporting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;649 cases across 25 specialties, in six languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Start with the graph, not the cases. We wrote a few hundred cases before the graph existed and had to re-audit all of them against it afterwards. That audit found problems a read-through had missed.&lt;/p&gt;

&lt;p&gt;NEXO is live on iOS and the daily case is free forever, with no ads. The site is at &lt;a href="https://nexo.wiki.br/en" rel="noopener noreferrer"&gt;nexo.wiki.br&lt;/a&gt; and the app is on the &lt;a href="https://apps.apple.com/app/id6761273295" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Happy to answer questions about the graph or the scoring.&lt;/p&gt;

</description>
      <category>computerscience</category>
      <category>learning</category>
      <category>science</category>
    </item>
  </channel>
</rss>
