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:
- 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.
- 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_Dystrophybecomes aGroupingover per-gene entries plus thesarcolemmal_mechanical_fragilitymodule.