Skip to content

fix: replicate VEP merge_features HGNC_ID propagation in cache builder#162

Merged
mwiewior merged 2 commits into
masterfrom
fix/hgnc-propagation-105
Apr 5, 2026
Merged

fix: replicate VEP merge_features HGNC_ID propagation in cache builder#162
mwiewior merged 2 commits into
masterfrom
fix/hgnc-propagation-105

Conversation

@mwiewior

@mwiewior mwiewior commented Apr 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Replicate VEP's merge_features() HGNC_ID propagation in the Parquet cache builder (storable_to_parquet)
  • Uses COALESCE + FIRST_VALUE IGNORE NULLS OVER (PARTITION BY gene_symbol) window function in the transcript dedup query
  • Closes 82 HGNC_ID gaps across 7 gene symbols genome-wide (e.g., FGF7P3 30/46 missing, GUSBP5 39/76 missing)
  • Guards against cross-pollination for NULL gene_symbol transcripts

Resolves biodatageeks/datafusion-bio-functions#105

Changes

  • storable_to_parquet.rs: Modified build_dedup_query() to build an explicit column list for transcripts, replacing gene_hgnc_id with the propagation expression
  • hgnc_propagation_tests.rs: 6 new unit tests using synthetic MemTable data covering: basic propagation, no-overwrite, NULL symbol, all-NULL HGNC, multiple symbols, mixed scenario
  • real_vep_cache_tests.rs: 1 new integration test against real VEP 115 chr22 fixture verifying zero remaining gaps after propagation

Performance

The window function adds one hash partition over gene_symbol (~20K distinct values). On ~3M transcript rows this is negligible (+1-2s) compared to the existing ROW_NUMBER window + ORDER BY + Parquet write.

Test plan

  • All 6 synthetic HGNC propagation tests pass
  • Real-data test (hgnc_propagation_fills_all_siblings) passes on chr22 fixture
  • All 29 existing ensembl-cache tests pass (no regressions)
  • cargo clippy clean
  • cargo fmt --check clean
  • Rebuild chr9 parquet and verify FGF7P3 all 46 transcripts have HGNC:26671

🤖 Generated with Claude Code

The Parquet cache builder now propagates gene_hgnc_id across transcripts
sharing the same gene_symbol, replicating VEP's merge_features() behaviour.
This closes 82 HGNC_ID gaps across 7 gene symbols genome-wide.

Resolves biodatageeks/datafusion-bio-functions#105

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@mwiewior

mwiewior commented Apr 4, 2026

Copy link
Copy Markdown
Contributor Author

@claude pls review

@claude

claude Bot commented Apr 4, 2026

Copy link
Copy Markdown

Claude finished @mwiewior's task in 1m 39s —— View job


Code Review

  • Gather context and read changed files
  • Review storable_to_parquet.rs changes
  • Review hgnc_propagation_tests.rs (new test file)
  • Review real_vep_cache_tests.rs changes
  • Post comprehensive review feedback

Overall this is a clean, focused fix that correctly replicates VEP's merge_features() propagation. The window-function approach is idiomatic and the NULL-symbol guard is the right call. A few things worth noting:

Correctness ✅

The core SQL expression is correct:

COALESCE(gene_hgnc_id,
    CASE WHEN gene_symbol IS NOT NULL
         THEN FIRST_VALUE(gene_hgnc_id) IGNORE NULLS OVER (PARTITION BY gene_symbol)
         ELSE NULL END) AS gene_hgnc_id
  • COALESCE ensures existing non-null values are never overwritten.
  • The gene_symbol IS NOT NULL guard correctly prevents NULL-symbol transcripts from receiving a propagated ID.
  • FIRST_VALUE ... IGNORE NULLS without ORDER BY is non-deterministic in general SQL, but here all siblings sharing a gene_symbol should carry the same HGNC ID, so any non-null value picked is the correct one. Fine as-is.

The _rn handling change at storable_to_parquet.rs:542 is correct — the explicit column list built from the schema naturally excludes _rn, so the post-query drop only needs to apply to Exon now.

Issues

1. Doc comment typo — _gene_hgnc_id vs gene_hgnc_id
storable_to_parquet.rs:81 says _gene_hgnc_id (with leading underscore) but the actual column is gene_hgnc_id. Minor but misleading.

Fix this →

2. Option<&SchemaRef> API immediately panics for Transcript — consider tightening
build_dedup_query accepts Option<&SchemaRef> but calls .expect() on line 100 when kind == Transcript. The Option wrapper communicates "optional" but the function treats it as mandatory for one variant. A cleaner design would either always pass a &SchemaRef or return a Result. Since this is an internal example binary the current approach is acceptable, but it's a hidden contract worth documenting explicitly (or asserting only in debug).

3. Integration test duplicates the production expression
real_vep_cache_tests.rs:1131–1156 reimplements the column-building loop and the COALESCE expression verbatim instead of calling into build_dedup_query. If someone updates the expression in production but forgets to update the test copy, the integration test will keep passing despite testing different logic.

Suggested fix: extract the HGNC propagation SQL expression as a const or helper (fn hgnc_propagation_expr() -> &'static str) inside the example file, and import/use that constant in both build_dedup_query and the integration test.

Fix this →

Minor Observations

  • The chrom value is directly interpolated into the SQL string (format!(" WHERE chrom = '{chrom}'")). This is a pre-existing issue and out of scope here, but worth tracking since this is an example binary that reads user CLI input.
  • Test coverage in hgnc_propagation_tests.rs is comprehensive — all key scenarios (basic fill, no-overwrite, NULL symbol, all-NULL HGNC, multiple symbols, mixed) are covered with clear assertions. Well done.
  • The schema.expect(...) message on line 100 is descriptive enough to diagnose a bug quickly.

Summary

The fix is correct and the approach is sound. Issue #3 (test/prod expression divergence) is the most actionable — the rest are minor polish items. The performance impact assessment in the PR description is reasonable given the data sizes involved.

mwiewior added a commit to biodatageeks/datafusion-bio-functions that referenced this pull request Apr 5, 2026
Picks up the HGNC_ID propagation fix from
biodatageeks/datafusion-bio-formats#162.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…results

FIRST_VALUE(gene_hgnc_id) IGNORE NULLS without ORDER BY is
non-deterministic — DataFusion may pick a NULL row as "first" in the
undefined partition order, causing the propagation to silently fail.

Adding ORDER BY gene_hgnc_id NULLS LAST ensures the non-null value
is always found first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@mwiewior
mwiewior merged commit 58e34d0 into master Apr 5, 2026
2 checks passed
mwiewior added a commit to biodatageeks/datafusion-bio-functions that referenced this pull request Apr 5, 2026
…106)

* fix: fjall compaction, Drop deadlock, and pre-commit hook toolchain

1. load_sift_cache: add major_compact() after persist — without this,
   every SIFT lookup probes hundreds of L0 SSTs (~10% slower)

2. CacheLoader (loader.rs): add mem::forget after persist + compact to
   avoid fjall Drop deadlock on large datasets (bounded flume channel
   fills up when background compaction is still running)

3. cache_builder.rs: apply mem::forget fix for both
   build_variation_fjall_from_parquet() and build_sift_fjall()

4. pre-commit: switch from doublify/pre-commit-rust v1.0 (2019) to
   local hooks using the project's own cargo toolchain — the old hooks
   used a rustfmt that didn't support edition 2024 let-chains

Also adds compact_fjall utility for re-compacting existing uncompacted
stores without re-ingesting data.

Closes #86

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump datafusion-bio-format-ensembl-cache to b82396d

Picks up the HGNC_ID propagation fix from
biodatageeks/datafusion-bio-formats#162.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add mem::forget to load_context_kv to prevent potential Drop deadlock

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* VEP cache fix

* fix: prevent extra fjall keyspaces that cause ~10% annotation perf degradation

load_context_kv was writing translations/exons keyspaces directly into
variation.fjall, adding unused keyspaces that increased fjall block cache
pressure during point lookups. Profiling showed variation_lookup time
growing progressively across chromosomes (+41s over a full WGS run).

Changes:
- load_context_kv: write to separate context.fjall instead of variation.fjall
- profile_annotation: auto-detect variation.fjall and enable use_fjall=true
- strip_fjall: new diagnostic tool to check for extra keyspaces
- kv_store: add tests ensuring create/open produce only meta+data keyspaces
- sift_store: add tests ensuring create/ingest produce only sift keyspace

Expected keyspace layout after rebuild:
- variation.fjall: default + meta + data (3 dirs)
- translation_sift.fjall: default + sift (2 dirs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — keyspace_exists guard, strip_fjall sift fix, cfg(not(test)) guard

1. compact_fjall: add keyspace_exists() check before db.keyspace() to
   prevent creating extra keyspaces when compacting (db.keyspace() with
   default options creates the keyspace if missing)

2. strip_fjall: remove "meta" from expected keyspaces for
   translation_sift.fjall — SiftKvStore only creates "sift", no "meta"

3. cache_builder: wrap mem::forget in #[cfg(not(test))] consistent with
   loader.rs, so tests properly release fjall lock files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove translations/exons from compact_fjall known keyspaces

These keyspaces should never exist in production fjall stores.
Keeping them in the list would compact keyspaces that shouldn't be there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: compact_fjall auto-detects store type to avoid cross-contamination

variation.fjall should only compact meta+data; translation_sift.fjall
should only compact sift. The previous flat list would attempt to open
all keyspace names on any store, risking creation of foreign keyspaces.

Now auto-detects from path: translation_sift → sift only, else → meta+data.
Explicit --keyspace arg still honored for manual use.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace mem::forget with drop() to enable fjall GC of dead SSTs

mem::forget skipped fjall's Drop handler which runs GC/maintenance to
delete old pre-compaction SST files. This left ~220GB of dead files
on disk after building variation.fjall (331GB instead of 110GB).

After major_compact() + persist(), there is no pending work for the
worker thread, so Drop completes without the deadlock from issue #86.

Changed in: cache_builder.rs, loader.rs, compact_fjall.rs,
load_sift_cache.rs, load_context_kv.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add HGNC_ID propagation to cache_builder transcript query (#105)

cache_builder's build_query() for transcripts used SELECT * which
didn't propagate gene_hgnc_id from sibling transcripts sharing the
same gene_symbol. This caused 1,730 HGNC_ID mismatches on chr9
(FGF7P3 — 19/46 transcripts missing HGNC:26671).

Port the same fix from datafusion-bio-formats PR #162: replace
SELECT * with an explicit column list using COALESCE + FIRST_VALUE
IGNORE NULLS OVER (PARTITION BY gene_symbol) for gene_hgnc_id.

TODO: consolidate build_dedup_query() into bio-formats as a shared
library function to avoid duplicate query logic across repos.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* bio-format update

* fix: restrict HGNC propagation to HGNC-source transcripts only (#104)

VEP only emits HGNC_ID for transcripts with gene_symbol_source='HGNC'.
The cache-level propagation was filling HGNC_ID for EntrezGene/RFAM
transcripts too, causing 39 false-positive mismatches on chr9.

Add gene_symbol_source = 'HGNC' guard to the COALESCE expression.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert gene_symbol_source guard from cache-level HGNC propagation

VEP's merge_features() propagates gene_hgnc_id purely by gene_symbol,
regardless of gene_symbol_source. The gene_symbol_source='HGNC' guard
was wrong at cache build time — FGF7P3 has 45/46 transcripts with
source=EntrezGene that still need HGNC_ID in the cache.

The source-based filtering is a runtime concern (hgnc_backfill flag,
#104) that controls whether HGNC_ID appears in annotation output,
not whether it's stored in the cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HGNC_ID gap: cache doesn't replicate VEP's merge_features symbol-to-HGNC propagation

1 participant