diff --git a/claude/maps/commands.md b/claude/maps/commands.md index 51a8fd86c..80329dbb2 100644 --- a/claude/maps/commands.md +++ b/claude/maps/commands.md @@ -144,7 +144,7 @@ Generated by `vg map commands` (do not edit; run `scripts/vg map commands` after | `fix_migrate_flags_to_imported_allele_info` | classification | (helper module, not a command) (guessed) | classification, flags, snpdb | | `fix_nbk_citations` | classification | | classification | | `fix_orphanet_revalidation` | classification | | classification | -| `fix_rematch_release_symbols_to_genes` | genes | | genes | +| `fix_rematch_release_symbols_to_genes` | genes | Resyncs ReleaseGeneSymbolGene (the symbol -> gene matches for a GeneAnnotationRelease) with what | genes | | `fix_tag_colors_collection_permissions` | snpdb | | snpdb | | `fix_truncated_annotation_runs` | annotation | Find AnnotationRuns whose VEP output was silently truncated, and mark them ERROR so they can be | annotation | | `fix_variant_annotation_add_hgvs_g` | annotation | | annotation | diff --git a/claude/plans/1669_release_gene_matcher_alias_chaining_plan.md b/claude/plans/1669_release_gene_matcher_alias_chaining_plan.md deleted file mode 100644 index 14893d338..000000000 --- a/claude/plans/1669_release_gene_matcher_alias_chaining_plan.md +++ /dev/null @@ -1,269 +0,0 @@ -# #1669 — ReleaseGeneMatcher: single-hop alias matching - -Written by Claude Fable 5 (claude-fable-5), 2026-08-31 - -[#1669](https://github.com/SACGF/variantgrid/issues/1669): `ReleaseGeneMatcher` walks the gene symbol -alias table transitively, so an alias string shared by two unrelated genes bridges them. Reported from -prod (SACGF/variantgrid_sapath#426) as a hypogonadotropic hypogonadism panel returning a **PDCD2** -variant via `MT-TS2 → RP8 → PDCD2`. - -The fix is one idea: **a symbol matches a release gene directly, or through exactly one -`GeneSymbolAlias` row that touches a symbol the release knows.** Everything else in this plan is -cleaning up the rows the old traversal wrote. - ---- - -## 1. Diagnosis (confirmed against the code and the local DB) - -`genes/gene_matching.py`, `ReleaseGeneMatcher`: - -* `aliases_dict` (line 165) builds `alias_graph` with every `GeneSymbolAlias` row hung under **both** - its `alias` and its `gene_symbol_id` (lines 173–176), then starts a walk from both ends of every row. -* `_aliases()` (line 186) recurses through that graph until it reaches any symbol in `self.genes` - (symbols the release actually has), then assigns those gene ids to **every symbol on the path** - (lines 196–211). Path length is unbounded; only loops are guarded. - -Local DB reproduces the prod rows exactly — `MT-TS2 → 5134 (PDCD2)` in releases 3, 4, 7, 8 and -`MT-TS2 → ENSG05220024333` in release 5. - -Two further findings that shape the cleanup: - -1. **`match_info` cannot identify chained rows.** `symbol_match_path` is a dict keyed by symbol, so - when the walk re-enters a symbol (`RP8 → MT-TS2 → RP8 → PDCD2`, which the loop guard allows because - the root symbol is never added to `visited_symbols`) the earlier hop is overwritten and the stored - text collapses to a single hop. Release 3's `MT-TS2` row reads `RP8 is an alias for PDCD2 (HGNC)`, - so the issue's `LIKE '%), %'` audit query undercounts. -2. **Rows also go stale when the alias table changes.** `genes/cached_web_resource/refseq.py` deletes - and re-imports NCBI aliases, but `ReleaseGeneSymbolGene` rows derived from the deleted aliases - survive (`match_symbols_to_genes` only inserts). Release 1 has `ADAM12-OT1 → ADAM12` citing - `ADAM12-OT1 is an alias for ADAM12 (NCBI)`; the only alias rows for `ADAM12-OT1` today are - `CAR10` and `FLJ31066` (HGNC). - -So the cleanup recomputes what the fixed matcher would produce and diffs against the table, rather -than pattern-matching `match_info`. - -### What the prototype single-hop matcher says about existing rows - -Alias/gene-version derived rows (`match_info IS NOT NULL`) per release on the local DB, classified by -the shortest explanation the single-hop rules give them: - -| release | rows | gene-version | forward hop only | backward hop only | multi-hop / stale | -|---|---|---|---|---|---| -| 1 GRCh38 RefSeq 110 | 1110 | 0 | 129 | 111 | 870 | -| 2 GRCh37 RefSeq 105 | 2012 | 82 | 177 | 113 | 1640 | -| 3 GRCh37 RefSeq 105 | 1502 | 91 | 180 | 113 | 1118 | -| 4 GRCh38 RefSeq 2023 | 1288 | 0 | 299 | 110 | 879 | -| 5 T2T Ensembl 2022-06 | 2249 | 0 | 171 | **1110** | 968 | -| 7 GRCh38 RefSeq 2024-08 | 1275 | 0 | 315 | 112 | 848 | -| 8 GRCh38 RefSeq 2025-08 | 606 | 0 | 128 | 125 | 353 | - -The "backward hop only" column is why the hop stays bidirectional (§2). The last column is what the -resync command (§4) removes. - ---- - -## 2. Design: one hop, either direction - -`GeneSymbolAlias(alias=A, gene_symbol=S)` reads "A is an alias for S". For a queried symbol `Q` that -is **absent** from the release: - -* **Forward** — `Q == A`, `S` in release → `Q` matches `S`'s genes. Gene list says `KAL1`, release - has `ANOS1`. This is the common "list uses an old name" case. -* **Backward** — `Q == S`, `A` in release → `Q` matches `A`'s genes. Gene list says `ANOS1`, release - still calls the gene `KAL1`. HGNC is refreshed far more often than gene annotation releases, so a - current-symbol gene list against an older release hits this constantly — 1,110 rows in the T2T - Ensembl 2022 release alone (`ABTB3`, `ACE2-DT`, `ACTMAP`, `ADISSP`, …), and ~110 per RefSeq release. - -One hop is enough for renames: HGNC's `prev_symbol` lists *every* previous symbol of a gene, so a -gene renamed twice has direct alias rows from both old names. Chaining is what lets an alias string -shared by two genes (`RP8`, `ALP`, `CD`, `ADMR`) act as a bridge, and dropping it stops -`MT-TS2 → RP8 → PDCD2`, `ATHS → ALP → {SLPI, ATRNL1, CCL27, PDLIM3, ASRGL1}`, -`CELIAC2 → CD → {NOD2, CTLA4}`, `ACKR5 → ADMR → GPR182`. - -Precedence, unchanged from today's `_get_gene_id_and_match_info_for_symbol`: - -1. Symbol is in the release → its genes, `match_info=None`, aliases never consulted. -2. Otherwise the union of gene-version matches (`Gene v0/GRCh38`, from `_get_genes_dict`) and - single-hop alias matches. Where both name the same gene the gene-version text wins (it is set - first; alias entries use `setdefault`). - -A symbol can still legitimately map to several genes (e.g. `RP8` forward-matches both `MT-TS2` and -`PDCD2`; only `PDCD2` is in the release, so `RP8` → `PDCD2` is correct and kept). - -The residual risk of the backward hop — HGNC's informal `alias_symbol` occasionally being another -gene's approved symbol (`AURKAIP1` lists `AIP`) — only bites when the queried symbol is absent from -the release *and* the alias is present as a different gene. Distinguishing `prev_symbol` from -`alias_symbol` is [#1668](https://github.com/SACGF/variantgrid/issues/1668) item 2; once that -provenance exists, the backward hop should accept `prev_symbol` rows only. `ReleaseGeneMatcher` -becomes a consumer of #1668's shared resolver at that point. - ---- - -## 3. `genes/gene_matching.py` - -Replace `aliases_dict` and delete `_aliases`: - -```python -@cached_property -def aliases_dict(self) -> dict[str, dict]: - """ Upper-case symbol -> {gene_id: match_info} for symbols the release lacks, reached either - from an older GeneVersion's symbol or by a single GeneSymbolAlias hop (in either direction) - to a symbol the release has. One hop is deliberate: HGNC lists every previous symbol of a - gene, so renames never need chaining, and chaining lets an alias string shared by two - unrelated genes bridge them (#1669). """ - genes_dict = self._get_genes_dict() - alias_qs = GeneSymbolAlias.objects.all() - for gsa in alias_qs: - alias = gsa.alias.upper() - symbol = gsa.gene_symbol_id.upper() - if alias == symbol: - continue - for query, target in ((alias, symbol), (symbol, alias)): - if query in self.genes: - continue - for gene_id in self.genes.get(target, []): - genes_dict[query].setdefault(gene_id, gsa.match_info) - return genes_dict -``` - -Notes for the implementer: - -* `self.genes` keys are `Upper("gene_symbol")`; `_get_genes_dict` keys are `gene_symbol_id.upper()`. - Keep everything upper-cased on the Python side — `alias` has `case_insensitive` collation in - Postgres but that does nothing for dict lookups. -* Loading all alias rows replaces the old `gene_symbol__releasegenesymbol__release=self.release` - filter. With one hop both ends are checked against `self.genes` directly, so the pre-filter buys - nothing, and removing it means `aliases_dict` (a `cached_property`) no longer depends on which - `ReleaseGeneSymbol` rows existed at the moment it was first computed. -* `_get_genes_dict`, `_get_gene_id_and_match_info_for_symbol`, `match_symbols_to_genes` and - everything below are unchanged. `ReleaseGeneSymbolGene.match_info` keeps its existing format - (`"X is an alias for Y (HGNC)"` / `"Gene v0/GRCh38"`), so `gene_grid.js` keeps rendering it. - ---- - -## 4. Resync command: `fix_rematch_release_symbols_to_genes` - -Extend the existing command (`genes/management/commands/fix_rematch_release_symbols_to_genes.py`) -from add-only to a full resync of the derived table, per release: - -1. `release_gene_symbols = list(gar.releasegenesymbol_set.all())` -2. `expected = gm._get_gene_id_and_match_info_for_symbol(rgs.gene_symbol_id for rgs in ...)` — - a dict `gene_symbol_id -> [(gene_id, match_info)]`. -3. Load existing rows: `ReleaseGeneSymbolGene.objects.filter(release_gene_symbol__release=gar) - .values_list("pk", "release_gene_symbol__gene_symbol_id", "gene_id", "match_info")`. -4. Diff on `(gene_symbol_id, gene_id)`: - * existing pair absent from `expected` → **delete** (`filter(pk__in=...)` in batches of 2000) - * pair present in both with different `match_info` → **update** (`bulk_update`) - * pair only in `expected` → **insert** (reuse `gm.match_symbols_to_genes(release_gene_symbols)` - after the deletes; it already does `ignore_conflicts=True`) -5. Print per-release counts: deleted / updated / inserted, and symbols left with no gene (as now). - At `--verbosity 2` also print each deleted `(symbol, gene_id, match_info)` so an operator can - eyeball what went. - -Add `--dry-run` (report the diff, make no writes) so it can be run on prod for inspection first. - -Cache: `GeneAnnotationRelease.genes_for_symbols` is a plain queryset with no Redis caching, and the -analysis gene-list nodes query `ReleaseGeneSymbolGene` at run time, so the change takes effect on -the next node load. Existing `NodeCount` figures for already-loaded gene-list nodes reflect the old -rows until the node re-runs; that is the normal behaviour after any gene-data change and needs no -extra invalidation. - ---- - -## 5. Migration: `genes/migrations/0089_one_off_resync_release_gene_symbol_genes.py` - -Follow `genes/migrations/0065_one_off_fix_make_panel_app_gene_lists_public.py`: - -```python -def _has_alias_derived_release_gene_matches(apps): - ReleaseGeneSymbolGene = apps.get_model("genes", "ReleaseGeneSymbolGene") - return ReleaseGeneSymbolGene.objects.filter(match_info__contains="is an alias for").exists() - -operations = [ - ManualOperation(task_id=ManualOperation.task_id_manage(["fix_rematch_release_symbols_to_genes"]), - note="Remove chained-alias gene matches (e.g. MT-TS2 -> RP8 -> PDCD2) and resync " - "ReleaseGeneSymbolGene to single-hop matching (#1669)", - test=_has_alias_derived_release_gene_matches), -] -``` - -Dependency: `("genes", "0088_one_off_stamp_existing_pfam_domains_imported")`. - ---- - -## 6. Tests — `genes/tests/test_gene_matching.py`, new `TestReleaseGeneMatcher` - -Build one small release in `setUpTestData`. `annotation/tests/test_data_fake_genes.py` has -`_create_fake_gene_version(genome_build, gene_id, symbol, consortium)`; the release needs a -`GeneAnnotationImport`, a `GeneAnnotationRelease(version, annotation_consortium, genome_build, -gene_annotation_import)` and a `ReleaseGeneVersion(release, gene_version)` per gene — a local helper -`_release_gene(symbol, gene_id)` wrapping those keeps the fixture readable. - -Release genes: `PDCD2`=`5134`, `ANOS1`=`3730`, `OLDNAME`=`111` (a gene the release still carries -under its previous symbol). Aliases (all HGNC): `RP8→MT-TS2`, `RP8→PDCD2`, `KAL1→ANOS1`, -`OLDNAME→NEWNAME`. Also create `GeneSymbol` rows for `MT-TS2`, `KAL1`, `NEWNAME`, `RP8`. - -Call `gm = ReleaseGeneMatcher(release)` and assert on -`gm._get_gene_id_and_match_info_for_symbol([...])`: - -| test | query | expected | -|---|---|---| -| `test_direct_symbol_wins` | `PDCD2` | `[("5134", None)]` | -| `test_forward_alias_hop` | `KAL1` | `[("3730", "KAL1 is an alias for ANOS1 (HGNC)")]` | -| `test_backward_alias_hop` | `NEWNAME` | `[("111", "OLDNAME is an alias for NEWNAME (HGNC)")]` | -| `test_shared_alias_does_not_bridge` | `MT-TS2` | nothing (`RP8` is absent from the release, so the walk stops) | -| `test_alias_matches_only_release_target` | `RP8` | `[("5134", "RP8 is an alias for PDCD2 (HGNC)")]` | - -Plus one command test: create `ReleaseGeneSymbol`s for `PDCD2`, `KAL1`, `MT-TS2`, plant a -`ReleaseGeneSymbolGene(MT-TS2 → 5134, "RP8 is an alias for PDCD2 (HGNC)")` and a direct -`PDCD2 → 5134`, run `call_command("fix_rematch_release_symbols_to_genes")`, and assert the planted -chained row is gone, the direct row remains, and `KAL1 → 3730` was inserted. Run the same with -`--dry-run` and assert nothing changed. - -Run with `python3 manage.py test --keepdb genes.tests.test_gene_matching`, then the wider -`genes.tests` and `annotation.tests.test_gene_level_annotation` (it builds `ReleaseGeneSymbolGene` -rows by hand and exercises `genes_for_symbol`). - ---- - -## 7. Changelog - -`variantgrid/templates/default_templates/changelog.html`, current release block: - -```html -
  • #1669 - Gene list matching follows a single alias hop; symbols sharing an alias string (e.g. MT-TS2 / PDCD2 via "RP8") are no longer linked
  • -``` - ---- - -## 8. Surfacing the substitution to the user - -GeneGrid already shows `Matched : ` for alias-matched symbols -(`variantgrid/static_files/default_static/js/gene_grid.js:384`). Showing the same on the gene list -page is #1668 item 4 and stays with that issue. - ---- - -## 9. Immediate prod workaround (before deploy) - -```sql -DELETE FROM genes_releasegenesymbolgene rgsg -USING genes_releasegenesymbol rgs -WHERE rgs.id = rgsg.release_gene_symbol_id - AND rgs.gene_symbol_id = 'MT-TS2' - AND rgsg.match_info LIKE '%PDCD2%'; -``` - -The full cleanup is §4 run via the §5 migration task. - ---- - -## Files - -| file | change | -|---|---| -| `genes/gene_matching.py` | `aliases_dict` rewritten to single hop; `_aliases` removed | -| `genes/management/commands/fix_rematch_release_symbols_to_genes.py` | add-only → full resync, `--dry-run` | -| `genes/migrations/0089_one_off_resync_release_gene_symbol_genes.py` | `ManualOperation` for the resync | -| `genes/tests/test_gene_matching.py` | `TestReleaseGeneMatcher` | -| `variantgrid/templates/default_templates/changelog.html` | entry | diff --git a/claude/plans/tso500_overall_plan.md b/claude/plans/tso500_overall_plan.md index 4097570f8..40f652a6b 100644 --- a/claude/plans/tso500_overall_plan.md +++ b/claude/plans/tso500_overall_plan.md @@ -404,8 +404,9 @@ one, so they want to stay stable from the first client. Phase 6's gene-symbol item rests on that assumption holding. Phase 5's fusion parser goes through the same resolver for `SEPT14` → `SEPTIN14`, so one check against a real database covers both — and if the alias is missing, a fusion partner still imports, just under a local `GENE:` id rather than its HGNC one. -[`1669_release_gene_matcher_alias_chaining_plan.md`](1669_release_gene_matcher_alias_chaining_plan.md) -changes alias resolution to single-hop, so do the check after that lands. +[#1669](https://github.com/SACGF/variantgrid/issues/1669) made `ReleaseGeneMatcher` single-hop; that is a +different resolver from this one, but do the check against a database that has had +`fix_rematch_release_symbols_to_genes` run. **Clients send a build's own name (`GRCh37`), not an alias.** `GenomeBuild.get_name_or_alias("hg19")` raises `MultipleObjectsReturned` rather than `DoesNotExist`, so a declared build that will not resolve diff --git a/claude/research/genes.md b/claude/research/genes.md index 6aef86115..b6a1227c1 100644 --- a/claude/research/genes.md +++ b/claude/research/genes.md @@ -81,8 +81,11 @@ A symbol does not name a gene; it names a gene *in a release*. The same symbol h RefSeq ids over time (and TAZ became TAFAZZIN between builds), so `genes/gene_matching.py:ReleaseGeneMatcher` writes `genes/models/models_gene_annotation_release.py:ReleaseGeneSymbol` / `ReleaseGeneSymbolGene` rows per release: first a direct hit on the release's own GeneVersion symbols, then `genes/gene_matching.py:ReleaseGeneMatcher.aliases_dict`, -which walks the alias graph (`ReleaseGeneMatcher._aliases`, loop-guarded) and the symbols other builds' GeneVersions -gave the same gene, recording the path as `match_info` so the gene list grid can show why. Readers never touch the +which takes a single `GeneSymbolAlias` hop in either direction (the symbol is the alias, or the alias is of the symbol) +plus the symbols other builds' GeneVersions gave the same gene, recording the hop as `match_info` so the gene list grid +can show why. The hop count is the whole point: chaining hops let an alias string shared by two unrelated genes bridge +them, which is how an MT-TS2 gene list matched PDCD2 via "RP8" (#1669), and HGNC lists every previous symbol of a gene +so renames never need more than one. Readers never touch the matcher: `genes/models/models_gene_annotation_release.py:GeneAnnotationRelease.genes_for_symbols` and `genes/models/models_gene_list.py:GeneList.get_genes` read the cached rows, and the release always comes from the VAV (`GeneAnnotationRelease.get_for_latest_annotation_versions_for_builds`). Matching is triggered whenever symbols @@ -247,8 +250,10 @@ dicts on first use and `GeneSymbolMatcher.create_gene_list_gene_symbols` re-matc a loop creating lists should share one matcher. Inserting `GeneListGeneSymbol` rows any other way leaves symbols with no release rows and the list matches nothing in analyses until `genes/management/commands/rematch_unmatched_gene_list_symbols.py:Command` runs; -`genes/management/commands/fix_rematch_release_symbols_to_genes.py:Command` re-runs the alias walk for symbols that -have a release row but no gene. +`genes/management/commands/fix_rematch_release_symbols_to_genes.py:Command` resyncs the whole derived table for every +release - it inserts, updates `match_info` and deletes matches the current rules no longer make, which matching itself +never does (`match_symbols_to_genes` only inserts), so rows left behind by an alias re-import or an older matcher need +it. Run it `--dry-run` first. Genes prefixed `unknown_` are legacy placeholders from pre-GFF imports; `genes/management/commands/fix_fake_genes.py:Command` re-points their transcripts where another version names the gene and `Gene.delete_orphaned_fake_genes` removes the diff --git a/genes/CLAUDE.md b/genes/CLAUDE.md index 956c78aea..5908a6e94 100644 --- a/genes/CLAUDE.md +++ b/genes/CLAUDE.md @@ -32,6 +32,8 @@ Gotchas: - `PanelAppPanel.cache_valid` expires after `settings.PANEL_APP_CACHE_DAYS` (models/models_panel_app.py:PanelAppPanel.cache_valid); panel_app.py:get_panel_app_local_cache re-fetches from the live API when stale, so tests must not depend on it. - GeneCoverageCollection is a partitioned model (models/models_gene_coverage.py:GeneCoverageCollection); delete via the model so partitions are dropped. - gene_matching.py:GeneSymbolMatcher and gene_matching.py:ReleaseGeneMatcher cache whole-table dicts on first use; build one per import, not per symbol. +- gene_matching.py:ReleaseGeneMatcher takes exactly one GeneSymbolAlias hop (either direction) - chaining hops lets an alias string shared by two unrelated genes bridge them (#1669). Keep it single-hop. +- Matching only ever inserts ReleaseGeneSymbolGene rows, so a rematch can't remove a match that's since become wrong; `manage.py fix_rematch_release_symbols_to_genes` (`--dry-run` first) is the full resync that also updates and deletes. - `` and `` have no HGVS at all - neither a ranged form nor an explicit expansion - so hgvs/hgvs_matcher.py:HGVSMatcher raises hgvs/hgvs_converter.py:HGVSNoRepresentationException before any converter runs, and classification records it as `ResolvedVariantInfo.error` rather than a Rollbar bug. Tests: - annotation/tests/test_data_fake_genes.py:create_fake_transcript_version builds Gene/GeneVersion/Transcript/TranscriptVersion (RUNX1, ENST00000300305.7) for a build; `create_gata2_transcript_version` / `create_pten_transcript_version` add RefSeq examples. diff --git a/genes/gene_matching.py b/genes/gene_matching.py index 1653f738d..128845cb5 100644 --- a/genes/gene_matching.py +++ b/genes/gene_matching.py @@ -170,70 +170,33 @@ def _get_genes_dict(self): @cached_property def aliases_dict(self) -> dict[str, dict]: - """ Get symbols from other GeneVersions that match genes from our release """ - genes_dict = self._get_genes_dict() - - # Gene Symbol alias - qs = GeneSymbolAlias.objects.filter(gene_symbol__releasegenesymbol__release=self.release) - gene_symboli_alias_list = [gsa for gsa in qs if gsa.alias != gsa.gene_symbol_id] - - alias_graph = defaultdict(list) - for gsa in gene_symboli_alias_list: - alias_graph[gsa.alias].append(gsa) - alias_graph[gsa.gene_symbol_id].append(gsa) - - for gene_symbol_alias in gene_symboli_alias_list: - for gene_symbol in [gene_symbol_alias.alias, gene_symbol_alias.gene_symbol_id]: - symbol_match_path = {gene_symbol: gene_symbol_alias.match_info} + """ Upper case symbol -> {gene_id: match_info} for symbols the release doesn't have, reached either + from an older GeneVersion's symbol or by a single GeneSymbolAlias hop (in either direction) to a + symbol the release does have. - self._aliases(alias_graph, genes_dict, gene_symbol, symbol_match_path) - - return genes_dict + One hop is deliberate: HGNC lists every previous symbol of a gene, so renames never need + chaining, while chaining lets an alias string shared by two unrelated genes bridge them - e.g. + 'RP8' is an alias of both MT-TS2 and PDCD2, which used to match MT-TS2 gene lists to PDCD2 (#1669) """ + genes_dict = self._get_genes_dict() - def _aliases(self, alias_graph, genes_dict, gene_symbol, symbol_match_path, visited_symbols=None): - # print(f"_aliases(alias_graph, genes_dict, {gene_symbol} - ({symbol_match_path})") - # Keep track of visited symbols to detect loops in graph - if visited_symbols is None: - visited_symbols = set() - else: - if gene_symbol in visited_symbols: # Stop unnecessary descent - return - visited_symbols.add(gene_symbol) - - if gene_id_list := self.genes.get(gene_symbol): - # Only need to build up match path for where we are - symbol_total_path = {} - match_paths = list(symbol_match_path.values()) - for i, symbol in enumerate(symbol_match_path): - mp = ", ".join(match_paths[i+1:]) - symbol_total_path[symbol] = mp - - for symbol in symbol_match_path: - if symbol != gene_symbol: # No point putting actual one in - match_info = symbol_total_path[symbol] + # Ordered so the match_info a symbol/gene pair reached by several alias rows gets is stable across runs + alias_values = GeneSymbolAlias.objects.order_by("pk").values_list("alias", "gene_symbol_id", "source") + for alias, symbol, source in alias_values.iterator(): + uc_alias = alias.upper() + uc_symbol = symbol.upper() + if uc_alias == uc_symbol: + continue + + # "alias is an alias for symbol" - a gene list can use either end and mean the other + for query, target_symbol in ((uc_alias, uc_symbol), (uc_symbol, uc_alias)): + if query in self.genes: + continue # Release has the symbol itself, so aliases are never consulted for it + if gene_id_list := self.genes.get(target_symbol): + match_info = GeneSymbolAlias(alias=alias, gene_symbol_id=symbol, source=source).match_info for gene_id in gene_id_list: - if existing_match_info := genes_dict[symbol].get(gene_id): - if len(match_info) >= len(existing_match_info): - continue # Don't override with longer - genes_dict[symbol][gene_id] = match_info - else: - if aliases_list := alias_graph.get(gene_symbol): - original_gene_symbol = gene_symbol - - for gene_symbol_alias in aliases_list: - # We may have looked it up via alias or gene symbol - use the other one + genes_dict[query].setdefault(gene_id, match_info) - if gene_symbol_alias.gene_symbol_id == original_gene_symbol: - gene_symbol = gene_symbol_alias.alias - else: - gene_symbol = gene_symbol_alias.gene_symbol_id - - # Make a copy for recursion - child_symbol_match_path = symbol_match_path.copy() - child_symbol_match_path[gene_symbol] = gene_symbol_alias.match_info - - self._aliases(alias_graph, genes_dict, gene_symbol, child_symbol_match_path, - visited_symbols=visited_symbols) + return genes_dict def _get_gene_id_and_match_info_for_symbol(self, gene_symbols) -> dict[str, list]: gene_symbol_gene_id_and_match_info = defaultdict(list) # list items = (gene_id, match_info) diff --git a/genes/management/commands/fix_rematch_release_symbols_to_genes.py b/genes/management/commands/fix_rematch_release_symbols_to_genes.py index 1bb17d309..2f910a2f4 100644 --- a/genes/management/commands/fix_rematch_release_symbols_to_genes.py +++ b/genes/management/commands/fix_rematch_release_symbols_to_genes.py @@ -1,24 +1,65 @@ +""" +Resyncs ReleaseGeneSymbolGene (the symbol -> gene matches for a GeneAnnotationRelease) with what +ReleaseGeneMatcher produces today: inserts missing matches, updates changed match_info and deletes +matches the current rules no longer make. + +The delete is what makes this more than a rematch: matching only ever inserted, so rows written by +the old multi-hop alias traversal (#1669) and rows derived from alias rows since deleted by a +re-import survive a rematch. Run with --dry-run first to see the diff. +""" from django.core.management import BaseCommand from genes.gene_matching import ReleaseGeneMatcher from genes.models import GeneAnnotationRelease, ReleaseGeneSymbol, ReleaseGeneSymbolGene +BATCH_SIZE = 2000 + class Command(BaseCommand): category = "one-off" + def add_arguments(self, parser): + parser.add_argument('--dry-run', action='store_true', help="Report the diff without writing anything") + def handle(self, *args, **options): - for gar in GeneAnnotationRelease.objects.all(): - no_match_qs = ReleaseGeneSymbol.objects.filter(release=gar, releasegenesymbolgene__isnull=True) + dry_run = options["dry_run"] + verbosity = options["verbosity"] - qs = ReleaseGeneSymbolGene.objects.filter(release_gene_symbol__release=gar) - num_genes_original = qs.count() - num_no_match_original = no_match_qs.count() - print(f"{gar} - symbols w/o gene: {num_no_match_original}") + for gar in GeneAnnotationRelease.objects.all(): + release_gene_symbols = list(gar.releasegenesymbol_set.all()) gm = ReleaseGeneMatcher(gar) - release_gene_symbols = gar.releasegenesymbol_set.all() - gm.match_symbols_to_genes(release_gene_symbols) - num_genes = qs.count() + expected = gm._get_gene_id_and_match_info_for_symbol(rgs.gene_symbol_id for rgs in release_gene_symbols) + expected_matches = {} # (gene_symbol_id, gene_id) -> match_info + for gene_symbol_id, gene_id_and_match_info in expected.items(): + for gene_id, match_info in gene_id_and_match_info: + expected_matches[(gene_symbol_id, gene_id)] = match_info - print(f"{gar} - matched {num_genes - num_genes_original} genes") - print(f"{gar} - {no_match_qs.count() - num_no_match_original} less symbols w/o genes") + existing_qs = ReleaseGeneSymbolGene.objects.filter(release_gene_symbol__release=gar) + existing = existing_qs.values_list("pk", "release_gene_symbol__gene_symbol_id", "gene_id", "match_info") + + delete_pks = [] + update_records = [] + for pk, gene_symbol_id, gene_id, match_info in existing: + key = (gene_symbol_id, gene_id) + if key in expected_matches: + expected_match_info = expected_matches.pop(key) # What's left over needs inserting + if expected_match_info != match_info: + update_records.append(ReleaseGeneSymbolGene(pk=pk, match_info=expected_match_info)) + else: + delete_pks.append(pk) + if verbosity >= 2: + print(f"{gar} - delete {gene_symbol_id} -> {gene_id} ({match_info})") + + num_insert = len(expected_matches) + print(f"{gar} - delete: {len(delete_pks)}, update: {len(update_records)}, insert: {num_insert}") + + if not dry_run: + for i in range(0, len(delete_pks), BATCH_SIZE): + ReleaseGeneSymbolGene.objects.filter(pk__in=delete_pks[i:i + BATCH_SIZE]).delete() + if update_records: + ReleaseGeneSymbolGene.objects.bulk_update(update_records, ["match_info"], batch_size=BATCH_SIZE) + if num_insert: + gm.match_symbols_to_genes(release_gene_symbols) + + no_match_qs = ReleaseGeneSymbol.objects.filter(release=gar, releasegenesymbolgene__isnull=True) + print(f"{gar} - symbols w/o gene: {no_match_qs.count()}") diff --git a/genes/migrations/0089_one_off_resync_release_gene_symbol_genes.py b/genes/migrations/0089_one_off_resync_release_gene_symbol_genes.py new file mode 100644 index 000000000..f5c12eddb --- /dev/null +++ b/genes/migrations/0089_one_off_resync_release_gene_symbol_genes.py @@ -0,0 +1,22 @@ +from django.db import migrations + +from manual.operations.manual_operations import ManualOperation + + +def _has_alias_derived_release_gene_matches(apps): + ReleaseGeneSymbolGene = apps.get_model("genes", "ReleaseGeneSymbolGene") + return ReleaseGeneSymbolGene.objects.filter(match_info__contains="is an alias for").exists() + + +class Migration(migrations.Migration): + + dependencies = [ + ('genes', '0088_one_off_stamp_existing_pfam_domains_imported'), + ] + + operations = [ + ManualOperation(task_id=ManualOperation.task_id_manage(["fix_rematch_release_symbols_to_genes"]), + note="Remove chained-alias gene matches (e.g. MT-TS2 -> RP8 -> PDCD2) and resync " + "ReleaseGeneSymbolGene to single-hop matching (#1669)", + test=_has_alias_derived_release_gene_matches), + ] diff --git a/genes/tests/test_gene_matching.py b/genes/tests/test_gene_matching.py index 9325a750f..448d3a745 100644 --- a/genes/tests/test_gene_matching.py +++ b/genes/tests/test_gene_matching.py @@ -1,14 +1,30 @@ from django.contrib.auth.models import User +from django.core.management import call_command from django.test.testcases import TestCase from genes.gene_matching import ( MAX_GENE_SYMBOL_LENGTH, GeneSymbolMatcher, HGNCMatcher, + ReleaseGeneMatcher, tokenize_gene_symbols, ) -from genes.models import HGNC, GeneList, GeneSymbol, GeneSymbolAlias, HGNCImport -from genes.models_enums import GeneSymbolAliasSource, HGNCStatus +from genes.models import ( + HGNC, + Gene, + GeneAnnotationImport, + GeneAnnotationRelease, + GeneList, + GeneSymbol, + GeneSymbolAlias, + GeneVersion, + HGNCImport, + ReleaseGeneSymbol, + ReleaseGeneSymbolGene, + ReleaseGeneVersion, +) +from genes.models_enums import AnnotationConsortium, GeneSymbolAliasSource, HGNCStatus +from snpdb.models import GenomeBuild class TestGeneMatching(TestCase): @@ -130,3 +146,96 @@ def test_create_gene_list_filters_oversized_from_prefiltered_list(self): self.assertIn("oversized", gene_list.error_message.lower()) saved_names = set(gene_list.genelistgenesymbol_set.values_list("original_name", flat=True)) self.assertEqual({"BRCA1"}, saved_names) + + +class ReleaseGeneMatcherTestCase(TestCase): + """ Base fixture: a release with PDCD2/ANOS1/OLDNAME, and HGNC aliases where "RP8" is shared by + MT-TS2 (absent from the release) and PDCD2. """ + + @classmethod + def setUpTestData(cls): + genome_build = GenomeBuild.get_name_or_alias("GRCh38") + cls.gene_annotation_import = GeneAnnotationImport.objects.create( + url="fake", genome_build=genome_build, annotation_consortium=AnnotationConsortium.REFSEQ) + cls.release = GeneAnnotationRelease.objects.create( + version="test_1669", annotation_consortium=AnnotationConsortium.REFSEQ, + genome_build=genome_build, gene_annotation_import=cls.gene_annotation_import) + + def _release_gene(symbol, gene_id): + gene_symbol = GeneSymbol.objects.get_or_create(symbol=symbol)[0] + gene = Gene.objects.create(identifier=gene_id, annotation_consortium=AnnotationConsortium.REFSEQ) + gene_version = GeneVersion.objects.create(gene=gene, gene_symbol=gene_symbol, version=1, + genome_build=genome_build, + import_source=cls.gene_annotation_import) + ReleaseGeneVersion.objects.create(release=cls.release, gene_version=gene_version) + return gene + + _release_gene("PDCD2", "5134") + _release_gene("ANOS1", "3730") + _release_gene("OLDNAME", "111") # Release still carries this gene under its previous symbol + + def _alias(alias, symbol): + GeneSymbol.objects.get_or_create(symbol=alias) + GeneSymbol.objects.get_or_create(symbol=symbol) + GeneSymbolAlias.objects.create(alias=alias, gene_symbol_id=symbol, + source=GeneSymbolAliasSource.HGNC) + + # HGNC has RP8 as an alias for both MT-TS2 (not in release) and PDCD2 (in release) + _alias("RP8", "MT-TS2") + _alias("RP8", "PDCD2") + _alias("KAL1", "ANOS1") + _alias("OLDNAME", "NEWNAME") + + +class TestReleaseGeneMatcher(ReleaseGeneMatcherTestCase): + """ A symbol reaches a release's genes directly, or through exactly one GeneSymbolAlias hop (either + direction). Chaining hops let an alias string shared by two unrelated genes bridge them (#1669). """ + + def _match(self, symbol): + gm = ReleaseGeneMatcher(self.release) + return gm._get_gene_id_and_match_info_for_symbol([symbol])[symbol] + + def test_direct_symbol_wins(self): + self.assertEqual([("5134", None)], self._match("PDCD2")) + + def test_forward_alias_hop(self): + self.assertEqual([("3730", "KAL1 is an alias for ANOS1 (HGNC)")], self._match("KAL1")) + + def test_backward_alias_hop(self): + self.assertEqual([("111", "OLDNAME is an alias for NEWNAME (HGNC)")], self._match("NEWNAME")) + + def test_shared_alias_does_not_bridge(self): + self.assertEqual([], self._match("MT-TS2")) + + def test_alias_matches_only_release_target(self): + self.assertEqual([("5134", "RP8 is an alias for PDCD2 (HGNC)")], self._match("RP8")) + + +class TestFixRematchReleaseSymbolsToGenes(ReleaseGeneMatcherTestCase): + """ The resync command has to delete matches the current rules no longer make - a rematch only inserts. """ + + def setUp(self): + release_gene_symbols = {} + for symbol in ["PDCD2", "KAL1", "MT-TS2"]: + release_gene_symbols[symbol] = ReleaseGeneSymbol.objects.create(release=self.release, + gene_symbol_id=symbol) + # What the old multi-hop traversal wrote: MT-TS2 -> RP8 -> PDCD2 + ReleaseGeneSymbolGene.objects.create(release_gene_symbol=release_gene_symbols["MT-TS2"], gene_id="5134", + match_info="RP8 is an alias for PDCD2 (HGNC)") + ReleaseGeneSymbolGene.objects.create(release_gene_symbol=release_gene_symbols["PDCD2"], gene_id="5134") + + def _matched_genes(self, symbol): + return set(ReleaseGeneSymbolGene.objects.filter(release_gene_symbol__release=self.release, + release_gene_symbol__gene_symbol_id=symbol) + .values_list("gene_id", flat=True)) + + def test_resync_deletes_chained_inserts_missing_and_keeps_direct(self): + call_command("fix_rematch_release_symbols_to_genes") + self.assertEqual(set(), self._matched_genes("MT-TS2")) + self.assertEqual({"5134"}, self._matched_genes("PDCD2")) + self.assertEqual({"3730"}, self._matched_genes("KAL1")) + + def test_dry_run_writes_nothing(self): + call_command("fix_rematch_release_symbols_to_genes", dry_run=True) + self.assertEqual({"5134"}, self._matched_genes("MT-TS2")) + self.assertEqual(set(), self._matched_genes("KAL1")) diff --git a/variantgrid/templates/default_templates/changelog.html b/variantgrid/templates/default_templates/changelog.html index ed7179c70..f83d11ab1 100644 --- a/variantgrid/templates/default_templates/changelog.html +++ b/variantgrid/templates/default_templates/changelog.html @@ -150,6 +150,7 @@

    Genes

  • PanelApp panels are matched on HGNC ID rather than the gene symbol from a dated snapshot, and deleted panels are handled
  • Gene Lists - faster grid, copy/paste genes in, and a graphs tab showing gene locations
  • A mitochondrial 'm.' HGVS on a non-MT reference now warns rather than silently resolving as 'g.'
  • +
  • Gene Lists - a symbol only matches genes through a single alias hop, so genes sharing an alias string (eg MT-TS2 / PDCD2 via "RP8") are no longer linked
  • Genome Builds

    @@ -294,6 +295,10 @@

    Liftover

    • #1273 - Liftover page - retry failed liftovers per tool (eg all failed bcftools +liftover)
    +

    Genes

    +
      +
    • #1669 - Gene list matching follows a single alias hop; symbols sharing an alias string (eg MT-TS2 / PDCD2 via "RP8") are no longer linked
    • +