Gene list matching: ReleaseGeneMatcher chains aliases bidirectionally, bridging unrelated genes
- Dominant language
- Python
- Stars
- 30
- Forks
- 3
- Avg merge
- 9h 28m
- Merged PRs (30d)
- 42
Description
🤖 Written by Claude
## Problem
`ReleaseGeneMatcher` traverses the gene symbol alias graph **transitively and in both directions**, so an alias string shared by two unrelated approved symbols acts as a bridge and links their genes together. Gene list filters then return variants from genes that are not on the list.
Reported from prod in SACGF/variantgrid_sapath#426: a hypogonadotropic hypogonadism panel was returning a variant in **PDCD2**. The bridge is **MT-TS2**, which is on the list. HGNC records `RP8` as an alias for both:
```
alias='RP8' -> gene_symbol='MT-TS2' source=HGNC
alias='RP8' -> gene_symbol='PDCD2' source=HGNC
```
So the matcher walks `MT-TS2 -> RP8` (backwards along an alias edge) then `RP8 -> PDCD2` (forwards), and writes:
```
ReleaseGeneSymbolGene release_gene_symbol=MT-TS2 gene=5134 match_info='RP8 is an alias for PDCD2 (HGNC)'
```
across seven releases. Gene `5134` is PDCD2.
Compounding it: MT-TS2 is a mitochondrial tRNA with no RefSeq gene in the release, so **PDCD2 is its only matched gene**. The list silently filters on PDCD2 *instead of* MT-TS2 — a false positive and a false negative from the same row.
## Cause
`genes/gene_matching.py`:
* `aliases_dict` (line 156) builds `alias_graph` **undirected** — each `GeneSymbolAlias` is appended under both `gsa.alias` and `gsa.gene_symbol_id` (lines 166-167).
* `_aliases()` (line 177) recurses through that graph, and on reaching any symbol with genes in the release, assigns those gene ids back to **every symbol in the accumulated path** (lines 185-200).
Only loops are guarded against (`visited_symbols`); path length is not, and neither is direction.
The right hop is the one the alias table is shaped for: `alias -> gene_symbol`, i.e. "MT-TS2 is also known as TRNS2". The backwards hop reads it as "RP8 is also known as MT-TS2", which is where the unrelated gene enters.
## Scope
Counting rows whose `match_info` shows a 2-hop path, release `GRCh38_RefSeq_2025_08` alone has **242 chained matches**:
```
ATHS -> ASRGL1, ATRNL1, CCL27, PDLIM3, SLPI [all via "ALP"]
CELIAC2 -> NOD2, CTLA4 [all via "CD"]
UGT1A -> SLC35A2 [via "UGT"]
AZF1 -> USP9Y [via "AZFA"]
CDR1 -> AKR1C4 [via "CDR"]
```
Audit query:
```sql
SELECT rgs.release_id, rgs.gene_symbol_id, rgsg.gene_id, rgsg.match_info
FROM genes_releasegenesymbolgene rgsg
JOIN genes_releasegenesymbol rgs ON rgs.id = rgsg.release_gene_symbol_id
WHERE rgsg.match_info LIKE '%), %'
ORDER BY rgs.release_id, rgs.gene_symbol_id;
```
## Proposed fix
1. **Restrict traversal to single, directional alias hops** — follow `alias -> gene_symbol` from the queried symbol only. `KAL1 -> ANOS1` keeps working; `MT-TS2 -> RP8 -> PDCD2` stops.
2. **Delete the existing chained rows.** `fix_rematch_release_symbols_to_genes` only adds matches, so a re-match leaves them in place. Needs a management command that audits and removes chained matches across all releases, plus a `ManualOperation` migration so deployments pick it up.
3. **Surface the substitution to the user.** `match_info` already records the hop, but the gene list UI shows GeneIDs with no warning that a symbol resolved via an alias to a differently-named gene.
## Relationship to #1668
Separate but adjacent. #1668 covers single-hop alias *ordering and provenance* across nine call sites — `ReleaseGeneMatcher` is not one of them, and preferring HGNC `prev_symbol` over `alias_symbol` does not stop this chaining. Once #1668's shared resolver exists, `ReleaseGeneMatcher` should become a consumer of it, which would structurally prevent multi-hop traversal from returning. Item 3 above overlaps #1668 item 4.
## Immediate workaround
Delete the `genes_releasegenesymbolgene` rows for `MT-TS2 -> 5134`.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in genes/gene_matching.py at aliases_dict and _aliases(), then inspect fix_rematch_release_symbols_to_genes and existing ManualOperation migrations. Use the supplied audit query to identify chained matches across releases. Done means traversal is limited to one directional alias hop, stale chained rows are removed through the deployment migration path, and the gene list UI surfaces substitutions.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, sql
- Domain
- backend, databases, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100