Skip to content

Gene classification as a separate axis

Date: 2026-09-02 Status: implemented Related: projects/NEUROMUSCULAR.md, dismech#9005

Problem

Neuromuscular disease has no equivalent of the ISDS Nosology of Genetic Skeletal Disorders — no committee placing each disorder in exactly one group. The closest whole-domain artifact is the Gene Table of Neuromuscular Disorders, published annually in Neuromuscular Disorders and served at musclegenetable.fr, which organises genes into 17 disease groups.

The obvious move was to add gene_table_nmd_category alongside isds_skeletal_category on DiseaseClassifications. It does not work, and the reason is instructive.

The Gene Table's leaf is not a disorder. It is a (group, disease, gene) coordinate, and a gene appears once per clinically distinct allelic presentation. TTN is Udd distal myopathy in group 4, LGMDR10 in group 1, centronuclear myopathy in group 3, HMERF in group 5, dilated/hypertrophic cardiomyopathy in group 10, and a motoneuron-disease presentation in group 12 — six groups for one gene. Across the committed transcription of GT_NMD 2026, 127 of 707 genes (18.0%) carry more than one group, over 879 assignments in total.

Projecting that onto a disease entry forces multi-assignment, which in turn wants a scoping mechanism to say which subtype each group covers. Two rejected designs came out of that: an applies_to slot on the abstract ClassificationAssignment, and reuse of the existing attaches_to hash-anchor grammar. Both are machinery built to carry a fact to the wrong place.

Splitting entries finer does not help either. Central_Core_Myopathy is already a single-gene entry, and RYR1 alone spans five groups.

Decision

Gene classification is its own axis, recorded against the gene rather than against a disease entry.

Note the scope line this draws. Systems that grade how well established a gene-disease link is -- ClinGen Gene-Disease Validity, GenCC, PanelApp's green/amber/red -- are not gene classifications and are out of scope here. They classify the gene-disease pair, they are an evidence-strength axis rather than a taxonomy, and they belong with the evidence model. An earlier draft of this design bundled such a slot in; it was stripped before merge, and adding one should be argued on its own merits in its own change.

kb/gene_classifications/

A source-level transcription, following the shape of kb/surrogate_endpoints/fda_surrogate_endpoints.yaml: provenance once in the file header, rows underneath, no per-row evidence.

GeneClassificationCollection holds classification_system, source_url, source_version, source_content_current_as_of, retrieved_date, and a list of GeneClassification rows. Each row binds an HGNC gene to a multivalued values list plus an optional source_label recording the source's own symbol where it differs from the HGNC-approved one.

values is multivalued by design — that is the whole point of moving the fact to the gene. values has range string rather than a specific enum because each collection draws from its own; test_collection_values_are_enum_members enforces membership of the enum named by classification_system.

First collection: nmd_gene_table.yaml, 707 genes from GT_NMD 2026 (current as of 2026-05-18), generated by scripts/fetch_nmd_gene_table.py.

What this deliberately is not

  • Not a gene entity. No pathophysiology, no per-row evidence, no disease links, no rendering. A lookup table.
  • Not gene_sets. Those reference flat, typically experimentally-derived sets (signatures, perturbation results) consumed as enrichment inputs, and are moving to genesets-rs. A gene classification is a curated, authoritative assertion about which genes cause disease in a domain, and carries its own category structure. Conflating the two was an error made and corrected during design.
  • Not an inference engine. Only genes the source lists; no derived memberships. Same posture as the ISDS module.

Implementation notes

Symbol resolution is the fiddly part and is the main thing the tests guard. The gene table renders a gene cell as symbol-plus-protein-name with no separator ("TTNTitin", "ATP13A2ATPase type 13A2"), so the symbol boundary is not findable by pattern — "ATP13A2AT" looks like a perfectly plausible symbol. Resolution is therefore by prefix match against the HGNC complete set.

Two ordering rules were needed, each fixing an observed mis-resolution:

  1. Approved symbols before previous/alias symbols, not longest-match alone. By length, "LAMB2Laminin, beta 2" resolves to LAMB2L (an alias of the pseudogene LAMB2P1) rather than LAMB2, and "ARAndrogen receptor" resolves to ARA (an alias of ABCC6) rather than AR. Both are wrong, and AR is Kennedy disease — a gene the table certainly means to list.
  2. Pseudogenes excluded from the index, since a disease-gene catalogue never cites one but their aliases collide with real symbols under prefix matching.

Consulting HGNC previous/alias symbols also means the 14 genes the table still lists under retired symbols (AARS→AARS1, ADCK3→COQ8A, C12orf65→MTRFR, SEPT9→SEPTIN9, TAZ→TAFAZZIN, KIAA0196→WASHC5, …) resolve to current identifiers rather than being dropped.

Coordinates must be read in their parenthesised context

Group numbers come from the "All allelic disease phenotypes" cell, where each phenotype is followed by its coordinates: Barth syndrome - BTHS (10.103, 10.90). Reading them with a bare \d{1,2}\.\d+ across the whole cell also captures decimals inside disease and locus names. TAZ is listed with its old locus designation "G4.5", which was read as group 4 and put TAFAZZIN — a cardioskeletal myopathy with proximal weakness — into distal myopathies.

The fix matches only parenthesised comma-separated coordinate runs. Across the 707 genes this changed exactly one assignment, which is what makes the class of bug dangerous: a single wrong row in an otherwise correct file, discoverable only by someone who knows the biology. It was caught in review by biological implausibility, not by any check.

The resolution floor exists so failure is loud

resolve_symbol walks prefixes downwards, so without a minimum length almost any capitalised cell eventually matches some one- or two-letter symbol. None becomes unreachable, every row resolves, and a wrong gene is indistinguishable from a right one — a zero-unresolved run is then evidence of nothing.

A floor of 3 characters, with an allow-list for genuinely short symbols, makes an unhandled run-together pattern surface as a reported non-resolution instead. The allow-list is populated by that mechanism working: the first run after adding the floor reported KY (kyphoscoliosis peptidase) as unresolved, and it was added. Extend the list the same way rather than lowering the floor.

Layout assumptions are asserted

The gene and coordinate cells are read positionally (cells[0], cells[2]), so a reordered or added column would leave the scraper producing a well-formed but wrong file. The header row is asserted against _EXPECTED_HEADER and a run that never sees it raises, converting that into a hard failure.

Consequences

The Gene Table becomes a curation-quality instrument rather than metadata. Since group membership is a presentation-level fact and conforms_to is a mechanism-level one, disagreement between them is a signal. And because the taxonomy now sits on genes, a disease entry that would have needed many groups is visible as an entry lumping mechanistically distinct presentations — Autosomal_Recessive_Limb-Girdle_Muscular_Dystrophy, whose whole pathograph is the three-node generic dystrophy trunk over eight genes spanning four proximal mechanisms, is the clearest case.

Follow-ups

  • Further collections: ClinGen dosage sensitivity (CGDS:, already cached), COSMIC Cancer Gene Census.
  • Decide whether Autosomal_Recessive_Limb-Girdle_Muscular_Dystrophy becomes a Grouping over per-gene entries plus the sarcolemmal_mechanical_fragility module.