feat(str): corpus-wide Y-STR marker report + mutation-rate provenance - #22
Merged
Conversation
- Implemented GSA linking for CNCB studies, extending dual-archive coverage to East/Central-Asian BioProjects (`PRJCA`, `CRA`). - Added `GsaClient` for GSA open-tier crawls (`getRunInfoByCra` CSV parsing, `md5sum.txt` checksums). - Parametrized `ingest_libraries` to support GSA source alongside ENA. - Updated `crawl_project` to dispatch by accession prefix (`PRJCA/CRA` to GSA, `PRJEB/ENA` to ENA). - Introduced `pubs.study_source` migration for `"CNCB_GSA"` support. - Handled controlled-tier studies (`HRA`, `GVM`) by recording accession + DAR marker, skipping file ingest. - Verified prefix-agnostic `SAMC` biosample ingestion within existing infrastructure. Scoped to GSA open-tier only; controlled-tier workflows (GSA-Human/aspersa) deferred.
Adds /str-markers and /api/v1/reports/str-markers: the observed repeat range (min / modal / max) of every Y-STR marker across all profiles we hold, joined to its repeat motif and mutation rate. Until now the STR data was only ever read per-haplogroup (modal signatures, ASR, branch ages), so there was no way to ask what the corpus says about a marker. Corpus-wide only — the clade filter can be added later without a schema change. Migration 0069 adds genomics.str_marker, the first STR marker *reference* in the schema; marker identity was previously a bare marker_name TEXT everywhere. Seeded (142 rows, 125 with a motif) by parsing the free-text str_mutation_rate.source, the only motif information this database holds. `coordinates` mirrors the core.genome_region shape so a locus source can be loaded in place later; it is empty today. Marker-name aliasing is load-bearing, not defensive: profiles say DYS19 and Y-GATA-H4 where the rate table says DYS394 and YGATAH4, so a join on name alone silently drops the two most recognizable markers in the panel. DYS19/DYS394 are also duplicate rate rows for one locus, so the seed dedupes them. Every lookup folds through str_marker.aliases. Migration 0070 re-keys str_mutation_rate on (marker_name, method) with a partial unique index on the single active rate per marker, so a rate derived from our own tree can coexist with the published one instead of overwriting it; load_marker_models now reads WHERE is_active, making promotion a data operation rather than a code change. It also records, per branch age, which rates produced it. That split is worth having: only 114 of 829 observed markers have a published rate, so across all 1,963 STR_VARIANCE branches 1,393,730 marker-scorings fall back to DEFAULT_STR_RATE against 221,819 measured — previously invisible. A reported 0 is FTDNA's null-allele (deleted locus) convention rather than a zero-repeat count, so it is excluded from min/modal/max and counted separately; without that DYS448 reads min 0 instead of 17 and CDY reads min '0-0'. The same rule applies per copy of a multi-copy vector. Deriving rates from accumulating observations is designed but NOT built here. The estimator must take branch lengths from method='SNP_POISSON' ages only — COMBINED or STR_VARIANCE would feed STR-derived ages back into STR rate estimation and let the model converge on itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
clippy::unnecessary_sort_by. The name and range arms rewrite directly; the motif arm follows for consistency, since a plain tuple key reads better than a hand-written comparison against a reconstructed tuple. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test connected to the shared DATABASE_URL database directly, unlike the two tests beside it in the same file, so it assumed that database was already migrated. That holds on a dev box and does not in CI, where the Postgres service starts empty — the insert failed with `relation "tree.haplogroup" does not exist`. Main has been red on this since 97f0853; it is unrelated to the STR work in this branch but blocks it, so it is fixed here. Switching to du_db::testing::ephemeral_db matches its siblings and makes the trailing manual cleanup redundant — the ephemeral database drops itself. Verified by pointing DATABASE_URL at a freshly created empty database and running the suite in parallel (CI's mode, not the --test-threads=1 that had been masking this locally alongside an already-migrated dev DB): 3/3 in variant_naming, and du-db green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a corpus-wide Y-STR marker report —
GET /str-markers(HTML) andGET /api/v1/reports/str-markers(JSON): the observed repeat range (min / modal / max) of every Y-STR marker across all profiles we hold, joined to its repeat motif and mutation rate.Until now the STR data was only ever read vertically —
du_db::ystrreads it per-haplogroup for modal signatures, ASR and branch ages — so there was no way to ask what the corpus says about a given marker. This is the horizontal view, the shape a vendor project page publishes as its MIN/MAX/MODE row, over a larger cohort.Corpus-wide only; the clade filter can be added later without a schema change.
Migrations
0069_str_marker.sql—genomics.str_marker, the first STR marker reference in the schema. Marker identity was previously a baremarker_name TEXTin every table; nothing said what DYS390 is. Seeded to 142 rows (125 with a motif) by parsing the free-textstr_mutation_rate.source, the only motif information this database holds.coordinatesmirrors thecore.genome_regionshape so a locus source can be loaded in place later — it is empty today, as no table has ever held an STR locus position.0070_str_rate_provenance.sql— re-keysstr_mutation_ratefrommarker_name UNIQUEto(marker_name, method), plus a partial unique index on the single active rate per marker, and records per-branch which rates produced an age estimate.Three findings that shaped the design
1. Marker-name aliasing is load-bearing, not defensive. Profiles say
DYS19andY-GATA-H4; the rate table saysDYS394andYGATAH4for the same loci. A join on name alone silently drops the two most recognizable markers in the panel.DYS19/DYS394are also duplicate rate rows for one locus (identical rate and motif) which collide on insert, so the seed dedupes them. Every lookup folds throughstr_marker.aliases.2. ~86% of the STR age model runs on a placeholder rate. Only 114 of 829 observed markers have a published rate; the rest fall back to
DEFAULT_STR_RATE(0.0025) atystr.rs:397/:627. Measured across all 1,963STR_VARIANCEbranches:This was previously invisible. It is now recorded per branch (
rate_method,measured_rate_markers,default_rate_markers) and shown per marker.3. Null alleles exist in multi-copy form too. A reported
0is FTDNA's deleted-locus convention, not a zero repeat count — including it makes DYS448 read min 0 instead of 17, and CDY read min0-0. Any zero copy in a vector counts as a null allele and is excluded from min/modal/max. Only fully-null0-0vectors actually occur today.Forward capacity (designed, NOT built here)
load_marker_modelsnow readsWHERE is_active, so promoting a derived rate is a data operation rather than a code change. The ingredients for deriving our own rates already exist:tree.haplogroup_str_asrholds 9.4M reconstructed states across 823 markers, and 10,242 haplogroups carry both ASR and aSNP_POISSONage — ~7× the marker coverage of our published rates.Important
When that job is written, branch lengths must come from
method='SNP_POISSON'ages only — neverCOMBINEDorSTR_VARIANCE, which would feed STR-derived ages back into STR rate estimation and let the model converge on itself. This is the one part of the design that fails silently if got wrong, so the job should assert the filter rather than parameterize it.Verification
DYS448min 17, not 0, with 1 null allele ·DYS39021/24/26 ·DYS385modal combination11-15·DYS19andY-GATA-H4resolve motif/rate only via alias folding ·DYS447readsDEFAULT_RATE.measured_rate_markers + default_rate_markers = marker_counton every row (0 mismatches).run-once branch-agere-run producedstr_ages=1963 snp=10242 combined=10257— identical to the pre-change baseline, so theis_activefilter causes no regression.--test-threads=1), including 2 new integration tests and 4 new unit tests.🤖 Generated with Claude Code