From 7c4fbf6ecc72b7dd65b96115a64158c7b3617896 Mon Sep 17 00:00:00 2001 From: James Kane Date: Sun, 2 Aug 2026 13:28:53 -0500 Subject: [PATCH 1/2] Bring the workspace back to rustfmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo fmt --all` had drifted far enough that running it touched 116 files, which meant every feature branch either carried a pile of unrelated reformatting or had to be de-noised by hand before review. This is that reformat, on its own, so the next one does not have to be. Pure `cargo fmt --all` output, with one class of exception. Where a standalone comment block directly follows a line that already ends in a trailing comment, rustfmt aligns the block to that comment's column — pushing text out to column 50-90 and, in a few cases, past the 120 the config asks for. That is worse than the drift it replaces, so 20 blank lines were inserted by hand to break the association and let the comments sit at their natural indent. Two of those artifacts predate this commit; they are fixed here too. One comment changed wording rather than position: the `// 400 absent entirely` note in `archaic::tests` documented a panel site deliberately missing from the genotype list, and rustfmt could only render it at a bad indent inside the `vec![]`. It is now a sentence above the binding, which is where it belonged. `cargo fmt --all --check` is clean and idempotent; clippy `--all-targets` clean; `cargo test --workspace` green. Co-Authored-By: Claude Opus 5 (1M context) --- .../examples/archaic_callable_dump.rs | 4 +- .../examples/archaic_classify_dump.rs | 4 +- .../examples/archaic_match_probe.rs | 12 +- .../examples/archaic_outgroup_density.rs | 4 +- .../examples/archaic_panel_dump.rs | 4 +- .../examples/archaic_private_dump.rs | 8 +- .../examples/archaic_segments_probe.rs | 36 +- .../examples/cram_query_probe.rs | 26 +- .../examples/denovo_profile.rs | 3 +- .../examples/profile_analysis.rs | 20 +- .../examples/reassembly_probe.rs | 40 +- .../examples/reassembly_validate.rs | 5 +- .../navigator-analysis/examples/site_reads.rs | 27 +- .../examples/validate_coverage.rs | 58 +- crates/navigator-analysis/src/ancestry.rs | 123 +- crates/navigator-analysis/src/archaic.rs | 36 +- .../navigator-analysis/src/archaic_match.rs | 71 +- .../src/archaic_segments.rs | 30 +- crates/navigator-analysis/src/caller.rs | 49 +- crates/navigator-analysis/src/callset.rs | 10 +- crates/navigator-analysis/src/cancel.rs | 5 +- crates/navigator-analysis/src/coverage.rs | 11 +- crates/navigator-analysis/src/gvcf.rs | 30 +- crates/navigator-analysis/src/haplo.rs | 61 +- crates/navigator-analysis/src/ibd_panel.rs | 17 +- crates/navigator-analysis/src/index.rs | 10 +- crates/navigator-analysis/src/lai.rs | 23 +- .../navigator-analysis/src/library_stats.rs | 27 +- crates/navigator-analysis/src/mask.rs | 9 +- crates/navigator-analysis/src/mastervar.rs | 22 +- crates/navigator-analysis/src/phasing.rs | 20 +- crates/navigator-analysis/src/preflight.rs | 5 +- crates/navigator-analysis/src/reader.rs | 35 +- crates/navigator-analysis/src/reassembly.rs | 22 +- crates/navigator-analysis/src/roh.rs | 8 +- crates/navigator-analysis/src/scan.rs | 10 +- crates/navigator-analysis/src/sex.rs | 19 +- crates/navigator-analysis/src/sv/walker.rs | 7 +- crates/navigator-analysis/src/unified.rs | 20 +- .../navigator-analysis/tests/cancel_real.rs | 5 +- .../tests/mastervar_real.rs | 8 +- .../navigator-analysis/tests/parity_real.rs | 6 +- crates/navigator-analysis/tests/sv.rs | 7 +- crates/navigator-app/src/analysis.rs | 15 +- crates/navigator-app/src/brief.rs | 11 +- crates/navigator-app/src/commands.rs | 9 +- crates/navigator-app/src/export.rs | 15 +- crates/navigator-app/src/fastpath.rs | 15 +- crates/navigator-app/src/ftdna_import.rs | 40 +- crates/navigator-app/src/haplogroup.rs | 429 ++++--- crates/navigator-app/src/ibd_exchange.rs | 11 +- crates/navigator-app/src/import_profiles.rs | 9 +- crates/navigator-app/src/import_unified.rs | 124 +- crates/navigator-app/src/lib.rs | 143 ++- crates/navigator-app/src/publish.rs | 16 +- crates/navigator-app/src/queries.rs | 23 +- crates/navigator-app/src/sync.rs | 11 +- crates/navigator-app/tests/app.rs | 105 +- .../tests/mastervar_autosomal_real.rs | 10 +- crates/navigator-domain/src/brief.rs | 30 +- crates/navigator-domain/src/consensus.rs | 25 +- crates/navigator-domain/src/filetype.rs | 10 +- crates/navigator-domain/src/ftdna_csv.rs | 12 +- crates/navigator-domain/src/i18n.rs | 6 +- crates/navigator-domain/src/identity.rs | 14 +- crates/navigator-domain/src/lib.rs | 2 +- crates/navigator-domain/src/llm_prompt.rs | 15 +- crates/navigator-domain/src/paths.rs | 6 +- crates/navigator-domain/src/reconciliation.rs | 17 +- .../navigator-domain/src/results_context.rs | 37 +- crates/navigator-domain/src/testtype.rs | 21 +- crates/navigator-domain/src/ysnp_dict.rs | 22 +- .../examples/ascertain_chip.rs | 8 +- .../examples/ascertainment.rs | 24 +- .../examples/check_liftover.rs | 35 +- .../examples/filter_maf.rs | 14 +- .../examples/filter_sites.rs | 9 +- .../examples/filter_tv.rs | 13 +- .../examples/genotype_bed.rs | 20 +- .../examples/ibd_qpadm_orient.rs | 33 +- .../examples/inspect_panel.rs | 11 +- .../navigator-panelbuild/examples/overlap.rs | 10 +- .../examples/panel_overlap.rs | 6 +- .../examples/phaploid_fit.rs | 21 +- .../navigator-panelbuild/examples/polarity.rs | 27 +- .../examples/qpadm_check.rs | 44 +- .../examples/qpadm_from_tsv.rs | 40 +- .../examples/qpadm_selftest.rs | 15 +- .../examples/resolve_chip_dosage.rs | 7 +- .../examples/score_modern_from_tsv.rs | 13 +- .../examples/score_superpop_from_tsv.rs | 20 +- .../examples/verify_qpadm_fit.rs | 31 +- crates/navigator-panelbuild/src/archaic.rs | 10 +- .../navigator-panelbuild/src/archaic_dist.rs | 11 +- .../navigator-panelbuild/src/archaic_tierb.rs | 28 +- crates/navigator-panelbuild/src/hap_panel.rs | 22 +- crates/navigator-panelbuild/src/pca.rs | 24 +- crates/navigator-refgenome/src/cache.rs | 13 +- crates/navigator-refgenome/src/gateway.rs | 4 +- crates/navigator-refgenome/src/registry.rs | 13 +- crates/navigator-store/src/artifact.rs | 32 +- .../src/consensus_archaic_segments.rs | 9 +- .../src/external_panel_dosage.rs | 5 +- crates/navigator-store/tests/store.rs | 22 +- crates/navigator-sync/src/oauth.rs | 10 +- crates/navigator-ui/src/charts.rs | 51 +- crates/navigator-ui/src/cli.rs | 85 +- crates/navigator-ui/src/ui/branch.rs | 19 +- crates/navigator-ui/src/ui/central.rs | 29 +- crates/navigator-ui/src/ui/descent.rs | 12 +- crates/navigator-ui/src/ui/detail.rs | 40 +- crates/navigator-ui/src/ui/events.rs | 30 +- crates/navigator-ui/src/ui/mod.rs | 35 +- crates/navigator-ui/src/ui/modals.rs | 706 ++++++----- crates/navigator-ui/src/ui/rowcache.rs | 5 +- crates/navigator-ui/src/worker.rs | 1089 +++++++++-------- 116 files changed, 3193 insertions(+), 1672 deletions(-) diff --git a/crates/navigator-analysis/examples/archaic_callable_dump.rs b/crates/navigator-analysis/examples/archaic_callable_dump.rs index a2fbbc63..7b50bdcb 100644 --- a/crates/navigator-analysis/examples/archaic_callable_dump.rs +++ b/crates/navigator-analysis/examples/archaic_callable_dump.rs @@ -13,7 +13,9 @@ use navigator_analysis::archaic::ArchaicCallable; fn main() -> Result<(), Box> { let mut a = std::env::args().skip(1); - let path = a.next().expect("usage: archaic_callable_dump [min_frac] [contig ...]"); + let path = a + .next() + .expect("usage: archaic_callable_dump [min_frac] [contig ...]"); let min_frac: f64 = a.next().and_then(|s| s.parse().ok()).unwrap_or(0.0); let want: Vec = a.collect(); diff --git a/crates/navigator-analysis/examples/archaic_classify_dump.rs b/crates/navigator-analysis/examples/archaic_classify_dump.rs index 2150622d..3307c2af 100644 --- a/crates/navigator-analysis/examples/archaic_classify_dump.rs +++ b/crates/navigator-analysis/examples/archaic_classify_dump.rs @@ -15,7 +15,9 @@ use navigator_analysis::archaic::ArchaicClassify; fn main() -> Result<(), Box> { let mut a = std::env::args().skip(1); - let path = a.next().expect("usage: archaic_classify_dump [contig ...]"); + let path = a + .next() + .expect("usage: archaic_classify_dump [contig ...]"); let want: Vec = a.collect(); let cls = ArchaicClassify::from_bytes(&std::fs::read(&path)?).map_err(|e| e.to_string())?; diff --git a/crates/navigator-analysis/examples/archaic_match_probe.rs b/crates/navigator-analysis/examples/archaic_match_probe.rs index 45c12138..d8552eab 100644 --- a/crates/navigator-analysis/examples/archaic_match_probe.rs +++ b/crates/navigator-analysis/examples/archaic_match_probe.rs @@ -53,7 +53,11 @@ fn main() -> Result<(), Box> { contig, &classify, pos_map, - |p| seq.get((p - 1).max(0) as usize).copied().map(|b| b.to_ascii_uppercase()), + |p| { + seq.get((p - 1).max(0) as usize) + .copied() + .map(|b| b.to_ascii_uppercase()) + }, &callable, 0.5, ); @@ -61,7 +65,11 @@ fn main() -> Result<(), Box> { eprintln!( "{contig}: {} informative diagnostic sites, {carried} carried ({:.1}%)", obs.len(), - if obs.is_empty() { 0.0 } else { carried as f64 * 100.0 / obs.len() as f64 } + if obs.is_empty() { + 0.0 + } else { + carried as f64 * 100.0 / obs.len() as f64 + } ); lengths.push((contig.clone(), seq.len() as i32)); observations.insert(contig.clone(), obs); diff --git a/crates/navigator-analysis/examples/archaic_outgroup_density.rs b/crates/navigator-analysis/examples/archaic_outgroup_density.rs index 78e356e9..08a26637 100644 --- a/crates/navigator-analysis/examples/archaic_outgroup_density.rs +++ b/crates/navigator-analysis/examples/archaic_outgroup_density.rs @@ -20,7 +20,9 @@ use navigator_analysis::archaic::ArchaicOutgroup; fn main() -> Result<(), Box> { let mut a = std::env::args().skip(1); - let path = a.next().expect("usage: archaic_outgroup_density [window_bp] [contig ...]"); + let path = a + .next() + .expect("usage: archaic_outgroup_density [window_bp] [contig ...]"); let window: i64 = a.next().and_then(|s| s.parse().ok()).unwrap_or(1000); let want: Vec = a.collect(); diff --git a/crates/navigator-analysis/examples/archaic_panel_dump.rs b/crates/navigator-analysis/examples/archaic_panel_dump.rs index c9ea5d47..52805f8c 100644 --- a/crates/navigator-analysis/examples/archaic_panel_dump.rs +++ b/crates/navigator-analysis/examples/archaic_panel_dump.rs @@ -21,7 +21,9 @@ use navigator_analysis::archaic::{ArchaicMarkerPanel, ARCHAIC_GENOMES}; fn main() -> Result<(), Box> { let mut a = std::env::args().skip(1); - let path = a.next().expect("usage: archaic_panel_dump [contig ...]"); + let path = a + .next() + .expect("usage: archaic_panel_dump [contig ...]"); let want: Vec = a.collect(); let panel = ArchaicMarkerPanel::from_bytes(&std::fs::read(&path)?).map_err(|e| e.to_string())?; diff --git a/crates/navigator-analysis/examples/archaic_private_dump.rs b/crates/navigator-analysis/examples/archaic_private_dump.rs index 86c573be..7454bd4e 100644 --- a/crates/navigator-analysis/examples/archaic_private_dump.rs +++ b/crates/navigator-analysis/examples/archaic_private_dump.rs @@ -17,8 +17,12 @@ use navigator_analysis::caller::SiteGenotype; fn main() -> Result<(), Box> { let mut a = std::env::args().skip(1); - let calls_path = a.next().expect("usage: archaic_private_dump "); - let og_path = a.next().expect("usage: archaic_private_dump "); + let calls_path = a + .next() + .expect("usage: archaic_private_dump "); + let og_path = a + .next() + .expect("usage: archaic_private_dump "); let calls: Vec = serde_json::from_str(&std::fs::read_to_string(&calls_path)?)?; let og = ArchaicOutgroup::from_bytes(&std::fs::read(&og_path)?).map_err(|e| e.to_string())?; diff --git a/crates/navigator-analysis/examples/archaic_segments_probe.rs b/crates/navigator-analysis/examples/archaic_segments_probe.rs index 2d8052e3..5fc3b67f 100644 --- a/crates/navigator-analysis/examples/archaic_segments_probe.rs +++ b/crates/navigator-analysis/examples/archaic_segments_probe.rs @@ -15,15 +15,37 @@ fn main() -> Result<(), Box> { let cls = ArchaicClassify::from_bytes(&std::fs::read(a.next().unwrap())?).map_err(|e| e.to_string())?; let cal = ArchaicCallable::from_bytes(&std::fs::read(a.next().unwrap())?).map_err(|e| e.to_string())?; println!("calls {} callable track {:.1} Mb", calls.len(), cal.callable_mb()); - let r = call_archaic_segments(&calls, &og, &cls, &cal, &GeneticMap::from_markers(Vec::new()), &ArchaicConfig::default()); + let r = call_archaic_segments( + &calls, + &og, + &cls, + &cal, + &GeneticMap::from_markers(Vec::new()), + &ArchaicConfig::default(), + ); let s = &r.summary; - println!("segments {} total {:.2} Mb = {:.2}% of {:.1} Mb callable", s.n_segments, s.total_mb, s.pct_callable, s.callable_mb); - println!(" Neanderthal {:.2} Mb Denisovan {:.2} Mb Unknown {:.2} Mb", s.neanderthal_mb, s.denisovan_mb, s.unknown_mb); + println!( + "segments {} total {:.2} Mb = {:.2}% of {:.1} Mb callable", + s.n_segments, s.total_mb, s.pct_callable, s.callable_mb + ); + println!( + " Neanderthal {:.2} Mb Denisovan {:.2} Mb Unknown {:.2} Mb", + s.neanderthal_mb, s.denisovan_mb, s.unknown_mb + ); for seg in r.segments.iter().take(6) { - println!(" {} {}-{} ({:.2} Mb) post {:.2} private {} ({:.0}/Mb) {:?} nea{} den{}", - seg.contig, seg.start, seg.end, seg.length_mb(), seg.posterior, seg.n_private, - seg.n_private as f64 / seg.length_mb().max(1e-9), seg.source, - seg.neanderthal_matches, seg.denisovan_matches); + println!( + " {} {}-{} ({:.2} Mb) post {:.2} private {} ({:.0}/Mb) {:?} nea{} den{}", + seg.contig, + seg.start, + seg.end, + seg.length_mb(), + seg.posterior, + seg.n_private, + seg.n_private as f64 / seg.length_mb().max(1e-9), + seg.source, + seg.neanderthal_matches, + seg.denisovan_matches + ); } Ok(()) } diff --git a/crates/navigator-analysis/examples/cram_query_probe.rs b/crates/navigator-analysis/examples/cram_query_probe.rs index 82ff1818..b6ef6a35 100644 --- a/crates/navigator-analysis/examples/cram_query_probe.rs +++ b/crates/navigator-analysis/examples/cram_query_probe.rs @@ -34,15 +34,24 @@ fn main() { let t = Instant::now(); let n: usize = reader.query(&header, &one(pos)).expect("q1").count(); - println!("first 1bp query ({n:>4} rec) : {:>8.2?} <- includes any lazy setup", t.elapsed()); + println!( + "first 1bp query ({n:>4} rec) : {:>8.2?} <- includes any lazy setup", + t.elapsed() + ); let t = Instant::now(); let n: usize = reader.query(&header, &one(pos)).expect("q2").count(); - println!("same query again ({n:>4} rec): {:>8.2?} <- warm: is the cost per-query or one-off?", t.elapsed()); + println!( + "same query again ({n:>4} rec): {:>8.2?} <- warm: is the cost per-query or one-off?", + t.elapsed() + ); let t = Instant::now(); let n: usize = reader.query(&header, &one(pos + 5_000_000)).expect("q3").count(); - println!("distant 1bp query ({n:>4} rec): {:>8.2?} <- new container: does it re-decode?", t.elapsed()); + println!( + "distant 1bp query ({n:>4} rec): {:>8.2?} <- new container: does it re-decode?", + t.elapsed() + ); let region: Region = format!("{contig}:{pos}-{}", pos + span).parse().expect("region"); let t = Instant::now(); @@ -70,7 +79,11 @@ fn main() { r.sequence().as_ref().to_vec(), ) }; - let mine: Vec<_> = reader.query(&header, ®ion).expect("mine").map(|r| key(&r.expect("rec"))).collect(); + let mine: Vec<_> = reader + .query(&header, ®ion) + .expect("mine") + .map(|r| key(&r.expect("rec"))) + .collect(); let repo = navigator_analysis::reader::build_repository(refp).expect("repo"); let mut oracle = cram::io::indexed_reader::Builder::default() @@ -84,7 +97,10 @@ fn main() { .expect("noodles query") .map(|r| key(&r.expect("rec"))) .collect(); - println!("\nVERIFY: noodles' own Query took {:?} for the same region", t.elapsed()); + println!( + "\nVERIFY: noodles' own Query took {:?} for the same region", + t.elapsed() + ); println!(" ours {} records, noodles {} records", mine.len(), theirs.len()); assert_eq!(mine, theirs, "container skipping changed the records returned"); println!(" IDENTICAL — container skipping is lossless"); diff --git a/crates/navigator-analysis/examples/denovo_profile.rs b/crates/navigator-analysis/examples/denovo_profile.rs index 72ce49ff..7a4931c4 100644 --- a/crates/navigator-analysis/examples/denovo_profile.rs +++ b/crates/navigator-analysis/examples/denovo_profile.rs @@ -27,7 +27,8 @@ fn main() { &contig, ¶ms, &navigator_analysis::CancelToken::none(), - ).expect("call_denovo"); + ) + .expect("call_denovo"); eprintln!( "call_denovo({contig}): {} variants in {:.1}s (realign={})", calls.len(), diff --git a/crates/navigator-analysis/examples/profile_analysis.rs b/crates/navigator-analysis/examples/profile_analysis.rs index 998fa5b4..2d13fdf8 100644 --- a/crates/navigator-analysis/examples/profile_analysis.rs +++ b/crates/navigator-analysis/examples/profile_analysis.rs @@ -36,16 +36,24 @@ fn main() { coverage::estimate_molecule_lengths(bam, Some(reference)).ok() }); timed("coverage SEQUENTIAL whole-genome", || { - coverage::collect_coverage_callable(bam, reference, ¶ms, None).map(|_| ()).err() + coverage::collect_coverage_callable(bam, reference, ¶ms, None) + .map(|_| ()) + .err() }); timed("coverage SEQUENTIAL scoped chrY+chrM", || { - coverage::collect_coverage_callable(bam, reference, ¶ms, Some(&ym)).map(|_| ()).err() + coverage::collect_coverage_callable(bam, reference, ¶ms, Some(&ym)) + .map(|_| ()) + .err() }); timed("coverage PARALLEL whole-genome", || { - unified::collect_unified_metrics_parallel(bam, reference, ¶ms, None).map(|_| ()).err() + unified::collect_unified_metrics_parallel(bam, reference, ¶ms, None) + .map(|_| ()) + .err() }); timed("coverage PARALLEL scoped chrY+chrM", || { - unified::collect_unified_metrics_parallel(bam, reference, ¶ms, Some(&ym)).map(|_| ()).err() + unified::collect_unified_metrics_parallel(bam, reference, ¶ms, Some(&ym)) + .map(|_| ()) + .err() }); // chrY haplogroup genotyping pass: a region query over chrY tallying ~200k target sites @@ -53,6 +61,8 @@ fn main() { let hp = HaploidCallerParams::default(); let targets: HashSet = (1..=200_000u32).map(|i| i as i64 * 300).collect(); timed("chrY genotyping call_bases_at (200k sites)", || { - caller::call_bases_at(bam, "chrY", &targets, &hp, Some(reference)).map(|_| ()).err() + caller::call_bases_at(bam, "chrY", &targets, &hp, Some(reference)) + .map(|_| ()) + .err() }); } diff --git a/crates/navigator-analysis/examples/reassembly_probe.rs b/crates/navigator-analysis/examples/reassembly_probe.rs index 659212aa..643a3119 100644 --- a/crates/navigator-analysis/examples/reassembly_probe.rs +++ b/crates/navigator-analysis/examples/reassembly_probe.rs @@ -26,9 +26,7 @@ use std::path::Path; use bio::alignment::pairwise::{Aligner as PwAligner, Scoring}; use bio::alignment::poa::Aligner as PoaAligner; use bio::alignment::AlignmentOperation; -use bio::stats::pairhmm::{ - EmissionParameters, GapParameters, PairHMM, StartEndGapParameters, XYEmission, -}; +use bio::stats::pairhmm::{EmissionParameters, GapParameters, PairHMM, StartEndGapParameters, XYEmission}; use bio::stats::{LogProb, Prob}; use navigator_analysis::reader::{open_indexed, read_contig_sequence}; use noodles::core::Region; @@ -124,7 +122,12 @@ fn window_reads(cram: &Path, refp: &Path, contig: &str, pos: i64, lo: i64, hi: i } // Keep reads that carry enough window sequence to anchor a realignment. if win.len() >= 30 { - reads.push(WinRead { bases: win, quals: winq, mapq, covers_pos }); + reads.push(WinRead { + bases: win, + quals: winq, + mapq, + covers_pos, + }); } } (reads, pile) @@ -149,11 +152,23 @@ fn consensus_base_at(consensus: &[u8], win_ref: &[u8], win_start: i64, pos: i64) if std::env::var("PROBE_DEBUG").is_ok() { eprintln!( "DEBUG consensus.len={} win_ref.len={} xstart={} ystart={} xend={} yend={} score={}", - consensus.len(), win_ref.len(), aln.xstart, aln.ystart, aln.xend, aln.yend, aln.score + consensus.len(), + win_ref.len(), + aln.xstart, + aln.ystart, + aln.xend, + aln.yend, + aln.score ); eprintln!(" consensus raw[..20]: {:?}", &consensus[..consensus.len().min(20)]); - eprintln!(" consensus str[..40]: {}", String::from_utf8_lossy(&consensus[..consensus.len().min(40)])); - eprintln!(" win_ref str[..40]: {}", String::from_utf8_lossy(&win_ref[..win_ref.len().min(40)])); + eprintln!( + " consensus str[..40]: {}", + String::from_utf8_lossy(&consensus[..consensus.len().min(40)]) + ); + eprintln!( + " win_ref str[..40]: {}", + String::from_utf8_lossy(&win_ref[..win_ref.len().min(40)]) + ); } let mut xi = aln.xstart; // consensus index let mut yi = aln.ystart; // win_ref index (ref coord = win_start + yi) @@ -288,12 +303,13 @@ fn main() { let total: u32 = pile.iter().sum(); // Candidate alt = the most common non-reference base at pos. let ref_i = base_index(ref_base as u8).unwrap_or(0); - let alt_i = (0..4) - .filter(|&i| i != ref_i) - .max_by_key(|&i| pile[i]) - .unwrap_or(ref_i); + let alt_i = (0..4).filter(|&i| i != ref_i).max_by_key(|&i| pile[i]).unwrap_or(ref_i); let alt_base = charb(alt_i); - let alt_frac = if total > 0 { (total - pile[ref_i]) as f64 / total as f64 } else { 0.0 }; + let alt_frac = if total > 0 { + (total - pile[ref_i]) as f64 / total as f64 + } else { + 0.0 + }; // Reference vs alternate haplotype over the window (alt = ref with the SNV at pos). let win_ref: Vec = refseq[(lo - 1) as usize..(hi as usize).min(refseq.len())].to_vec(); diff --git a/crates/navigator-analysis/examples/reassembly_validate.rs b/crates/navigator-analysis/examples/reassembly_validate.rs index 7161a672..59d63cb8 100644 --- a/crates/navigator-analysis/examples/reassembly_validate.rs +++ b/crates/navigator-analysis/examples/reassembly_validate.rs @@ -26,7 +26,10 @@ fn main() { let hi = (pos + 5) as usize; let called = |reassembly: bool| -> Option<(char, char, u32, u32, Option)> { - let params = HaploidCallerParams { reassembly, ..HaploidCallerParams::default() }; + let params = HaploidCallerParams { + reassembly, + ..HaploidCallerParams::default() + }; let calls = call_denovo_region(bam, refp, contig, lo, hi, ¶ms).expect("call_denovo_region"); calls .into_iter() diff --git a/crates/navigator-analysis/examples/site_reads.rs b/crates/navigator-analysis/examples/site_reads.rs index 468c8461..e324257b 100644 --- a/crates/navigator-analysis/examples/site_reads.rs +++ b/crates/navigator-analysis/examples/site_reads.rs @@ -30,7 +30,10 @@ fn main() { let region: Region = format!("{contig}:{lo}-{hi}").parse().expect("region"); println!("site chrY:{pos} ref={ref_base} window ±{win}"); - println!("{:<32} {:>4} {:>4} {:>5} {:>4} {:>3} {:>8}", "qname", "pair", "mapq", "site", "bq", "nm", "flags"); + println!( + "{:<32} {:>4} {:>4} {:>5} {:>4} {:>3} {:>8}", + "qname", "pair", "mapq", "site", "bq", "nm", "flags" + ); let mut base_tally: HashMap = HashMap::new(); for result in reader.query(&header, ®ion).expect("query") { let rec = result.expect("rec"); @@ -38,11 +41,16 @@ fn main() { if f.is_secondary() || f.is_supplementary() || f.is_duplicate() || f.is_unmapped() { continue; } - let Some(start) = rec.alignment_start().map(|p| p.get() as i64) else { continue }; + let Some(start) = rec.alignment_start().map(|p| p.get() as i64) else { + continue; + }; let seq = rec.sequence(); let quals = rec.quality_scores(); let qb = quals.as_ref(); - let name = rec.name().map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default(); + let name = rec + .name() + .map(|n| String::from_utf8_lossy(n).into_owned()) + .unwrap_or_default(); let mapq = rec.mapping_quality().map_or(255, |m| m.get()); // Walk the CIGAR: capture the base at `pos` and count mismatches vs ref (excluding `pos`). @@ -84,9 +92,18 @@ fn main() { continue; // doesn't span the site } *base_tally.entry(site_base).or_default() += 1; - let pair = if f.is_first_segment() { "R1" } else if f.is_last_segment() { "R2" } else { "?" }; + let pair = if f.is_first_segment() { + "R1" + } else if f.is_last_segment() { + "R2" + } else { + "?" + }; let mm: Vec = mm_pos.iter().map(|p| (p - pos).to_string()).collect(); - println!("{name:<38} {pair:>4} {mapq:>4} {site_base:>5} {site_bq:>4} {nm:>3} mm@[{}]", mm.join(",")); + println!( + "{name:<38} {pair:>4} {mapq:>4} {site_base:>5} {site_bq:>4} {nm:>3} mm@[{}]", + mm.join(",") + ); } let mut keys: Vec<_> = base_tally.keys().copied().collect(); keys.sort(); diff --git a/crates/navigator-analysis/examples/validate_coverage.rs b/crates/navigator-analysis/examples/validate_coverage.rs index b8c94366..de7f08d1 100644 --- a/crates/navigator-analysis/examples/validate_coverage.rs +++ b/crates/navigator-analysis/examples/validate_coverage.rs @@ -32,21 +32,20 @@ fn main() { last = done; } }; - let standalone = - match coverage::collect_coverage_callable_with_progress( - bam, - reference, - ¶ms, - None, - &mut progress, - &navigator_analysis::CancelToken::none(), - ) { - Ok(r) => r, - Err(e) => { - eprintln!("standalone coverage error: {e}"); - std::process::exit(1); - } - }; + let standalone = match coverage::collect_coverage_callable_with_progress( + bam, + reference, + ¶ms, + None, + &mut progress, + &navigator_analysis::CancelToken::none(), + ) { + Ok(r) => r, + Err(e) => { + eprintln!("standalone coverage error: {e}"); + std::process::exit(1); + } + }; let standalone_dur = t0.elapsed(); eprintln!("\nstandalone coverage done in {standalone_dur:.1?}"); summarize("standalone", &standalone); @@ -54,21 +53,20 @@ fn main() { // 2. The oracle: trusted per-contig parallel walker on the same file. let t1 = Instant::now(); let progress2 = |_done: usize, _total: usize| {}; - let unified = - match unified::collect_unified_metrics_parallel_with_progress( - bam, - reference, - ¶ms, - None, - &progress2, - &navigator_analysis::CancelToken::none(), - ) { - Ok(r) => r, - Err(e) => { - eprintln!("parallel walker error: {e}"); - std::process::exit(1); - } - }; + let unified = match unified::collect_unified_metrics_parallel_with_progress( + bam, + reference, + ¶ms, + None, + &progress2, + &navigator_analysis::CancelToken::none(), + ) { + Ok(r) => r, + Err(e) => { + eprintln!("parallel walker error: {e}"); + std::process::exit(1); + } + }; let parallel_dur = t1.elapsed(); eprintln!("\nparallel walker done in {parallel_dur:.1?}"); summarize("parallel ", &unified.coverage); diff --git a/crates/navigator-analysis/src/ancestry.rs b/crates/navigator-analysis/src/ancestry.rs index fbd80a94..3f3e33c7 100644 --- a/crates/navigator-analysis/src/ancestry.rs +++ b/crates/navigator-analysis/src/ancestry.rs @@ -183,7 +183,13 @@ impl HaplotypeReference { /// Pack per-haplotype allele rows (`rows[h][s]` = 0/1) into the bit-packed form. `hap_pop[h]` /// is the population index of haplotype `h`. Used by the offline builder and by tests. - pub fn from_rows(build: String, sites: Vec, populations: Vec, hap_pop: Vec, rows: &[Vec]) -> Self { + pub fn from_rows( + build: String, + sites: Vec, + populations: Vec, + hap_pop: Vec, + rows: &[Vec], + ) -> Self { let n_sites = sites.len(); let n_haplotypes = rows.len(); let total_bits = n_sites * n_haplotypes; @@ -658,7 +664,12 @@ pub fn paint_local_ancestry_phased( .sites .iter() .filter(|s| s.freqs.len() == panel.populations.len()) - .map(|s| ((s.contig.as_str(), s.position), per_state_af(&s.freqs, &pop_state, &states))) + .map(|s| { + ( + (s.contig.as_str(), s.position), + per_state_af(&s.freqs, &pop_state, &states), + ) + }) .collect(); let contigs: std::collections::BTreeSet<&str> = phased.sites.iter().map(|s| s.contig.as_str()).collect(); @@ -684,7 +695,14 @@ pub fn paint_local_ancestry_phased( // collapse_copy needs (pos, _, dosage-ish); the AF/allele payload is unused there. let collapse_sites: Vec<(i64, Vec, i32)> = sites.iter().map(|s| (s.0, Vec::new(), s.2 as i32)).collect(); - segments.extend(collapse_copy(contig, &collapse_sites, &path, &states, params.min_segment_sites, side)); + segments.extend(collapse_copy( + contig, + &collapse_sites, + &path, + &states, + params.min_segment_sites, + side, + )); } } segments @@ -1365,7 +1383,13 @@ pub struct F4Estimate { impl F4Estimate { /// Standard error of statistic `i` from the jackknife covariance diagonal. pub fn se(&self, i: usize) -> f64 { - self.cov.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0).max(0.0).sqrt() + self.cov + .get(i) + .and_then(|r| r.get(i)) + .copied() + .unwrap_or(0.0) + .max(0.0) + .sqrt() } } @@ -1393,7 +1417,10 @@ pub fn f4_vector( } // Reject out-of-range population indices up front — a mis-built quartet must not panic mid-scan. let ref_ok = |p: Pop| matches!(p, Pop::Target) || matches!(p, Pop::Ref(i) if i < k); - if !quartets.iter().all(|q| ref_ok(q.a) && ref_ok(q.b) && ref_ok(q.c) && ref_ok(q.d)) { + if !quartets + .iter() + .all(|q| ref_ok(q.a) && ref_ok(q.b) && ref_ok(q.c) && ref_ok(q.d)) + { return None; } @@ -1889,14 +1916,10 @@ mod tests { alternate_allele: 'G', }) .collect(); - let rows: Vec> = (0..3).map(|h| (0..9).map(|s| ((s + h) % 2 == 0) as u8).collect()).collect(); - let full = HaplotypeReference::from_rows( - "t".to_string(), - sites, - vec!["GBR".to_string()], - vec![0, 0, 0], - &rows, - ); + let rows: Vec> = (0..3) + .map(|h| (0..9).map(|s| ((s + h) % 2 == 0) as u8).collect()) + .collect(); + let full = HaplotypeReference::from_rows("t".to_string(), sites, vec!["GBR".to_string()], vec![0, 0, 0], &rows); let thin = full.thin_sites(3); assert_eq!(thin.n_sites, 3); assert_eq!(thin.n_haplotypes, 3); @@ -2215,7 +2238,13 @@ mod tests { let panel = two_pop_panel(n); // Hom-alt (→ A) everywhere except a 15-site hom-ref run (→ B) in the middle. let genos: Vec = (0..n) - .map(|i| sg("chr1", 1 + i as i64 * 1_000_000, if (40..55).contains(&i) { 0 } else { 2 })) + .map(|i| { + sg( + "chr1", + 1 + i as i64 * 1_000_000, + if (40..55).contains(&i) { 0 } else { 2 }, + ) + }) .collect(); let prior = vec![("A".to_string(), 0.99), ("B".to_string(), 0.01)]; @@ -2224,7 +2253,10 @@ mod tests { &genos, &panel, &prior, - &PaintParams { min_ancestry: 0.0, ..PaintParams::default() }, + &PaintParams { + min_ancestry: 0.0, + ..PaintParams::default() + }, ); assert!( ungated.iter().any(|s| s.population_code == "B"), @@ -2355,7 +2387,10 @@ mod tests { struct Lcg(u64); impl Lcg { fn next_f64(&mut self) -> f64 { - self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); (self.0 >> 11) as f64 / (1u64 << 53) as f64 } /// A diploid dosage drawn under HWE at alt-frequency `f`. @@ -2575,9 +2610,15 @@ mod tests { F4_BLOCK_BP, ) .expect("f4 vector"); - assert!(est.values[1].abs() > 0.02, "denominator f4 must be firmly non-degenerate"); + assert!( + est.values[1].abs() > 0.02, + "denominator f4 must be firmly non-degenerate" + ); let recovered = 1.0 - est.values[0] / est.values[1]; - assert!((recovered - alpha).abs() < 1e-4, "f4-ratio recovered α={recovered:.6}, want {alpha}"); + assert!( + (recovered - alpha).abs() < 1e-4, + "f4-ratio recovered α={recovered:.6}, want {alpha}" + ); } /// f4's exact symmetries (pure f64 arithmetic over one fixed site set): swapping either pair @@ -2623,7 +2664,12 @@ mod tests { let cab = 0.5 + 0.3 * (rng.next_f64() - 0.5); // drift shared by A,B let ccd = 0.5 + 0.3 * (rng.next_f64() - 0.5); // drift shared by C,D let tip = |rng: &mut Lcg, c: f64| (c + 0.15 * (rng.next_f64() - 0.5)) as f32; - vec![tip(&mut rng, cab), tip(&mut rng, cab), tip(&mut rng, ccd), tip(&mut rng, ccd)] + vec![ + tip(&mut rng, cab), + tip(&mut rng, cab), + tip(&mut rng, ccd), + tip(&mut rng, ccd), + ] }) .collect(); let (panel, genos) = f4_panel(&["A", "B", "C", "D"], &freqs); @@ -2637,8 +2683,14 @@ mod tests { .expect("f4 vector"); let z_null = est.values[0] / est.se(0); let z_edge = est.values[1] / est.se(1); - assert!(z_null.abs() < 4.0, "symmetric tree: f4(A,B;C,D) must sit near 0, z={z_null:.2}"); - assert!(z_edge.abs() > 8.0, "real internal edge: f4(A,C;B,D) must be many SE from 0, z={z_edge:.2}"); + assert!( + z_null.abs() < 4.0, + "symmetric tree: f4(A,B;C,D) must sit near 0, z={z_null:.2}" + ); + assert!( + z_edge.abs() > 8.0, + "real internal edge: f4(A,C;B,D) must be many SE from 0, z={z_edge:.2}" + ); assert!( est.values[0].abs() * 5.0 < est.values[1].abs(), "the null statistic must be far smaller than the real edge" @@ -2721,10 +2773,22 @@ mod tests { let fit = qpadm_fit(&genos, &panel, &[0, 1, 2], &outgroups, F4_BLOCK_BP).expect("3-source fit"); assert_eq!(fit.dof, 3, "dof = #outgroups − #sources = 6 − 3"); for (i, &want) in truth.iter().enumerate() { - assert!((fit.weights[i] - want).abs() < 0.08, "w{i} = {:.3}, want {want}", fit.weights[i]); + assert!( + (fit.weights[i] - want).abs() < 0.08, + "w{i} = {:.3}, want {want}", + fit.weights[i] + ); } - assert!(fit.weights_feasible(0.02), "weights must be valid proportions: {:?}", fit.weights); - assert!(fit.p_value > 0.01, "well-specified model must not be rejected, p = {:.4}", fit.p_value); + assert!( + fit.weights_feasible(0.02), + "weights must be valid proportions: {:?}", + fit.weights + ); + assert!( + fit.p_value > 0.01, + "well-specified model must not be rejected, p = {:.4}", + fit.p_value + ); // Drop a needed source (S3): the 2-source model can't express the target's cladeC affinity, // so its f4 residual with the cladeC outgroup is large → rejected. @@ -2751,9 +2815,16 @@ mod tests { assert_eq!(r.panel_type, "ancient"); // Recovered within the underlying qpAdm test's tolerance (~8 pts), and correctly ordered. for (code, want) in [("S1", 50.0), ("S2", 30.0), ("S3", 20.0)] { - assert!((pct(&r, code) - want).abs() < 9.0, "{code}: {:.1} vs {want}", pct(&r, code)); + assert!( + (pct(&r, code) - want).abs() < 9.0, + "{code}: {:.1} vs {want}", + pct(&r, code) + ); } - assert!(pct(&r, "S1") > pct(&r, "S2") && pct(&r, "S2") > pct(&r, "S3"), "order preserved"); + assert!( + pct(&r, "S1") > pct(&r, "S2") && pct(&r, "S2") > pct(&r, "S3"), + "order preserved" + ); let p = r.fit_distance.expect("p-value on fit_distance"); assert!((0.0..=1.0).contains(&p), "p={p}"); diff --git a/crates/navigator-analysis/src/archaic.rs b/crates/navigator-analysis/src/archaic.rs index e7153bd5..bb3b1df6 100644 --- a/crates/navigator-analysis/src/archaic.rs +++ b/crates/navigator-analysis/src/archaic.rs @@ -306,10 +306,8 @@ impl ArchaicMarkerResult { /// *base*. Reading dosage directly as "archaic copies" would invert every site where CHM13 /// orientation left the derived allele on REF — 3 % of the panel. pub fn count_archaic_markers(genotypes: &[SiteGenotype], panel: &ArchaicMarkerPanel) -> ArchaicMarkerResult { - let by_pos: std::collections::HashMap<(&str, i64), &SiteGenotype> = genotypes - .iter() - .map(|g| ((g.contig.as_str(), g.position), g)) - .collect(); + let by_pos: std::collections::HashMap<(&str, i64), &SiteGenotype> = + genotypes.iter().map(|g| ((g.contig.as_str(), g.position), g)).collect(); let (mut total, mut nea, mut den, mut shared) = (0u32, 0u32, 0u32, 0u32); let mut called = 0usize; @@ -652,7 +650,13 @@ mod tests { } } - fn site(position: i64, reference_allele: char, alternate_allele: char, derived: char, class: DiagnosticClass) -> ArchaicSite { + fn site( + position: i64, + reference_allele: char, + alternate_allele: char, + derived: char, + class: DiagnosticClass, + ) -> ArchaicSite { ArchaicSite { contig: "chr1".into(), position, @@ -741,11 +745,11 @@ mod tests { site(400, 'A', 'G', 'G', DiagnosticClass::Neanderthal), ], }; + // Panel site 400 is absent from the genotypes entirely — the fourth way a site goes uncalled. let genotypes = vec![ - gt("chr1", 100, "A", "G", 2), // counted - gt("chr1", 200, "A", "G", -1), // explicit no-call - gt("chr1", 300, "C", "T", 2), // alleles disagree with the panel - // 400 absent entirely + gt("chr1", 100, "A", "G", 2), // counted + gt("chr1", 200, "A", "G", -1), // explicit no-call + gt("chr1", 300, "C", "T", 2), // alleles disagree with the panel ]; let r = count_archaic_markers(&genotypes, &panel); assert_eq!(r.called_sites, 1, "only the usable site counts"); @@ -768,7 +772,10 @@ mod tests { let dense: Vec = (0..10_000).map(|i| i * 40).collect(); let ds = PositionStream::encode("chr21", &dense); assert_eq!(ds.iter().collect::>(), dense); - assert!(ds.deltas.len() < dense.len() * 2, "delta encoding should stay ~1 byte/site here"); + assert!( + ds.deltas.len() < dense.len() * 2, + "delta encoding should stay ~1 byte/site here" + ); } #[test] @@ -779,7 +786,10 @@ mod tests { contigs: vec![PositionStream::encode("chr21", &[100, 200, 300, 400])], }; // 200 and 400 are shared with Africans -> stripped; the rest are private. - assert_eq!(og.retain_private("chr21", &[50, 200, 250, 400, 500]), vec![50, 250, 500]); + assert_eq!( + og.retain_private("chr21", &[50, 200, 250, 400, 500]), + vec![50, 250, 500] + ); // Exact-boundary behaviour: first and last outgroup entries. assert_eq!(og.retain_private("chr21", &[100, 400]), Vec::::new()); // A contig with no outgroup data yields NOTHING rather than everything — stripping nothing @@ -823,7 +833,9 @@ mod tests { let called: Vec = (0..1000).collect(); // Expected copies over 1000 sites at f=0.5 is 1000; landing exactly there is the median. - let p = dist.percentile_for_called("HIGH", &called, 1000, "fp").expect("percentile"); + let p = dist + .percentile_for_called("HIGH", &called, 1000, "fp") + .expect("percentile"); assert!((p - 50.0).abs() < 2.0, "expected ~50th percentile, got {p}"); // Well above expectation ranks high, well below ranks low. diff --git a/crates/navigator-analysis/src/archaic_match.rs b/crates/navigator-analysis/src/archaic_match.rs index e3e636d8..77f9c2f2 100644 --- a/crates/navigator-analysis/src/archaic_match.rs +++ b/crates/navigator-analysis/src/archaic_match.rs @@ -291,9 +291,9 @@ pub fn observations_for_contig( if callable.callable_fraction(contig, pos) < min_callable_fraction { continue; } - let carries = calls_by_pos.get(&pos).is_some_and(|g| { - g.dosage > 0 && g.alternate_allele.as_bytes().first() == Some(&derived) - }); + let carries = calls_by_pos + .get(&pos) + .is_some_and(|g| g.dosage > 0 && g.alternate_allele.as_bytes().first() == Some(&derived)); let class = match c.classes.get(i).copied().unwrap_or(2) { 0 => DiagnosticClass::Neanderthal, 1 => DiagnosticClass::Denisovan, @@ -327,7 +327,14 @@ fn ln_sum_exp(a: f64, b: f64) -> f64 { /// /// Log-space forward/backward with recombination-scaled transitions, as in [`crate::roh`]. Exposed /// so the decoding can be tested against hand-computed posteriors without constructing assets. -pub fn posteriors(obs: &[SiteObs], contig: &str, gmap: &GeneticMap, p_bg: f64, p_arch: f64, switches_per_cm: f64) -> Vec { +pub fn posteriors( + obs: &[SiteObs], + contig: &str, + gmap: &GeneticMap, + p_bg: f64, + p_arch: f64, + switches_per_cm: f64, +) -> Vec { let n = obs.len(); if n == 0 { return Vec::new(); @@ -477,7 +484,11 @@ pub fn call_from_observations( let total_mb: f64 = segments.iter().map(|s| s.length_mb()).sum(); let summary = ArchaicSummary { total_mb, - pct_callable: if callable_mb > 0.0 { total_mb * 100.0 / callable_mb } else { 0.0 }, + pct_callable: if callable_mb > 0.0 { + total_mb * 100.0 / callable_mb + } else { + 0.0 + }, callable_mb, neanderthal_mb: 0.0, denisovan_mb: 0.0, @@ -537,7 +548,9 @@ pub fn carried_panel_sites<'a>( calls.iter().map(|c| ((c.contig.as_str(), c.position), c)).collect(); let mut out = BTreeMap::new(); for s in &panel.sites { - let Some((k, g)) = by_pos.get_key_value(&(s.contig.as_str(), s.position)) else { continue }; + let Some((k, g)) = by_pos.get_key_value(&(s.contig.as_str(), s.position)) else { + continue; + }; let carries = g.dosage > 0 && g.alternate_allele.starts_with(s.archaic_derived_allele); out.insert(*k, carries); } @@ -567,19 +580,23 @@ pub fn filter_by_concordance( let kept: Vec = result .segments .into_iter() - .filter(|seg| { - match segment_concordance(panel, &seg.contig, seg.start, seg.end, &carried, min_sites) { + .filter( + |seg| match segment_concordance(panel, &seg.contig, seg.start, seg.end, &carried, min_sites) { Some(c) => c >= min_concordance, None => true, - } - }) + }, + ) .collect(); let total_mb: f64 = kept.iter().map(|s| s.length_mb()).sum(); let callable_mb = result.summary.callable_mb; ArchaicSegmentResult { summary: ArchaicSummary { total_mb, - pct_callable: if callable_mb > 0.0 { total_mb * 100.0 / callable_mb } else { 0.0 }, + pct_callable: if callable_mb > 0.0 { + total_mb * 100.0 / callable_mb + } else { + 0.0 + }, callable_mb, neanderthal_mb: 0.0, denisovan_mb: 0.0, @@ -670,7 +687,11 @@ mod tests { ..Default::default() }, ); - assert!(r.segments.is_empty(), "background should call nothing, got {:?}", r.segments); + assert!( + r.segments.is_empty(), + "background should call nothing, got {:?}", + r.segments + ); } /// Scattered carried sites at the background rate must not accumulate into a tract — the @@ -691,7 +712,11 @@ mod tests { ..Default::default() }, ); - assert!(r.segments.is_empty(), "background-rate carriers formed {:?}", r.segments); + assert!( + r.segments.is_empty(), + "background-rate carriers formed {:?}", + r.segments + ); } /// A site whose derived allele IS the reference base separates nothing, and a no-call there @@ -765,7 +790,10 @@ mod tests { // Altai is derived at all 4 sites; Denisova at only the first. let panel = ArchaicMarkerPanel { build: "chm13v2.0".into(), - thresholds: ArchaicPanelThresholds { max_afr_freq: 0.01, min_non_afr_freq: 0.0005 }, + thresholds: ArchaicPanelThresholds { + max_afr_freq: 0.01, + min_non_afr_freq: 0.0005, + }, sites: vec![ panel_site(1_000, 'A', [D, A, A, D]), panel_site(2_000, 'A', [D, A, A, A]), @@ -793,7 +821,10 @@ mod tests { fn filter_keeps_segments_it_cannot_judge() { let panel = ArchaicMarkerPanel { build: "chm13v2.0".into(), - thresholds: ArchaicPanelThresholds { max_afr_freq: 0.01, min_non_afr_freq: 0.0005 }, + thresholds: ArchaicPanelThresholds { + max_afr_freq: 0.01, + min_non_afr_freq: 0.0005, + }, sites: vec![panel_site(1_000, 'A', [ArchaicCall::HomDerived; 4])], }; let seg = ArchaicSegment { @@ -836,7 +867,10 @@ mod tests { } let panel = ArchaicMarkerPanel { build: "chm13v2.0".into(), - thresholds: ArchaicPanelThresholds { max_afr_freq: 0.01, min_non_afr_freq: 0.0005 }, + thresholds: ArchaicPanelThresholds { + max_afr_freq: 0.01, + min_non_afr_freq: 0.0005, + }, sites, }; // Carries 9/10 in the first span, 1/10 in the second. @@ -868,7 +902,10 @@ mod tests { let out = filter_by_concordance(r, &panel, &calls, 0.7, 3); assert_eq!(out.segments.len(), 1, "the poorly-matching segment should go"); assert_eq!(out.segments[0].start, 900); - assert_eq!(out.summary.n_segments, 1, "the summary must be recomputed, not carried over"); + assert_eq!( + out.summary.n_segments, 1, + "the summary must be recomputed, not carried over" + ); } /// A no-call is hom-reference, i.e. NOT carrying. Conditioning on "has a call" instead is what diff --git a/crates/navigator-analysis/src/archaic_segments.rs b/crates/navigator-analysis/src/archaic_segments.rs index 3093bebf..c09f933e 100644 --- a/crates/navigator-analysis/src/archaic_segments.rs +++ b/crates/navigator-analysis/src/archaic_segments.rs @@ -268,7 +268,17 @@ pub fn call_archaic_segments( continue; } segments.extend(call_contig( - contig, positions, lo, hi, gmap, cfg, background, archaic_rate, classify, callable, &alleles, + contig, + positions, + lo, + hi, + gmap, + cfg, + background, + archaic_rate, + classify, + callable, + &alleles, )); } @@ -582,7 +592,14 @@ mod tests { build: "chm13v2.0".into(), contigs: Vec::new(), }; - let r = call_archaic_segments(&calls, &og, &classify, &callable_all(), &GeneticMap::from_markers(Vec::new()), &ArchaicConfig::default()); + let r = call_archaic_segments( + &calls, + &og, + &classify, + &callable_all(), + &GeneticMap::from_markers(Vec::new()), + &ArchaicConfig::default(), + ); assert_eq!(r.segments.len(), 1, "exactly the dense block should call"); let s = &r.segments[0]; assert!(s.start >= 950_000 && s.start <= 1_050_000, "start {} off", s.start); @@ -610,7 +627,14 @@ mod tests { build: "chm13v2.0".into(), contigs: Vec::new(), }; - let r = call_archaic_segments(&calls, &og, &classify, &callable_all(), &GeneticMap::from_markers(Vec::new()), &ArchaicConfig::default()); + let r = call_archaic_segments( + &calls, + &og, + &classify, + &callable_all(), + &GeneticMap::from_markers(Vec::new()), + &ArchaicConfig::default(), + ); assert!(r.segments.is_empty(), "outgroup-shared density must not call archaic"); } diff --git a/crates/navigator-analysis/src/caller.rs b/crates/navigator-analysis/src/caller.rs index aacd20b9..52ced0c4 100644 --- a/crates/navigator-analysis/src/caller.rs +++ b/crates/navigator-analysis/src/caller.rs @@ -494,14 +494,16 @@ pub fn call_indels_at( // Parse + left-normalize each target's expected allele. proc_lo = 1 (full-contig reference). struct PTarget { - pos: i64, // VCF POS (anchor), 1-based - n_anchor: i64, // normalized CIGAR anchor (= pos+1 canonically) + pos: i64, // VCF POS (anchor), 1-based + n_anchor: i64, // normalized CIGAR anchor (= pos+1 canonically) n_allele: IndelAllele, - span_end: i64, // last ref base the ref-spanning read must cover + span_end: i64, // last ref base the ref-spanning read must cover } let mut ptargets: Vec = Vec::new(); for (pos, anc, der) in targets { - let Some(al) = expected_indel_allele(anc, der) else { continue }; + let Some(al) = expected_indel_allele(anc, der) else { + continue; + }; let (n_anchor, n_allele) = left_normalize(pos + 1, &al, &refbytes, 1); let del_len = match &al { IndelAllele::Del(l) => *l as i64, @@ -534,10 +536,14 @@ pub fn call_indels_at( if !passes(&record, params) { continue; } - let Some(start) = record.alignment_start().map(|p| p.get() as i64) else { continue }; + let Some(start) = record.alignment_start().map(|p| p.get() as i64) else { + continue; + }; let (raw, ref_end) = read_indel_events(&record, start); - let events: Vec<(i64, IndelAllele)> = - raw.into_iter().map(|(a, al)| left_normalize(a, &al, &refbytes, 1)).collect(); + let events: Vec<(i64, IndelAllele)> = raw + .into_iter() + .map(|(a, al)| left_normalize(a, &al, &refbytes, 1)) + .collect(); // Targets whose anchor this read could inform: pos in [start, ref_end]. let lo = positions.partition_point(|&p| p < start); let hi = positions.partition_point(|&p| p <= ref_end); @@ -545,10 +551,7 @@ pub fn call_indels_at( let t = &ptargets[i]; if events.iter().any(|(a, al)| *a == t.n_anchor && *al == t.n_allele) { matched[i] += 1; - } else if start <= t.pos - && ref_end >= t.span_end - && !events.iter().any(|(a, _)| *a == t.n_anchor) - { + } else if start <= t.pos && ref_end >= t.span_end && !events.iter().any(|(a, _)| *a == t.n_anchor) { refspan[i] += 1; } } @@ -1160,7 +1163,12 @@ fn denovo_chunk( /// A reassembly candidate for a paralog-gated position: the top **non-reference** base, kept only if /// it carries at least `min_paralog_minor_reads` reads (a real alternate, not a lone error). -fn active_candidate(pos: i64, counts: &[u32; 4], ref_base: u8, params: &HaploidCallerParams) -> Option { +fn active_candidate( + pos: i64, + counts: &[u32; 4], + ref_base: u8, + params: &HaploidCallerParams, +) -> Option { let ref_bi = base_index(ref_base)?; let (alt_bi, &alt_count) = counts .iter() @@ -1319,7 +1327,13 @@ fn extract_window_reads( } } if wseq.len() >= 30 { - reads.push(reassembly::WindowRead { name, seq: wseq, quals: wq, mapq, site_obs }); + reads.push(reassembly::WindowRead { + name, + seq: wseq, + quals: wq, + mapq, + site_obs, + }); } } Ok(reads) @@ -2167,11 +2181,14 @@ mod tests { assert!(!para([11, 0, 0, 0])); // One discordant read at low depth — a sequencing error, kept. assert!(!para([3, 1, 0, 0])); // second=1 (< 2 reads) - // Scattered errors across other bases, none reaching 2 reads — kept. + + // Scattered errors across other bases, none reaching 2 reads — kept. assert!(!para([18, 1, 1, 0])); // second=1 - // Genuine bi-allelic pileup (7 derived / 4 ancestral) — paralog, dropped. + + // Genuine bi-allelic pileup (7 derived / 4 ancestral) — paralog, dropped. assert!(para([7, 4, 0, 0])); // second=4, 0.36 > 0.20 - // Boundary: 2/10 = 0.20 is not strictly above the threshold — kept. + + // Boundary: 2/10 = 0.20 is not strictly above the threshold — kept. assert!(!para([8, 2, 0, 0])); // 3/10 = 0.30 > 0.20 with 3 reads — dropped. assert!(para([7, 3, 0, 0])); diff --git a/crates/navigator-analysis/src/callset.rs b/crates/navigator-analysis/src/callset.rs index 04906c80..07146117 100644 --- a/crates/navigator-analysis/src/callset.rs +++ b/crates/navigator-analysis/src/callset.rs @@ -55,10 +55,7 @@ fn autosome_contig(chr: &str) -> Option { /// Select the target individual's column index in the `.ind` file (0-based, matching the `.geno` /// character position). `sample` names it; a single-individual file needs no name. fn select_individual(ind_text: &str, sample: Option<&str>) -> Result { - let ids: Vec<&str> = ind_text - .lines() - .filter_map(|l| l.split_whitespace().next()) - .collect(); + let ids: Vec<&str> = ind_text.lines().filter_map(|l| l.split_whitespace().next()).collect(); if ids.is_empty() { return Err(AnalysisError::Message("EIGENSTRAT .ind has no individuals".into())); } @@ -191,10 +188,7 @@ rs3 22 0.0 4000 T C // SAMPLE_B: rs1=0→(G,G); rs2=9→missing; rsX skipped (chr23); rs3=1→(T,C). assert_eq!( cs.calls, - vec![ - ("1".to_string(), 1000, 'G', 'G'), - ("22".to_string(), 4000, 'T', 'C'), - ] + vec![("1".to_string(), 1000, 'G', 'G'), ("22".to_string(), 4000, 'T', 'C'),] ); assert_eq!(cs.missing, 1); assert_eq!(cs.build, "GRCh37"); diff --git a/crates/navigator-analysis/src/cancel.rs b/crates/navigator-analysis/src/cancel.rs index e1f6f24c..11b986ce 100644 --- a/crates/navigator-analysis/src/cancel.rs +++ b/crates/navigator-analysis/src/cancel.rs @@ -116,7 +116,10 @@ mod tests { Ok(()) }); canceller.cancel(); - assert!(handle.join().unwrap().is_err(), "the loop must stop, not run to completion"); + assert!( + handle.join().unwrap().is_err(), + "the loop must stop, not run to completion" + ); } /// Cancellation must be reported as itself, never as a generic failure — the UI branches on diff --git a/crates/navigator-analysis/src/coverage.rs b/crates/navigator-analysis/src/coverage.rs index 9fed0ae6..57cd58eb 100644 --- a/crates/navigator-analysis/src/coverage.rs +++ b/crates/navigator-analysis/src/coverage.rs @@ -24,8 +24,8 @@ use noodles::fasta; use serde::{Deserialize, Serialize}; -use crate::contig; use crate::cancel::CancelToken; +use crate::contig; use crate::error::AnalysisError; use crate::reader; use crate::readview::AlnRead; @@ -399,7 +399,14 @@ pub fn collect_coverage_callable( params: &CallableLociParams, contig_allowlist: Option<&HashSet>, ) -> Result { - collect_coverage_callable_with_progress(bam_path, reference_path, params, contig_allowlist, &mut |_, _| {}, &CancelToken::none()) + collect_coverage_callable_with_progress( + bam_path, + reference_path, + params, + contig_allowlist, + &mut |_, _| {}, + &CancelToken::none(), + ) } /// Like [`collect_coverage_callable`], reporting `progress(contigs_done, contigs_total)` as each diff --git a/crates/navigator-analysis/src/gvcf.rs b/crates/navigator-analysis/src/gvcf.rs index 29873619..5180972c 100644 --- a/crates/navigator-analysis/src/gvcf.rs +++ b/crates/navigator-analysis/src/gvcf.rs @@ -224,7 +224,9 @@ pub fn read_diploid_calls_from( let mut col = l.split('\t'); let chrom = col.next().unwrap_or(""); - let Some(sorted) = targets_by_contig.get(chrom) else { continue }; + let Some(sorted) = targets_by_contig.get(chrom) else { + continue; + }; let pos: i64 = match col.next().and_then(|s| s.parse().ok()) { Some(p) => p, None => continue, @@ -316,11 +318,7 @@ pub struct GvcfSnv { /// which resolves sites a pileup caller can't (misaligned ref reads → false ~50/50), so reading the /// GVCF recovers private SNVs the de-novo pileup caller drops. Ref blocks, hom-ref, and indel records /// are skipped; records are gated on `params.min_dp` / `params.min_gq`. -pub fn read_derived_snvs( - gvcf: &Path, - contig: &str, - params: &GvcfReadParams, -) -> Result, AnalysisError> { +pub fn read_derived_snvs(gvcf: &Path, contig: &str, params: &GvcfReadParams) -> Result, AnalysisError> { let file = std::fs::File::open(gvcf).map_err(|e| AnalysisError::io(gvcf, e))?; read_derived_snvs_from(bgzf::io::Reader::new(file), contig, params) } @@ -396,7 +394,11 @@ pub fn read_derived_snvs_from( alternate: alt_allele.as_bytes()[0].to_ascii_uppercase() as char, depth, alt_depth, - allele_fraction: if depth > 0 { alt_depth as f64 / depth as f64 } else { 0.0 }, + allele_fraction: if depth > 0 { + alt_depth as f64 / depth as f64 + } else { + 0.0 + }, gq, }); } @@ -504,7 +506,16 @@ pub fn read_site_evidence_from( Some((ad_vec[0], ad_vec.get(alt_idx).copied().unwrap_or(0))) }; // A variant record is more specific than any ref block at the same site → override. - out.insert(pos, GvcfSiteEvidence { allele, dp, ad, gq, refblock: false }); + out.insert( + pos, + GvcfSiteEvidence { + allele, + dp, + ad, + gq, + refblock: false, + }, + ); } } Ok(out) @@ -680,7 +691,8 @@ chrM\t100\t.\tC\tT,\t500\t.\tDP=30\tGT:AD:DP:GQ:PL\t1:0,30,0:30:99:510, called.variant_bases.insert(2459921, 'A'); called.callable.insert(2459921); called.callable.insert(2459000); // hom-ref-only → takes the reference base - // The reference base at a hom-ref site can be the *derived* allele (CHM13 = J1 Y). + + // The reference base at a hom-ref site can be the *derived* allele (CHM13 = J1 Y). let ref_base: HashMap = [(2459921, 'G'), (2459000, 'T'), (700, 'C')].into_iter().collect(); let calls = assemble_calls(&called, &ref_base); assert_eq!(calls.get(&2459921), Some(&'A'), "variant (derived) wins over reference"); diff --git a/crates/navigator-analysis/src/haplo.rs b/crates/navigator-analysis/src/haplo.rs index 8329949b..30692716 100644 --- a/crates/navigator-analysis/src/haplo.rs +++ b/crates/navigator-analysis/src/haplo.rs @@ -169,7 +169,10 @@ impl DuVariant { if let Some(p) = self.link_alleles() { return p; } - (coord.ancestral.clone().unwrap_or_default(), coord.derived.clone().unwrap_or_default()) + ( + coord.ancestral.clone().unwrap_or_default(), + coord.derived.clone().unwrap_or_default(), + ) } } @@ -299,7 +302,9 @@ pub fn normalize_polarity(tree: &mut HaploTree, reference: &HashMap) -> bool { if b == d { return true; } - let ambiguous = locus - .ancestral - .chars() - .next() - .is_some_and(|a| strand_ambiguous(a, d)); + let ambiguous = locus.ancestral.chars().next().is_some_and(|a| strand_ambiguous(a, d)); !ambiguous && complement_base(b) == d } @@ -803,7 +804,10 @@ fn node_counts(node: &HaploNode, calls: &HashMap) -> (usize, usize, u /// Public view of a node's `(derived, ancestral, no-call)` defining-SNP tally against `calls` — for /// diagnostics / tracing a placement path. pub fn node_call_counts(tree: &HaploTree, calls: &HashMap, node_id: i64) -> (usize, usize, usize) { - tree.nodes.get(&node_id).map(|n| node_counts(n, calls)).unwrap_or((0, 0, 0)) + tree.nodes + .get(&node_id) + .map(|n| node_counts(n, calls)) + .unwrap_or((0, 0, 0)) } /// Find a node by name for the branch-report tool: matches a **haplogroup name** (e.g. `R-FGC29071`) @@ -1036,10 +1040,12 @@ mod tests { fn descent_by_node_buckets_path_with_state() { let t = parse_ftdna_json(TREE).unwrap(); // Sample is derived at H (A146G) and H2 (A263G); H2a's SNP (C750T) was never called. - let state: HashMap = - [("A146G".to_string(), CallState::Derived), ("A263G".to_string(), CallState::Derived)] - .into_iter() - .collect(); + let state: HashMap = [ + ("A146G".to_string(), CallState::Derived), + ("A263G".to_string(), CallState::Derived), + ] + .into_iter() + .collect(); let grouped = descent_by_node(&t, 4, &state); // terminal H2a // root → H → H2 → H2a, root carries no defining loci. @@ -1321,11 +1327,26 @@ mod tests { name: "CT".into(), is_root: false, loci: vec![ - Locus { position: 100, ancestral: "T".into(), derived: "C".into(), name: "PF1016".into() }, + Locus { + position: 100, + ancestral: "T".into(), + derived: "C".into(), + name: "PF1016".into(), + }, // Already-aligned SNP — must be left untouched. - Locus { position: 200, ancestral: "A".into(), derived: "G".into(), name: "M168".into() }, + Locus { + position: 200, + ancestral: "A".into(), + derived: "G".into(), + name: "M168".into(), + }, // Strand-different alleles (G>A vs C>T) — not a pure swap, left untouched. - Locus { position: 300, ancestral: "G".into(), derived: "A".into(), name: "S3".into() }, + Locus { + position: 300, + ancestral: "G".into(), + derived: "A".into(), + name: "S3".into(), + }, ], children: vec![], }, @@ -1479,7 +1500,15 @@ mod tests { let t = parse_ftdna_json(CONFIDENT_DIVERGENCE_TREE).unwrap(); // Derived at H(146); ancestral at B(500) → carries none of B's derived (d == 0); but matches // all five of Bdeep's SNPs (a homoplasy block → 5 derived below B, past REDEEM_DERIVED). - let c = calls(&[(146, 'G'), (500, 'C'), (900, 'A'), (901, 'A'), (902, 'A'), (903, 'A'), (904, 'A')]); + let c = calls(&[ + (146, 'G'), + (500, 'C'), + (900, 'A'), + (901, 'A'), + (902, 'A'), + (903, 'A'), + (904, 'A'), + ]); // Kulczynski is lured to Bdeep by the five coincidental matches... assert_eq!(score(&t, &c)[0].name, "Bdeep"); // ...but B is a confident divergence (zero derived), never redeemed despite the derived block diff --git a/crates/navigator-analysis/src/ibd_panel.rs b/crates/navigator-analysis/src/ibd_panel.rs index d55ffa9d..20ba3c42 100644 --- a/crates/navigator-analysis/src/ibd_panel.rs +++ b/crates/navigator-analysis/src/ibd_panel.rs @@ -422,12 +422,7 @@ mod tests { } // A panel site with an explicit build-locus contig (e.g. "chr1" for the b38 column vs bare "1"). - fn site_b( - rsid: &str, - chm13: (i64, char, char), - build: (&str, i64, char, char), - which: &str, - ) -> IbdPanelSite { + fn site_b(rsid: &str, chm13: (i64, char, char), build: (&str, i64, char, char), which: &str) -> IbdPanelSite { let locus = Locus { contig: build.0.into(), position: build.1, @@ -490,7 +485,10 @@ mod tests { assert!(!by_pos.contains_key(&300), "palindrome skipped"); // Emitted at CHM13 loci with CHM13 alleles; depth preserved. let swap = out.iter().find(|s| s.position == 200).unwrap(); - assert_eq!((swap.reference_allele.as_str(), swap.alternate_allele.as_str()), ("G", "T")); + assert_eq!( + (swap.reference_allele.as_str(), swap.alternate_allele.as_str()), + ("G", "T") + ); assert_eq!(swap.depth, 20); assert!(out.iter().all(|s| s.contig == "chr1")); } @@ -502,7 +500,10 @@ mod tests { let (panel, _) = IbdPanel::from_sites("chm13v2.0", sites); let out = panel.resolve_alignment("GRCh37", &[geno("rs1", "1", 500, "A", "G", 2)]); assert_eq!(out.len(), 1); - assert_eq!((out[0].position, out[0].dosage, out[0].contig.as_str()), (100, 2, "chr1")); + assert_eq!( + (out[0].position, out[0].dosage, out[0].contig.as_str()), + (100, 2, "chr1") + ); // A no-call (dosage < 0) is dropped. assert!(panel .resolve_alignment("GRCh37", &[geno("rs1", "1", 500, "A", "G", -1)]) diff --git a/crates/navigator-analysis/src/index.rs b/crates/navigator-analysis/src/index.rs index 3a8df232..1ee42f8f 100644 --- a/crates/navigator-analysis/src/index.rs +++ b/crates/navigator-analysis/src/index.rs @@ -187,7 +187,6 @@ fn multi_reference_panic(text: &str) -> bool { text.contains(MULTI_REFERENCE_PANIC) && !text.contains("slice reference sequence name") } - fn is_coordinate_sorted(header: &sam::Header) -> bool { header .header() @@ -197,9 +196,7 @@ fn is_coordinate_sorted(header: &sam::Header) -> bool { } #[allow(clippy::type_complexity)] -fn alignment_context( - record: &bam::Record, -) -> std::io::Result<(Option, Option, Option)> { +fn alignment_context(record: &bam::Record) -> std::io::Result<(Option, Option, Option)> { Ok(( record.reference_sequence_id().transpose()?, record.alignment_start().transpose()?, @@ -228,7 +225,10 @@ mod tests { let known = index_panic_error(path, &"invalid reference sequence name"); let known = known.to_string(); assert!(known.contains("multi-reference slices"), "names the cause: {known}"); - assert!(known.contains("samtools index /data/sample.cram"), "gives the command: {known}"); + assert!( + known.contains("samtools index /data/sample.cram"), + "gives the command: {known}" + ); // An unclassified panic reports its own text rather than borrowing the known diagnosis. let other = index_panic_error(path, &String::from("not yet implemented")).to_string(); diff --git a/crates/navigator-analysis/src/lai.rs b/crates/navigator-analysis/src/lai.rs index 1054b042..42a581a0 100644 --- a/crates/navigator-analysis/src/lai.rs +++ b/crates/navigator-analysis/src/lai.rs @@ -441,7 +441,9 @@ fn smooth_viterbi( bp[i][b] = arg; } } - let mut last = (0..n_labels).max_by(|&a, &b| v[n - 1][a].total_cmp(&v[n - 1][b])).unwrap_or(0); + let mut last = (0..n_labels) + .max_by(|&a, &b| v[n - 1][a].total_cmp(&v[n - 1][b])) + .unwrap_or(0); let mut path = vec![0usize; n]; path[n - 1] = last; for i in (1..n).rev() { @@ -496,8 +498,13 @@ fn collapse_labels( .map(|(l, lo, hi)| { let code = labels[l].as_str(); let super_pop = population_super(code).unwrap_or(code); - let fine = if super_pop != code { Some(code.to_string()) } else { None }; - let mean_post = (lo..=hi).map(|i| post[i].get(l).copied().unwrap_or(0.0)).sum::() / (hi - lo + 1) as f64; + let fine = if super_pop != code { + Some(code.to_string()) + } else { + None + }; + let mean_post = + (lo..=hi).map(|i| post[i].get(l).copied().unwrap_or(0.0)).sum::() / (hi - lo + 1) as f64; AncestrySegment { contig: contig.to_string(), start: positions[lo], @@ -648,7 +655,10 @@ mod tests { ); let map = GeneticMap::uniform(1.0, &[("chr1", 250_000_000)]); let phased = phased_side(&pat); - let params = CopyingLaiParams { min_segment_cm: 5.0, ..CopyingLaiParams::default() }; + let params = CopyingLaiParams { + min_segment_cm: 5.0, + ..CopyingLaiParams::default() + }; // Empty prior → gate disabled (keep all haplotypes), so the folding path is exercised. let segs = paint_copying_lai(&phased, &reference, &map, &[], ¶ms); // The folded tiny pop must never surface as a fine call. @@ -799,7 +809,10 @@ mod tests { let map = GeneticMap::uniform(1.0, &[("chr1", 700_000_000)]); let prior = vec![("EUR".to_string(), 1.0)]; let paint = |params: &CopyingLaiParams| { - call_shares(&paint_copying_lai(&phased, &reference, &map, &prior, params), &positions) + call_shares( + &paint_copying_lai(&phased, &reference, &map, &prior, params), + &positions, + ) }; let previous = CopyingLaiParams { recomb_per_cm: 0.1, diff --git a/crates/navigator-analysis/src/library_stats.rs b/crates/navigator-analysis/src/library_stats.rs index a0008b6b..7fcc1bb1 100644 --- a/crates/navigator-analysis/src/library_stats.rs +++ b/crates/navigator-analysis/src/library_stats.rs @@ -153,8 +153,16 @@ pub fn scan_library_stats( /// read platforms are `SHORT`. `None` when the platform is unknown (contributes no vote). fn detect_read_type_from_qname(qname: &str, platform: &str) -> Option<&'static str> { match platform { - "PacBio" => Some(if qname.rsplit('/').next() == Some("ccs") { "HIFI" } else { "CLR" }), - "Nanopore" => Some(if qname.contains(';') { "ONT_DUPLEX" } else { "ONT_SIMPLEX" }), + "PacBio" => Some(if qname.rsplit('/').next() == Some("ccs") { + "HIFI" + } else { + "CLR" + }), + "Nanopore" => Some(if qname.contains(';') { + "ONT_DUPLEX" + } else { + "ONT_SIMPLEX" + }), "Illumina" | "MGI" => Some("SHORT"), _ => None, } @@ -387,12 +395,21 @@ mod tests { Some("ONT_SIMPLEX") ); assert_eq!( - detect_read_type_from_qname("abcdef01-2345-6789-abcd-ef0123456789;01234567-89ab-cdef-0123-456789abcdef", "Nanopore"), + detect_read_type_from_qname( + "abcdef01-2345-6789-abcd-ef0123456789;01234567-89ab-cdef-0123-456789abcdef", + "Nanopore" + ), Some("ONT_DUPLEX") ); // Short-read platforms. - assert_eq!(detect_read_type_from_qname("A00123:45:H7TJ2DSXX:1:1101:1000:1996", "Illumina"), Some("SHORT")); - assert_eq!(detect_read_type_from_qname("V300012345L1C001R0010000123", "MGI"), Some("SHORT")); + assert_eq!( + detect_read_type_from_qname("A00123:45:H7TJ2DSXX:1:1101:1000:1996", "Illumina"), + Some("SHORT") + ); + assert_eq!( + detect_read_type_from_qname("V300012345L1C001R0010000123", "MGI"), + Some("SHORT") + ); // Unknown platform ⇒ no vote. assert_eq!(detect_read_type_from_qname("totally random name", "Unknown"), None); } diff --git a/crates/navigator-analysis/src/mask.rs b/crates/navigator-analysis/src/mask.rs index dce0e825..f2eae544 100644 --- a/crates/navigator-analysis/src/mask.rs +++ b/crates/navigator-analysis/src/mask.rs @@ -224,7 +224,8 @@ mod tests { // [10,20) and [15,25) coalesce to [10,25); [40,50) separate. let m = RegionMask::from_intervals(vec![(40, 50), (10, 20), (15, 25)]); assert_eq!(m.covered(), 15 + 10); // [10,25)=15, [40,50)=10 - // 1-based positions: base0 = pos-1. + + // 1-based positions: base0 = pos-1. assert!(!m.contains(10)); // base0 9 < 10 assert!(m.contains(11)); // base0 10 in [10,25) assert!(m.contains(25)); // base0 24 in [10,25) @@ -240,10 +241,8 @@ mod tests { let dir = std::env::temp_dir().join(format!("dun-maskgz-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("m.bed.gz"); - let mut enc = flate2::write::GzEncoder::new( - std::fs::File::create(&path).unwrap(), - flate2::Compression::default(), - ); + let mut enc = + flate2::write::GzEncoder::new(std::fs::File::create(&path).unwrap(), flate2::Compression::default()); // chrX ignored; two chrY intervals, one of them coalescing. enc.write_all(b"chrY\t100\t200\nchrX\t0\t50\nchrY\t150\t260\n").unwrap(); enc.finish().unwrap(); diff --git a/crates/navigator-analysis/src/mastervar.rs b/crates/navigator-analysis/src/mastervar.rs index 64afd864..1f987c0a 100644 --- a/crates/navigator-analysis/src/mastervar.rs +++ b/crates/navigator-analysis/src/mastervar.rs @@ -189,7 +189,10 @@ fn locus_call(rows: &[Row]) -> Option { } // Diploid: reconstruct the two haplotypes. An `all` snp row means both alleles carry it. - if let Some(all) = rows.iter().find(|r| r.allele == Allele::All && r.var_type == VarType::Snp) { + if let Some(all) = rows + .iter() + .find(|r| r.allele == Allele::All && r.var_type == VarType::Snp) + { return snp_call(contig, position, reference, &all.allele_seq, rs_id, Some("1/1".into())); } let hap = |which: Allele| -> Hap { @@ -277,9 +280,10 @@ pub fn parse_reader(reader: impl BufRead) -> Result') { - columns = Some(Columns::from_header(&line).ok_or_else(|| { - MasterVarError::Format("column header is missing required masterVar fields".into()) - })?); + columns = + Some(Columns::from_header(&line).ok_or_else(|| { + MasterVarError::Format("column header is missing required masterVar fields".into()) + })?); continue; } let Some(c) = columns.as_ref() else { @@ -403,7 +407,10 @@ mod tests { ); assert_eq!(out.calls.len(), 1); let c = &out.calls[0]; - assert_eq!((c.position, c.reference.as_str(), c.alternate.as_str()), (9006, "T", "C")); + assert_eq!( + (c.position, c.reference.as_str(), c.alternate.as_str()), + (9006, "T", "C") + ); assert_eq!(c.genotype.as_deref(), Some("0/1")); } @@ -429,7 +436,10 @@ mod tests { #[test] fn first_rs_id_extracts_first_accession() { - assert_eq!(first_rs_id("dbsnp.100:rs2748067;dbsnp.131:rs76046194").as_deref(), Some("rs2748067")); + assert_eq!( + first_rs_id("dbsnp.100:rs2748067;dbsnp.131:rs76046194").as_deref(), + Some("rs2748067") + ); assert_eq!(first_rs_id("").as_deref(), None); assert_eq!(first_rs_id("cosmic:COSM123").as_deref(), None); } diff --git a/crates/navigator-analysis/src/phasing.rs b/crates/navigator-analysis/src/phasing.rs index 2d0c9c68..0df80f1a 100644 --- a/crates/navigator-analysis/src/phasing.rs +++ b/crates/navigator-analysis/src/phasing.rs @@ -219,13 +219,14 @@ impl<'a> ReferencePhaser<'a> { // Collect candidate successor states, keyed by (x,y), keeping the best incoming lp. let mut next: HashMap<(u32, u32), (f64, u32)> = HashMap::new(); - let consider = |x: u32, y: u32, base_lp: f64, trans_ln: f64, bp: u32, next: &mut HashMap<(u32, u32), (f64, u32)>| { - let lp = base_lp + trans_ln + self.emit_ln(g, allele(col, x as usize), allele(col, y as usize)); - let e = next.entry((x, y)).or_insert((f64::NEG_INFINITY, 0)); - if lp > e.0 { - *e = (lp, bp); - } - }; + let consider = + |x: u32, y: u32, base_lp: f64, trans_ln: f64, bp: u32, next: &mut HashMap<(u32, u32), (f64, u32)>| { + let lp = base_lp + trans_ln + self.emit_ln(g, allele(col, x as usize), allele(col, y as usize)); + let e = next.entry((x, y)).or_insert((f64::NEG_INFINITY, 0)); + if lp > e.0 { + *e = (lp, bp); + } + }; for (bi, s) in prev.iter().enumerate() { let bp = bi as u32; @@ -249,10 +250,7 @@ impl<'a> ReferencePhaser<'a> { } } - let mut beam: Vec = next - .into_iter() - .map(|((x, y), (lp, bp))| Bs { x, y, lp, bp }) - .collect(); + let mut beam: Vec = next.into_iter().map(|((x, y), (lp, bp))| Bs { x, y, lp, bp }).collect(); prune_beam(&mut beam, self.params.beam); trellis.push(beam); } diff --git a/crates/navigator-analysis/src/preflight.rs b/crates/navigator-analysis/src/preflight.rs index 6ba59bae..e2f77135 100644 --- a/crates/navigator-analysis/src/preflight.rs +++ b/crates/navigator-analysis/src/preflight.rs @@ -613,6 +613,9 @@ mod tests { // which path component is absent, so key on the message rather than a Unix errno. assert!(first.detail.starts_with("not found"), "{}", first.detail); // The reference check must not run — the report stops at the first real blocker. - assert!(!report.checks.iter().any(|c| c.id == CheckId::ReferenceFasta), "{report}"); + assert!( + !report.checks.iter().any(|c| c.id == CheckId::ReferenceFasta), + "{report}" + ); } } diff --git a/crates/navigator-analysis/src/reader.rs b/crates/navigator-analysis/src/reader.rs index f8a85686..4d9eeefa 100644 --- a/crates/navigator-analysis/src/reader.rs +++ b/crates/navigator-analysis/src/reader.rs @@ -312,13 +312,16 @@ impl IdxReader { let path = path.clone(); let repo = repo.clone(); - let ref_id = header.reference_sequences().get_index_of(region.name()).ok_or_else(|| { - AnalysisError::Message(format!( - "contig {} not in {} header", - String::from_utf8_lossy(region.name()), - path.display() - )) - })?; + let ref_id = header + .reference_sequences() + .get_index_of(region.name()) + .ok_or_else(|| { + AnalysisError::Message(format!( + "contig {} not in {} header", + String::from_utf8_lossy(region.name()), + path.display() + )) + })?; let interval = region.interval(); let mut offsets = cram_container_offsets(inner.index(), ref_id, interval).into_iter(); @@ -364,8 +367,7 @@ impl IdxReader { for rec in &records { // Same per-record overlap test noodles applies post-decode — the // container filter is a coarse prefilter, not a replacement for it. - if let (Some(Ok(start)), Some(Ok(end))) = (rec.alignment_start(), rec.alignment_end()) - { + if let (Some(Ok(start)), Some(Ok(end))) = (rec.alignment_start(), rec.alignment_end()) { if !interval.intersects((start..=end).into()) { continue; } @@ -734,8 +736,14 @@ mod tests { for region in [ Region::new(b"chrM".to_vec(), ..), - Region::new(b"chrM".to_vec(), Position::new(1).unwrap()..=Position::new(200).unwrap()), - Region::new(b"chrM".to_vec(), Position::new(50).unwrap()..=Position::new(60).unwrap()), + Region::new( + b"chrM".to_vec(), + Position::new(1).unwrap()..=Position::new(200).unwrap(), + ), + Region::new( + b"chrM".to_vec(), + Position::new(50).unwrap()..=Position::new(60).unwrap(), + ), ] { let (header, mut ours) = open_indexed(&cram, Some(&reference)).expect("open"); let mine: Vec = ours @@ -757,7 +765,10 @@ mod tests { .map(|r| capture(&r.expect("rec"))) .collect(); - assert_eq!(mine, reference_impl, "region {region:?}: must match noodles' Query exactly"); + assert_eq!( + mine, reference_impl, + "region {region:?}: must match noodles' Query exactly" + ); } } } diff --git a/crates/navigator-analysis/src/reassembly.rs b/crates/navigator-analysis/src/reassembly.rs index 01bc213c..525b559a 100644 --- a/crates/navigator-analysis/src/reassembly.rs +++ b/crates/navigator-analysis/src/reassembly.rs @@ -199,7 +199,11 @@ fn genotype_candidate( } } - let allele_fraction = if depth > 0 { alt_depth as f64 / depth as f64 } else { 0.0 }; + let allele_fraction = if depth > 0 { + alt_depth as f64 / depth as f64 + } else { + 0.0 + }; let genotype = if log_odds > params.min_log_odds && alt_depth >= params.min_alt_fragments { Zygosity::Derived } else if log_odds < -params.min_log_odds { @@ -468,7 +472,9 @@ mod tests { } fn call_with(reads: &[WindowRead], params: &ReassemblyParams) -> ReassemblyCall { - genotype_window(REF, WIN_START, &[candidate()], reads, params).pop().unwrap() + genotype_window(REF, WIN_START, &[candidate()], reads, params) + .pop() + .unwrap() } #[test] @@ -539,11 +545,19 @@ mod tests { // are clean. Against a reference+single-SNV alt haplotype (v1) the linked variants penalise // the true reads; the POA-assembled haplotype (v2) lets them match cleanly, so the call is // both DERIVED and more confident than v1. - let mut reads: Vec<_> = (0..10).map(|i| read_muts(&format!("alt{i}"), b'T', LINKED, 35, 60)).collect(); + let mut reads: Vec<_> = (0..10) + .map(|i| read_muts(&format!("alt{i}"), b'T', LINKED, 35, 60)) + .collect(); reads.extend((0..4).map(|i| read(&format!("ref{i}"), b'A', 35, 60))); let v1 = call_with(&reads, &ReassemblyParams::default()); // assemble_alt: false (ref+SNV) - let v2 = call_with(&reads, &ReassemblyParams { assemble_alt: true, ..Default::default() }); + let v2 = call_with( + &reads, + &ReassemblyParams { + assemble_alt: true, + ..Default::default() + }, + ); assert_eq!(v1.genotype, Zygosity::Derived); assert_eq!(v2.genotype, Zygosity::Derived); assert!( diff --git a/crates/navigator-analysis/src/roh.rs b/crates/navigator-analysis/src/roh.rs index b8babd29..c8e36b2f 100644 --- a/crates/navigator-analysis/src/roh.rs +++ b/crates/navigator-analysis/src/roh.rs @@ -201,7 +201,13 @@ fn span_cm(gmap: &GeneticMap, chr: &str, start_bp: i64, end_bp: i64) -> f64 { /// Log-space 2-state HMM (0 = Normal, 1 = Autozygous) over one chromosome's sorted sites; returns /// the stitched Autozygous runs (unfiltered). -fn call_chromosome(chr: &str, sites: &[(i64, bool)], gmap: &GeneticMap, cfg: &RohConfig, baseline: f64) -> Vec { +fn call_chromosome( + chr: &str, + sites: &[(i64, bool)], + gmap: &GeneticMap, + cfg: &RohConfig, + baseline: f64, +) -> Vec { let n = sites.len(); let ln = |x: f64| x.max(1e-300).ln(); diff --git a/crates/navigator-analysis/src/scan.rs b/crates/navigator-analysis/src/scan.rs index 867ac499..c5f929e2 100644 --- a/crates/navigator-analysis/src/scan.rs +++ b/crates/navigator-analysis/src/scan.rs @@ -446,9 +446,15 @@ mod tests { let sample = scan_sample(&dir); let sc = &sample.sidecars; - assert!(sc.has_haplogroup_gvcf(), "bare chrY.g.vcf.gz must be detected as the Y GVCF"); + assert!( + sc.has_haplogroup_gvcf(), + "bare chrY.g.vcf.gz must be detected as the Y GVCF" + ); assert!(sc.chr_y_gvcf.as_ref().unwrap().ends_with("chrY.g.vcf.gz")); - assert!(sc.callable_bed.as_ref().is_some_and(|p| p.ends_with("callable_status.bed"))); + assert!(sc + .callable_bed + .as_ref() + .is_some_and(|p| p.ends_with("callable_status.bed"))); assert!(sc.coverage.is_some() && sc.stats.is_some()); assert_eq!(sample.alignment_files.len(), 1, "the chrYM.cram"); diff --git a/crates/navigator-analysis/src/sex.rs b/crates/navigator-analysis/src/sex.rs index b911f59a..6a7e7f2c 100644 --- a/crates/navigator-analysis/src/sex.rs +++ b/crates/navigator-analysis/src/sex.rs @@ -304,7 +304,12 @@ mod tests { #[test] fn y_scoped_detects_y_only_extracts() { // chrY in the millions, autosomes only a few dozen mismapped reads, no chrX → Y-scoped. - assert!(is_y_scoped([("chrY", 3_000_000), ("chr1", 30), ("chr2", 24), ("chr7", 12)])); + assert!(is_y_scoped([ + ("chrY", 3_000_000), + ("chr1", 30), + ("chr2", 24), + ("chr7", 12) + ])); // A pure chrY-only alignment (nothing elsewhere) → Y-scoped. assert!(is_y_scoped([("chrY", 2_000_000)])); // chrY + chrM only (the chrYM.cram shape) → Y-scoped (chrM is neither autosome nor chrX). @@ -314,9 +319,17 @@ mod tests { #[test] fn y_scoped_rejects_wgs_and_females() { // Male WGS: autosomes dwarf chrY → not Y-scoped (the ratio walk handles these). - assert!(!is_y_scoped([("chr1", 200_000_000), ("chrX", 5_000_000), ("chrY", 3_000_000)])); + assert!(!is_y_scoped([ + ("chr1", 200_000_000), + ("chrX", 5_000_000), + ("chrY", 3_000_000) + ])); // Female WGS: chrY only a trace of mismapping → not Y-scoped. - assert!(!is_y_scoped([("chr1", 200_000_000), ("chrX", 10_000_000), ("chrY", 300)])); + assert!(!is_y_scoped([ + ("chr1", 200_000_000), + ("chrX", 10_000_000), + ("chrY", 300) + ])); // Near-empty alignment: a handful of chrY reads is not enough to judge. assert!(!is_y_scoped([("chrY", 50)])); } diff --git a/crates/navigator-analysis/src/sv/walker.rs b/crates/navigator-analysis/src/sv/walker.rs index 492f4c1c..34390ed8 100644 --- a/crates/navigator-analysis/src/sv/walker.rs +++ b/crates/navigator-analysis/src/sv/walker.rs @@ -189,12 +189,7 @@ fn is_expected_orientation(record: &impl AlnRead, pos1: i64, mate_pos: i64) -> b /// Parse the first SA-tag alignment into a [`SplitRead`]; clip length is the read's own /// soft/hard-clip total. -fn extract_split_read( - record: &impl AlnRead, - contig: &str, - mapq: u8, - config: &SvCallerConfig, -) -> Option { +fn extract_split_read(record: &impl AlnRead, contig: &str, mapq: u8, config: &SvCallerConfig) -> Option { let sa = record.string_tag(SA_TAG)?; if sa.is_empty() { return None; diff --git a/crates/navigator-analysis/src/unified.rs b/crates/navigator-analysis/src/unified.rs index 65c9ae47..8f9d9c39 100644 --- a/crates/navigator-analysis/src/unified.rs +++ b/crates/navigator-analysis/src/unified.rs @@ -26,12 +26,12 @@ use noodles::core::Region; use rayon::prelude::*; use serde::{Deserialize, Serialize}; +use crate::cancel::CancelToken; use crate::contig; use crate::coverage::{ merge_coverage_partials, CallableLociParams, ContigCoverageAccum, ContigCoveragePartial, CoverageResult, CoverageState, }; -use crate::cancel::CancelToken; use crate::error::AnalysisError; use crate::read_metrics::{ReadMetrics, ReadMetricsState}; use crate::reader::{self, RecordSink}; @@ -141,7 +141,14 @@ pub fn collect_unified_metrics( params: &CallableLociParams, contig_allowlist: Option<&HashSet>, ) -> Result { - collect_unified_metrics_with_progress(bam_path, reference_path, params, contig_allowlist, &mut |_, _| {}, &CancelToken::none()) + collect_unified_metrics_with_progress( + bam_path, + reference_path, + params, + contig_allowlist, + &mut |_, _| {}, + &CancelToken::none(), + ) } /// Like [`collect_unified_metrics`], reporting `progress(contigs_done, contigs_total)` as the @@ -208,7 +215,14 @@ pub fn collect_unified_metrics_parallel( params: &CallableLociParams, contig_allowlist: Option<&HashSet>, ) -> Result { - collect_unified_metrics_parallel_with_progress(bam_path, reference_path, params, contig_allowlist, &|_, _| {}, &CancelToken::none()) + collect_unified_metrics_parallel_with_progress( + bam_path, + reference_path, + params, + contig_allowlist, + &|_, _| {}, + &CancelToken::none(), + ) } /// Worker threads for the per-contig fan-out. Defaults to all available cores capped at 12 — diff --git a/crates/navigator-analysis/tests/cancel_real.rs b/crates/navigator-analysis/tests/cancel_real.rs index 54d9c46a..a8bb1952 100644 --- a/crates/navigator-analysis/tests/cancel_real.rs +++ b/crates/navigator-analysis/tests/cancel_real.rs @@ -44,7 +44,10 @@ fn cancelling_a_whole_genome_walk_returns_promptly() { let elapsed = started.elapsed(); eprintln!("returned after {elapsed:.1?}: {result:?}"); - assert!(result.is_err(), "a cancelled walk must not return a partial result as success"); + assert!( + result.is_err(), + "a cancelled walk must not return a partial result as success" + ); assert!( matches!(result, Err(navigator_analysis::AnalysisError::Cancelled)), "must report cancellation, not a generic failure" diff --git a/crates/navigator-analysis/tests/mastervar_real.rs b/crates/navigator-analysis/tests/mastervar_real.rs index b71868ca..3be191d0 100644 --- a/crates/navigator-analysis/tests/mastervar_real.rs +++ b/crates/navigator-analysis/tests/mastervar_real.rs @@ -43,7 +43,13 @@ fn parse_real_master_var() { // chrY / chrM must be hemizygous (genotype "1") — never diploid. for c in out.calls.iter().filter(|c| c.contig == "chrY" || c.contig == "chrM") { - assert_eq!(c.genotype.as_deref(), Some("1"), "{}:{} should be hemizygous", c.contig, c.position); + assert_eq!( + c.genotype.as_deref(), + Some("1"), + "{}:{} should be hemizygous", + c.contig, + c.position + ); } // Every call is a clean single-base biallelic SNP. for c in &out.calls { diff --git a/crates/navigator-analysis/tests/parity_real.rs b/crates/navigator-analysis/tests/parity_real.rs index f1c73e51..7ee60f79 100644 --- a/crates/navigator-analysis/tests/parity_real.rs +++ b/crates/navigator-analysis/tests/parity_real.rs @@ -78,7 +78,7 @@ fn hg002_chrm_denovo_smoke() { &PathBuf::from(reference), "chrM", &HaploidCallerParams::default(), - &navigator_analysis::CancelToken::none(), + &navigator_analysis::CancelToken::none(), ) .expect("de-novo should succeed on real data"); @@ -115,7 +115,7 @@ fn hg002_chry_denovo_streams() { &PathBuf::from(reference), "chrY", &HaploidCallerParams::default(), - &navigator_analysis::CancelToken::none(), + &navigator_analysis::CancelToken::none(), ) .expect("chrY de-novo should succeed"); eprintln!("chrY de-novo calls: {}", calls.len()); @@ -307,7 +307,7 @@ fn hg002_chrm_gatk_parity() { &PathBuf::from(&reference), "chrM", &HaploidCallerParams::default(), - &navigator_analysis::CancelToken::none(), + &navigator_analysis::CancelToken::none(), ) .expect("de-novo should succeed"); diff --git a/crates/navigator-analysis/tests/sv.rs b/crates/navigator-analysis/tests/sv.rs index 12bd98cb..84e6f8d4 100644 --- a/crates/navigator-analysis/tests/sv.rs +++ b/crates/navigator-analysis/tests/sv.rs @@ -73,7 +73,7 @@ fn walker_reads_cram_with_the_same_result_as_bam() { &config, &navigator_analysis::CancelToken::none(), ) - .expect("BAM walk should succeed"); + .expect("BAM walk should succeed"); let from_cram = walker::collect_evidence( &fixtures().join("sv.cram"), Some(&fixtures().join("svref.fa")), @@ -92,7 +92,10 @@ fn walker_reads_cram_with_the_same_result_as_bam() { // Compare the evidence itself, not just the counts — the split read carries the fields that // come from the accessors CRAM implements differently (name, SA tag, CIGAR clip length). let (b, c) = (&from_bam.split_reads[0], &from_cram.split_reads[0]); - assert_eq!((&c.read_name, c.clip_length, &c.supp_chrom, c.supp_pos), (&b.read_name, b.clip_length, &b.supp_chrom, b.supp_pos)); + assert_eq!( + (&c.read_name, c.clip_length, &c.supp_chrom, c.supp_pos), + (&b.read_name, b.clip_length, &b.supp_chrom, b.supp_pos) + ); let names = |e: &SvEvidenceCollection| { let mut v: Vec<_> = e .discordant_pairs diff --git a/crates/navigator-app/src/analysis.rs b/crates/navigator-app/src/analysis.rs index fc2cfb50..6b4758fc 100644 --- a/crates/navigator-app/src/analysis.rs +++ b/crates/navigator-app/src/analysis.rs @@ -666,7 +666,9 @@ impl App { } let kind = denovo_kind(&contig); let calls = tokio::task::spawn_blocking(move || { - navigator_analysis::guard_walk("de-novo calling", || caller::call_denovo(&bam, &reference, &contig, ¶ms, &cancel)) + navigator_analysis::guard_walk("de-novo calling", || { + caller::call_denovo(&bam, &reference, &contig, ¶ms, &cancel) + }) }) .await??; self.save_analysis(alignment_id, &kind, caller::DENOVO_VERSION, &calls) @@ -858,7 +860,8 @@ impl App { /// alignments (see [`consensus_diploid_calls`]), sample column `consensus`. Heavy; the export /// path runs it off the UI thread. pub async fn consensus_diploid_vcf(&self, biosample_guid: SampleGuid) -> Result { - let calls = self.consensus_diploid_calls(biosample_guid, None, CancelToken::none()) + let calls = self + .consensus_diploid_calls(biosample_guid, None, CancelToken::none()) .await?; Ok(navigator_analysis::vcf::write_diploid_vcf("consensus", &calls)) } @@ -983,10 +986,10 @@ impl App { Some(p) => Some(PathBuf::from(p)), None => self.gateway.cached_reference(&aln.reference_build), }; - Ok(tokio::task::spawn_blocking(move || { - navigator_analysis::preflight::diagnose(&bam, reference.as_deref()) - }) - .await?) + Ok( + tokio::task::spawn_blocking(move || navigator_analysis::preflight::diagnose(&bam, reference.as_deref())) + .await?, + ) } pub async fn run_denovo_for_alignment( diff --git a/crates/navigator-app/src/brief.rs b/crates/navigator-app/src/brief.rs index 192ef7a7..7bee310e 100644 --- a/crates/navigator-app/src/brief.rs +++ b/crates/navigator-app/src/brief.rs @@ -568,10 +568,7 @@ fn fallback_test_text(lang: Lang, target: TargetType) -> (String, Option TargetType::Autosomal | TargetType::Mixed => ("brief.testAutosomal", None), TargetType::XChromosome => ("brief.testX", Some("brief.testXLimits")), }; - ( - tr(lang, what).to_string(), - limits.map(|k| tr(lang, k).to_string()), - ) + (tr(lang, what).to_string(), limits.map(|k| tr(lang, k).to_string())) } /// The one-line "who you are" headline summary. @@ -582,11 +579,7 @@ fn headline_summary( maternal: Option<&LineageBrief>, ) -> String { match (paternal, maternal) { - (Some(p), Some(m)) => tr_fmt( - lang, - "brief.headlineBoth", - &[name, &p.haplogroup, &m.haplogroup], - ), + (Some(p), Some(m)) => tr_fmt(lang, "brief.headlineBoth", &[name, &p.haplogroup, &m.haplogroup]), (Some(p), None) => tr_fmt(lang, "brief.headlinePaternal", &[name, &p.haplogroup]), (None, Some(m)) => tr_fmt(lang, "brief.headlineMaternal", &[name, &m.haplogroup]), (None, None) => tr(lang, "brief.headlineNone").to_string(), diff --git a/crates/navigator-app/src/commands.rs b/crates/navigator-app/src/commands.rs index 154099c9..6e0e7966 100644 --- a/crates/navigator-app/src/commands.rs +++ b/crates/navigator-app/src/commands.rs @@ -490,8 +490,13 @@ impl App { pub async fn record_analysis_error(&self, alignment_id: i64, step: &str, message: &str) { let mut message = message.to_string(); message.truncate(500); // keep the payload small; the head carries the cause - let marker = AnalysisError { step: step.to_string(), message }; - let _ = self.save_analysis(alignment_id, ERROR_KIND, ERROR_VERSION, &marker).await; + let marker = AnalysisError { + step: step.to_string(), + message, + }; + let _ = self + .save_analysis(alignment_id, ERROR_KIND, ERROR_VERSION, &marker) + .await; } /// Clear any persisted [`record_analysis_error`] marker for this alignment (no-op when absent). diff --git a/crates/navigator-app/src/export.rs b/crates/navigator-app/src/export.rs index 95c44921..f0875f95 100644 --- a/crates/navigator-app/src/export.rs +++ b/crates/navigator-app/src/export.rs @@ -290,7 +290,9 @@ pub fn branch_report_tsv(report: &BranchReport) -> String { "# DUNavigator {dna} branch report — node {} ({}); {d} derived / {a} ancestral / {n} no-call\n", report.root, report.contig ); - out.push_str("node\tparent\tmarker\tchrom\tpos\tancestral\tderived\tobserved_base\tstatus\tGT\tAD\tDP\tGQ\tsource\tnote\n"); + out.push_str( + "node\tparent\tmarker\tchrom\tpos\tancestral\tderived\tobserved_base\tstatus\tGT\tAD\tDP\tGQ\tsource\tnote\n", + ); for r in &report.rows { out.push_str(&format!( "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n", @@ -849,11 +851,16 @@ mod tests { ], }; let tsv = branch_report_tsv(&report); - assert!(tsv.lines().next().unwrap().starts_with("# DUNavigator Y-DNA branch report — node R-FGC29071")); - assert!(tsv.contains("node\tparent\tmarker\tchrom\tpos\tancestral\tderived\tobserved_base\tstatus\tGT\tAD\tDP\tGQ\tsource\tnote")); assert!(tsv .lines() - .any(|l| l == "R-FGC29071\tR-FGC29067\tFGC29069\tchrY\t14583465\tG\tT\tT\tderived\t1\t0,11\t11\t99\tgvcf_variant\t")); + .next() + .unwrap() + .starts_with("# DUNavigator Y-DNA branch report — node R-FGC29071")); + assert!(tsv.contains( + "node\tparent\tmarker\tchrom\tpos\tancestral\tderived\tobserved_base\tstatus\tGT\tAD\tDP\tGQ\tsource\tnote" + )); + assert!(tsv.lines().any(|l| l + == "R-FGC29071\tR-FGC29067\tFGC29069\tchrY\t14583465\tG\tT\tT\tderived\t1\t0,11\t11\t99\tgvcf_variant\t")); // Ref-block row: AD/DP omitted (.), GQ kept, ancestral, hom-ref note. assert!(tsv .lines() diff --git a/crates/navigator-app/src/fastpath.rs b/crates/navigator-app/src/fastpath.rs index 8d844e08..010ce25c 100644 --- a/crates/navigator-app/src/fastpath.rs +++ b/crates/navigator-app/src/fastpath.rs @@ -371,7 +371,14 @@ impl App { }; // Don't downgrade a full deep walk on reimport — keep it if it's already equal-or-fuller. let wrote = self - .save_analysis_no_downgrade(alignment_id, "read_metrics", "1", &metrics, "pipeline-sidecar", completeness) + .save_analysis_no_downgrade( + alignment_id, + "read_metrics", + "1", + &metrics, + "pipeline-sidecar", + completeness, + ) .await?; Ok(wrote) } @@ -601,7 +608,11 @@ impl App { canonical_build(&aln.reference_build), Some(ReferenceBuild::Chm13v2 | ReferenceBuild::Chm13v2MaskedRcrs) ); - let regions = if is_chm13 { self.y_structural_regions().await } else { None }; + let regions = if is_chm13 { + self.y_structural_regions().await + } else { + None + }; // L2: the cohort **callable mask** (Poznik-style, CALLABLE in ≥90% of a ~3k-male cohort) — // only ~25% of non-PAR chrY is reliably callable cohort-wide. L3: a **cohort-shared-sites** // blocklist — every position that varies with ≥2 carriers across the cohort (plus homoplasy diff --git a/crates/navigator-app/src/ftdna_import.rs b/crates/navigator-app/src/ftdna_import.rs index d23beb4c..a50e3226 100644 --- a/crates/navigator-app/src/ftdna_import.rs +++ b/crates/navigator-app/src/ftdna_import.rs @@ -250,17 +250,20 @@ impl App { } } out.subjects_examined += 1; - let derived = - navigator_domain::identity::catalog_ids_from_provenance(&b.donor_identifier, b.sample_accession.as_deref()); + let derived = navigator_domain::identity::catalog_ids_from_provenance( + &b.donor_identifier, + b.sample_accession.as_deref(), + ); if derived.is_empty() { continue; } out.subjects_matched += 1; - let existing: std::collections::HashSet<(String, String)> = external_id::list_for(self.store.pool(), b.guid) - .await? - .into_iter() - .map(|e| (e.source, e.external_id)) - .collect(); + let existing: std::collections::HashSet<(String, String)> = + external_id::list_for(self.store.pool(), b.guid) + .await? + .into_iter() + .map(|e| (e.source, e.external_id)) + .collect(); for (ns, val) in derived { if existing.contains(&(ns.clone(), val.clone())) { continue; @@ -333,9 +336,7 @@ impl App { } } // Skip samples whose name isn't a recognizable catalog alias unless `--all`. - if !all - && navigator_domain::identity::catalog_ids_from_provenance(&b.donor_identifier, None).is_empty() - { + if !all && navigator_domain::identity::catalog_ids_from_provenance(&b.donor_identifier, None).is_empty() { continue; } if limit.is_some_and(|n| out.examined >= n) { @@ -367,11 +368,12 @@ impl App { out.examples.push(format!("{} → {acc}", b.donor_identifier)); } } - let existing: std::collections::HashSet<(String, String)> = external_id::list_for(self.store.pool(), b.guid) - .await? - .into_iter() - .map(|e| (e.source, e.external_id)) - .collect(); + let existing: std::collections::HashSet<(String, String)> = + external_id::list_for(self.store.pool(), b.guid) + .await? + .into_iter() + .map(|e| (e.source, e.external_id)) + .collect(); for (ns, val) in &ids { if existing.contains(&(ns.clone(), val.clone())) { continue; @@ -705,9 +707,11 @@ impl App { source: &str, external_id: &str, ) -> Result, AppError> { - Ok(navigator_store::external_id::find(self.store.pool(), source, external_id) - .await? - .map(|e| e.biosample_guid)) + Ok( + navigator_store::external_id::find(self.store.pool(), source, external_id) + .await? + .map(|e| e.biosample_guid), + ) } /// FTDNA-reported member labels for a Subject, if imported. diff --git a/crates/navigator-app/src/haplogroup.rs b/crates/navigator-app/src/haplogroup.rs index d8821df6..34336371 100644 --- a/crates/navigator-app/src/haplogroup.rs +++ b/crates/navigator-app/src/haplogroup.rs @@ -15,7 +15,10 @@ fn parse_painting_json(s: &str) -> Result { Ok(r) => Ok(r), Err(_) => { let segments: Vec = serde_json::from_str(s)?; - Ok(PaintingResult { segments, ..Default::default() }) + Ok(PaintingResult { + segments, + ..Default::default() + }) } } } @@ -169,7 +172,10 @@ impl App { let per_contig = self.callable_intervals_all(*id).await?; Ok(export::callable_bed(&per_contig)) } - ExportRequest::DiploidVcf(id) => self.diploid_vcf_genome(*id, navigator_analysis::CancelToken::none()).await, + ExportRequest::DiploidVcf(id) => { + self.diploid_vcf_genome(*id, navigator_analysis::CancelToken::none()) + .await + } ExportRequest::ConsensusDiploidVcf(guid) => self.consensus_diploid_vcf(*guid).await, ExportRequest::SubjectBriefHtml(guid) => { let brief = self.subject_brief(*guid).await?; @@ -294,8 +300,15 @@ impl App { source_key: &str, call: &RunHaplogroupCall, ) -> Result<(), AppError> { - self.record_haplogroup_call_fp(biosample_guid, dna_type, source_key, call, CallProvenance::NavigatorWalk, None) - .await + self.record_haplogroup_call_fp( + biosample_guid, + dna_type, + source_key, + call, + CallProvenance::NavigatorWalk, + None, + ) + .await } /// Like [`record_haplogroup_call`](Self::record_haplogroup_call) but stamps the input @@ -406,7 +419,10 @@ impl App { let Ok(bio) = self.biosample_of_alignment(alignment_id).await else { return Ok(false); }; - Ok(self.preferred_external_call(bio, dna_type, alignment_id).await?.is_some()) + Ok(self + .preferred_external_call(bio, dna_type, alignment_id) + .await? + .is_some()) } /// "Compare callers": the trusted external caller vs Navigator's internal caller for one @@ -425,9 +441,11 @@ impl App { }; if y_bearing { let external = match bio { - Some(g) => haplogroup_call::get_one(self.store.pool(), g, DnaType::Y, &external_y_source_key(alignment_id)) - .await? - .map(|c| c.haplogroup), + Some(g) => { + haplogroup_call::get_one(self.store.pool(), g, DnaType::Y, &external_y_source_key(alignment_id)) + .await? + .map(|c| c.haplogroup) + } None => None, }; let navigator = self @@ -443,9 +461,11 @@ impl App { } let external_mt = match bio { - Some(g) => haplogroup_call::get_one(self.store.pool(), g, DnaType::Mt, &external_mt_source_key(alignment_id)) - .await? - .map(|c| c.haplogroup), + Some(g) => { + haplogroup_call::get_one(self.store.pool(), g, DnaType::Mt, &external_mt_source_key(alignment_id)) + .await? + .map(|c| c.haplogroup) + } None => None, }; let navigator_mt = self @@ -730,7 +750,12 @@ impl App { /// tree is unavailable (interpret then falls back to each variant's stored ref/alt). async fn current_y_polarity(&self) -> std::collections::BTreeMap { match y_tree_provider() { - YTreeProvider::DecodingUs => self.decodingus_y_polarity().await.unwrap_or_default().into_iter().collect(), + YTreeProvider::DecodingUs => self + .decodingus_y_polarity() + .await + .unwrap_or_default() + .into_iter() + .collect(), YTreeProvider::Ftdna => self .fetch_ftdna_y_tree() .await @@ -1009,8 +1034,14 @@ impl App { profile.terminal = terminal; // Persist observations (keyed dna_type='Mt') with the tree provider actually used. - self.persist_observed_profile(biosample_guid, DnaType::Mt, &observed, &profile.summary, Some(provider.to_string())) - .await?; + self.persist_observed_profile( + biosample_guid, + DnaType::Mt, + &observed, + &profile.summary, + Some(provider.to_string()), + ) + .await?; Ok(profile) } @@ -1054,7 +1085,11 @@ impl App { .gvcf_base_calls(a.id, "chrY", &gvcf, &tree, tree_build_for_contig("chrY")) .await .ok(), - _ => self.assign_haplogroup_detail(a.id, "chrY", &tree_json).await.ok().map(|(_, _, c)| c), + _ => self + .assign_haplogroup_detail(a.id, "chrY", &tree_json) + .await + .ok() + .map(|(_, _, c)| c), }; let Some(calls) = calls else { continue }; if !calls.is_empty() { @@ -1082,7 +1117,10 @@ impl App { /// DecodingUs-provider genome consensus (the default): genotype every WGS alignment against the /// DecodingUs Y tree in each source's *native* build, group by build, pool by position, and place /// on the build carrying the most evidence. - async fn place_y_consensus_decodingus(&self, biosample_guid: SampleGuid) -> Result, AppError> { + async fn place_y_consensus_decodingus( + &self, + biosample_guid: SampleGuid, + ) -> Result, AppError> { // Genotype every WGS alignment against the **DecodingUs** Y tree — the workspace's configured // provider, served from the local cache — in each source's *native* build (`hs1` for CHM13, // `GRCh38`, `GRCh37`). No liftover and no FTDNA dependency: the per-alignment genotype is @@ -1097,12 +1135,18 @@ impl App { // Parse the DecodingUs tree once per distinct build the sources use (cheap — the JSON is // memoized). Built up front so the async genotyping loop holds only shared borrows of `trees`. - let mut builds: std::collections::HashSet<&'static str> = - alignments.iter().filter_map(|a| decodingus_build_key(&a.reference_build)).collect(); + let mut builds: std::collections::HashSet<&'static str> = alignments + .iter() + .filter_map(|a| decodingus_build_key(&a.reference_build)) + .collect(); for set in &vsets { if set.source_type != SourceType::Chip { // Unknown vendor build → GRCh38 (the vendor-Y-VCF import default). - if let Some(bk) = set.reference_build.as_deref().map_or(Some("GRCh38"), decodingus_build_key) { + if let Some(bk) = set + .reference_build + .as_deref() + .map_or(Some("GRCh38"), decodingus_build_key) + { builds.insert(bk); } } @@ -1116,11 +1160,15 @@ impl App { let mut by_build: HashMap<&'static str, YSourceCalls> = HashMap::new(); for a in &alignments { - let Some(bk) = decodingus_build_key(&a.reference_build) else { continue }; + let Some(bk) = decodingus_build_key(&a.reference_build) else { + continue; + }; let Some(tree) = trees.get(bk) else { continue }; // Native build → no liftover; the cache-key matches the Y assignment's, so a CRAM walk is // a hit — but a preferred-external alignment is genotyped from its GVCF instead (no decode). - let Ok(calls) = self.consensus_base_calls(a, "chrY", tree, None).await else { continue }; + let Ok(calls) = self.consensus_base_calls(a, "chrY", tree, None).await else { + continue; + }; if !calls.is_empty() { by_build.entry(bk).or_default().push((SourceType::WgsShortRead, calls)); } @@ -1133,11 +1181,20 @@ impl App { if set.source_type == SourceType::Chip { continue; } - let Some(bk) = set.reference_build.as_deref().map_or(Some("GRCh38"), decodingus_build_key) else { continue }; + let Some(bk) = set + .reference_build + .as_deref() + .map_or(Some("GRCh38"), decodingus_build_key) + else { + continue; + }; let Some(tree) = trees.get(bk) else { continue }; let calls = Self::vset_chr_y_calls(set); if !calls.is_empty() { - by_build.entry(bk).or_default().push((set.source_type, strand_reconcile_to_tree(tree, calls))); + by_build + .entry(bk) + .or_default() + .push((set.source_type, strand_reconcile_to_tree(tree, calls))); } } @@ -1203,7 +1260,9 @@ impl App { )); } } - out.push_str(&format!("\ntotals: derived={derived} ancestral={ancestral} nocall={nocall}\n")); + out.push_str(&format!( + "\ntotals: derived={derived} ancestral={ancestral} nocall={nocall}\n" + )); Ok(out) } @@ -1236,12 +1295,18 @@ impl App { // Localize the BAM/CRAM and resolve its reference exactly as `base_calls` does, then tally the // raw reads at the lineage positions and read the reference base there. let bam = self - .localize(Path::new(&aln.bam_path.clone().ok_or(AppError::MissingPaths(alignment_id))?)) + .localize(Path::new( + &aln.bam_path.clone().ok_or(AppError::MissingPaths(alignment_id))?, + )) .await; let is_cram = bam.extension().is_some_and(|e| e.eq_ignore_ascii_case("cram")); let reference = match aln.reference_path.clone() { Some(p) => Some(PathBuf::from(p)), - None if is_cram => Some(self.gateway.resolve_reference(&aln.reference_build, &mut |_, _| {}).await?), + None if is_cram => Some( + self.gateway + .resolve_reference(&aln.reference_build, &mut |_, _| {}) + .await?, + ), None => self.gateway.cached_reference(&aln.reference_build), }; let targets: HashSet = assignment.lineage.iter().map(|e| e.position).collect(); @@ -1300,7 +1365,9 @@ impl App { e.state, )); } - out.push_str(&format!("\ntotals: derived={derived} ancestral={ancestral} nocall={nocall}\n")); + out.push_str(&format!( + "\ntotals: derived={derived} ancestral={ancestral} nocall={nocall}\n" + )); Ok(out) } @@ -1315,7 +1382,11 @@ impl App { decodingus_build_key(&a.reference_build) == Some("hs1") && a.aligner.to_ascii_lowercase().contains("pbmm2") }) - .or_else(|| alignments.iter().find(|a| decodingus_build_key(&a.reference_build) == Some("hs1"))) + .or_else(|| { + alignments + .iter() + .find(|a| decodingus_build_key(&a.reference_build) == Some("hs1")) + }) .or_else(|| alignments.first()); Ok(pick.map(|a| a.id)) } @@ -1330,8 +1401,10 @@ impl App { for a in &alignments { let y_only = match sequence_run::get(self.store.pool(), a.sequence_run_id).await? { // `target_of` is tolerant of unknown codes (→ None), which stay eligible. - Some(run) => navigator_domain::testtype::target_of(&run.test_type) - == Some(navigator_domain::testtype::TargetType::YChromosome), + Some(run) => { + navigator_domain::testtype::target_of(&run.test_type) + == Some(navigator_domain::testtype::TargetType::YChromosome) + } None => false, }; if !y_only { @@ -1364,11 +1437,17 @@ impl App { let tree_json = self.fetch_decodingus_y_tree().await?; let alignments = alignment::list_for_biosample(self.store.pool(), biosample_guid).await?; let vsets = variant_set::list_for_biosample(self.store.pool(), biosample_guid).await?; - let mut builds: std::collections::HashSet<&'static str> = - alignments.iter().filter_map(|a| decodingus_build_key(&a.reference_build)).collect(); + let mut builds: std::collections::HashSet<&'static str> = alignments + .iter() + .filter_map(|a| decodingus_build_key(&a.reference_build)) + .collect(); for set in &vsets { if set.source_type != SourceType::Chip { - if let Some(bk) = set.reference_build.as_deref().map_or(Some("GRCh38"), decodingus_build_key) { + if let Some(bk) = set + .reference_build + .as_deref() + .map_or(Some("GRCh38"), decodingus_build_key) + { builds.insert(bk); } } @@ -1381,7 +1460,9 @@ impl App { } let mut by_build: HashMap<&'static str, YSourceCalls> = HashMap::new(); for a in &alignments { - let Some(bk) = decodingus_build_key(&a.reference_build) else { continue }; + let Some(bk) = decodingus_build_key(&a.reference_build) else { + continue; + }; let Some(tree) = trees.get(bk) else { continue }; if let Ok(calls) = self.base_calls(a.id, "chrY", tree, None).await { if !calls.is_empty() { @@ -1393,13 +1474,20 @@ impl App { if set.source_type == SourceType::Chip { continue; } - let Some(bk) = set.reference_build.as_deref().map_or(Some("GRCh38"), decodingus_build_key) else { + let Some(bk) = set + .reference_build + .as_deref() + .map_or(Some("GRCh38"), decodingus_build_key) + else { continue; }; let Some(tree) = trees.get(bk) else { continue }; let calls = Self::vset_chr_y_calls(set); if !calls.is_empty() { - by_build.entry(bk).or_default().push((set.source_type, strand_reconcile_to_tree(tree, calls))); + by_build + .entry(bk) + .or_default() + .push((set.source_type, strand_reconcile_to_tree(tree, calls))); } } let Some(bk) = by_build @@ -1589,7 +1677,11 @@ impl App { continue; }; if !calls.is_empty() { - sources.push((format!("aln #{} · {}", a.id, a.aligner), SourceType::WgsShortRead, calls)); + sources.push(( + format!("aln #{} · {}", a.id, a.aligner), + SourceType::WgsShortRead, + calls, + )); } } @@ -1650,7 +1742,11 @@ impl App { .filter_map(|c| c.alternate.chars().next().map(|b| (c.position, b.to_ascii_uppercase()))) .collect(); if !chip_mt.is_empty() { - sources.push(("Chip mtDNA panel".to_string(), SourceType::Chip, strand_reconcile_to_tree(tree, chip_mt))); + sources.push(( + "Chip mtDNA panel".to_string(), + SourceType::Chip, + strand_reconcile_to_tree(tree, chip_mt), + )); } Ok(sources) @@ -1701,7 +1797,9 @@ impl App { DnaType::Mt => self.cached_mt_profile(biosample_guid).await?, }; let Some(profile) = profile else { return Ok(None) }; - let Some(terminal) = profile.terminal.clone() else { return Ok(None) }; + let Some(terminal) = profile.terminal.clone() else { + return Ok(None); + }; // Render on the configured provider's tree so the node names + defining SNPs line up with the // profile's placement (which followed the same provider). Y: DecodingUs in the subject's @@ -1902,7 +2000,9 @@ impl App { let Some(alignment_id) = self.pick_alignment_for(guid, dna).await? else { return Ok(None); }; - Ok(Some(self.branch_report(alignment_id, dna, node_query, max_depth).await?)) + Ok(Some( + self.branch_report(alignment_id, dna, node_query, max_depth).await?, + )) } /// The persisted autosomal consensus-profile snapshot for a subject, if built — cheap (no @@ -1939,11 +2039,14 @@ impl App { &self, biosample_guid: SampleGuid, ) -> Result, AppError> { - self.build_autosomal_profile_inner(biosample_guid, true).await.map(Some).or_else(|e| match e { - // "no source" isn't an error for a refresh — the subject just has nothing cached yet. - AppError::Import(_) => Ok(None), - other => Err(other), - }) + self.build_autosomal_profile_inner(biosample_guid, true) + .await + .map(Some) + .or_else(|e| match e { + // "no source" isn't an error for a refresh — the subject just has nothing cached yet. + AppError::Import(_) => Ok(None), + other => Err(other), + }) } /// **Panel batch-process mode** (progressive-consensus, docs §7.17): genotype one alignment at @@ -2094,7 +2197,8 @@ impl App { // One source per imported **external autosomal call set** (a trusted 1240K EIGENSTRAT set — // GATK4 / pileupCaller). Resolved to CHM13 panel dosages at import and stored, so it pools in // with no CRAM decode (available to both the full build and the progressive refresh). - for row in navigator_store::external_panel_dosage::list_for_biosample(self.store.pool(), biosample_guid).await? { + for row in navigator_store::external_panel_dosage::list_for_biosample(self.store.pool(), biosample_guid).await? + { match serde_json::from_str::>(&row.dosages) { Ok(gts) => { let obs = to_obs(gts); @@ -2407,10 +2511,12 @@ impl App { } let reference = self.gateway.cached_reference("chm13v2.0")?; let pairs = tokio::task::spawn_blocking(move || { - navigator_analysis::reader::read_contig_sequence(&reference, "chrM").ok().map(|chrm| { - let chrm = String::from_utf8_lossy(&chrm).into_owned(); - navigator_analysis::mtvariants::mt_position_map(navigator_analysis::mtvariants::rcrs(), &chrm) - }) + navigator_analysis::reader::read_contig_sequence(&reference, "chrM") + .ok() + .map(|chrm| { + let chrm = String::from_utf8_lossy(&chrm).into_owned(); + navigator_analysis::mtvariants::mt_position_map(navigator_analysis::mtvariants::rcrs(), &chrm) + }) }) .await .ok() @@ -2574,9 +2680,7 @@ impl App { if let Ok(entries) = std::fs::read_dir(&dir) { for e in entries.flatten() { let p = e.path(); - if p.extension().and_then(|x| x.to_str()) == Some("json") - && std::fs::remove_file(&p).is_ok() - { + if p.extension().and_then(|x| x.to_str()) == Some("json") && std::fs::remove_file(&p).is_ok() { removed += 1; } } @@ -2810,7 +2914,9 @@ impl App { // required; PCA + fine frequencies are optional (best-effort — the feature degrades if absent). self.ensure_ancestry_asset(build, &ancestry_panel_path(build)).await?; let _ = self.ensure_ancestry_asset(build, &ancestry_pca_path(build)).await; - let _ = self.ensure_ancestry_asset(build, &ancestry_freq_global_path(build)).await; + let _ = self + .ensure_ancestry_asset(build, &ancestry_freq_global_path(build)) + .await; let panel_path = ancestry_panel_path(build); let panel_bytes = read_verified_asset(build, &panel_path)? .ok_or_else(|| AppError::AncestryPanelMissing(panel_path.clone()))?; @@ -2878,10 +2984,7 @@ impl App { /// no autosomal calls, or the deep model does not apply (non-European / model rejected / infeasible /// weights). `None` persists nothing — keeping an inapplicable breakdown off the UI *and* out of /// the PDS. - pub async fn estimate_deep_ancestry( - &self, - biosample_guid: SampleGuid, - ) -> Result, AppError> { + pub async fn estimate_deep_ancestry(&self, biosample_guid: SampleGuid) -> Result, AppError> { if !crate::ANCIENT_ANCESTRY_ENABLED { return Ok(None); } @@ -2907,7 +3010,9 @@ impl App { // Both the scope gate and the qpAdm fit read the *same* genotypes; every panel here is // CHM13-canonical (§7.16), so no per-site re-keying is needed. let profile = self.cached_autosomal_profile(biosample_guid).await?.ok_or_else(|| { - AppError::Import("build the autosomal consensus first (Autosomal tab) before estimating deep ancestry".into()) + AppError::Import( + "build the autosomal consensus first (Autosomal tab) before estimating deep ancestry".into(), + ) })?; let genotypes = consensus_genotypes(&profile); if genotypes.is_empty() { @@ -2957,10 +3062,7 @@ impl App { /// /// Every row reports its dispersion even when the applicability gate rejects it, so a rejection /// can be read as a magnitude rather than taken on faith. - pub async fn ancient_ancestry_stability( - &self, - biosample_guid: SampleGuid, - ) -> Result, AppError> { + pub async fn ancient_ancestry_stability(&self, biosample_guid: SampleGuid) -> Result, AppError> { // Build the consensus on demand — this is a diagnostic, and requiring the caller to have // clicked through the GUI first would make it useless from the CLI. let profile = match self.cached_autosomal_profile(biosample_guid).await? { @@ -2969,8 +3071,7 @@ impl App { }; let build = ReferenceBuild::Chm13v2; let path = ancestry_freq_ancient_path(build); - let bytes = - read_verified_asset(build, &path)?.ok_or_else(|| AppError::AncestryPanelMissing(path.clone()))?; + let bytes = read_verified_asset(build, &path)?.ok_or_else(|| AppError::AncestryPanelMissing(path.clone()))?; let panel = AncestryPanel::from_bytes(&bytes)?; // The super-pop panel too: deep ancestry is scoped by the modern estimate, so each view has // to be scored by both models or the diagnostic wouldn't be reproducing the shipped policy. @@ -3041,11 +3142,16 @@ impl App { // (MAF/strand/polarity/ts-tv) has been ruled out. if let Ok(dump_path) = std::env::var("NAVIGATOR_ANCIENT_DUMP") { let want = std::env::var("NAVIGATOR_ANCIENT_ALN").unwrap_or_else(|_| "#9".into()); - let freq: std::collections::HashMap<(&str, i64), &Vec> = - panel.sites.iter().map(|s| ((s.contig.as_str(), s.position), &s.freqs)).collect(); + let freq: std::collections::HashMap<(&str, i64), &Vec> = panel + .sites + .iter() + .map(|s| ((s.contig.as_str(), s.position), &s.freqs)) + .collect(); let mut out = String::from("chip\taln_dosage\twhg\tanf\tsteppe\n"); for v in &profile.variants { - let Some(f) = freq.get(&(v.contig.as_str(), v.position)) else { continue }; + let Some(f) = freq.get(&(v.contig.as_str(), v.position)) else { + continue; + }; if f.len() != 3 { continue; } @@ -3077,33 +3183,34 @@ impl App { }; // One source's own observed dosages, restricted to the variants the predicate keeps. - let build_single = - |label: &str, keep: &dyn Fn(&navigator_domain::consensus::DiploidVariant) -> bool| -> Vec { - profile - .variants - .iter() - .filter(|v| keep(v)) - .filter_map(|v| { - let obs = v.sources.iter().find(|s| s.label.as_str() == label)?; - (obs.dosage >= 0).then(|| SiteGenotype { - name: v.name.clone(), - contig: v.contig.clone(), - position: v.position, - reference_allele: v.reference.clone(), - alternate_allele: v.alternate.clone(), - ploidy: 2, - dosage: obs.dosage as i32, - gq: 0, - depth: 0, - ref_depth: 0, - alt_depth: 0, - pls: Vec::new(), - gt: None, - allele_depths: None, - }) + let build_single = |label: &str, + keep: &dyn Fn(&navigator_domain::consensus::DiploidVariant) -> bool| + -> Vec { + profile + .variants + .iter() + .filter(|v| keep(v)) + .filter_map(|v| { + let obs = v.sources.iter().find(|s| s.label.as_str() == label)?; + (obs.dosage >= 0).then(|| SiteGenotype { + name: v.name.clone(), + contig: v.contig.clone(), + position: v.position, + reference_allele: v.reference.clone(), + alternate_allele: v.alternate.clone(), + ploidy: 2, + dosage: obs.dosage as i32, + gq: 0, + depth: 0, + ref_depth: 0, + alt_depth: 0, + pls: Vec::new(), + gt: None, + allele_depths: None, }) - .collect() - }; + }) + .collect() + }; // Each source alone: take that source's own observed dosage at each site. For a WGS // source, also refit it three ways to localize the stability bias: @@ -3118,17 +3225,25 @@ impl App { }); fit(format!("source: {label}"), &build_single(label, &|_| true)); if !is_chip { - fit(format!("source: {label} ∩chip"), &build_single(label, &|v| chip_sites.contains(&v.name))); - fit(format!("source: {label} ∁chip"), &build_single(label, &|v| !chip_sites.contains(&v.name))); - fit(format!("source: {label} ¬ambig"), &build_single(label, &|v| !is_ambiguous(v))); + fit( + format!("source: {label} ∩chip"), + &build_single(label, &|v| chip_sites.contains(&v.name)), + ); + fit( + format!("source: {label} ∁chip"), + &build_single(label, &|v| !chip_sites.contains(&v.name)), + ); + fit( + format!("source: {label} ¬ambig"), + &build_single(label, &|v| !is_ambiguous(v)), + ); } } // Density: deterministic thinning of the pooled consensus. A well-conditioned fit barely // moves when half the evidence is removed; an over-fit one lurches. for (keep, label) in [(2usize, "consensus ÷2 sites"), (4, "consensus ÷4 sites")] { - let thinned: Vec = - consensus.iter().step_by(keep).cloned().collect(); + let thinned: Vec = consensus.iter().step_by(keep).cloned().collect(); fit(label.to_string(), &thinned); } rows @@ -3281,7 +3396,9 @@ impl App { let phaser = ReferencePhaser::new(&hap, &gmap, PhaseParams::default()); let phased_g = phaser.phase(&genotypes); let segs = navigator_analysis::lai::paint_copying_lai(&phased_g, &hap, &gmap, &prior, &lai_params); - let anchor = parent_genos.as_ref().and_then(|pg| anchor_side_to_parent(&phased_g, pg)); + let anchor = parent_genos + .as_ref() + .and_then(|pg| anchor_side_to_parent(&phased_g, pg)); (segs, true, anchor) } None => { @@ -3299,7 +3416,11 @@ impl App { .await?; let side_labels = build_side_labels(phased, anchor_side, parent_meta.as_ref()); - let result = PaintingResult { segments, side_labels, phased }; + let result = PaintingResult { + segments, + side_labels, + phased, + }; // Cache keyed to the consensus signature so it's reused until the consensus is rebuilt. consensus_painting::upsert( @@ -3461,7 +3582,9 @@ impl App { panel_fingerprint: &str, ) -> Result, AppError> { let build = ReferenceBuild::Chm13v2; - let _ = self.ensure_ancestry_asset(build, &crate::archaic_marker_dist_path(build)).await; + let _ = self + .ensure_ancestry_asset(build, &crate::archaic_marker_dist_path(build)) + .await; let Some(bytes) = crate::read_verified_asset(build, &crate::archaic_marker_dist_path(build))? else { return Ok(None); }; @@ -3643,7 +3766,10 @@ impl App { let pairs: Vec<(&str, i32)> = lengths.iter().map(|(k, v)| (k.as_str(), *v)).collect(); let gmap = crate::load_genetic_map(rb, &pairs); - eprintln!("archaic segments: {} calls over {contigs_present} autosome(s)", calls.len()); + eprintln!( + "archaic segments: {} calls over {contigs_present} autosome(s)", + calls.len() + ); let result = tokio::task::spawn_blocking(move || -> Result<_, AppError> { use navigator_analysis::archaic_match as am; @@ -3661,7 +3787,11 @@ impl App { contig, &classify, pos_map, - |p| seq.get((p - 1).max(0) as usize).copied().map(|b| b.to_ascii_uppercase()), + |p| { + seq.get((p - 1).max(0) as usize) + .copied() + .map(|b| b.to_ascii_uppercase()) + }, &callable, am::MatchConfig::default().min_callable_fraction, ); @@ -3707,7 +3837,10 @@ impl App { panel: &ArchaicMarkerPanel, ) -> Result, AppError> { let kind = crate::archaic_panel_cache_kind(); - if let Some(g) = self.load_analysis(alignment_id, &kind, caller::GENOTYPE_VERSION).await? { + if let Some(g) = self + .load_analysis(alignment_id, &kind, caller::GENOTYPE_VERSION) + .await? + { return Ok(g); } let build = self.alignment_or_err(alignment_id).await?.reference_build; @@ -3756,7 +3889,9 @@ impl App { for s in &panel.sites { let Some(l) = s.locus(&build) else { continue }; let key = navigator_analysis::contig::bare_upper(&l.contig); - let Some(contig) = index.get(&key).cloned() else { continue }; + let Some(contig) = index.get(&key).cloned() else { + continue; + }; targets.push(( s, Site { @@ -3783,10 +3918,8 @@ impl App { .await??; // Re-key onto CHM13: same position/alleles the counter expects, dosage re-expressed. - let by_pos: HashMap<(&str, i64), &SiteGenotype> = called - .iter() - .map(|g| ((g.contig.as_str(), g.position), g)) - .collect(); + let by_pos: HashMap<(&str, i64), &SiteGenotype> = + called.iter().map(|g| ((g.contig.as_str(), g.position), g)).collect(); let mut out = Vec::with_capacity(targets.len()); for (site, target) in &targets { let Some(g) = by_pos.get(&(target.contig.as_str(), target.position)) else { @@ -3836,16 +3969,17 @@ impl App { alignment_id: i64, ) -> Result { let build = ReferenceBuild::Chm13v2; - self.ensure_ancestry_asset(build, &crate::archaic_markers_path(build)).await?; + self.ensure_ancestry_asset(build, &crate::archaic_markers_path(build)) + .await?; let path = crate::archaic_markers_path(build); - let bytes = crate::read_verified_asset(build, &path)? - .ok_or_else(|| AppError::AncestryPanelMissing(path.clone()))?; + let bytes = + crate::read_verified_asset(build, &path)?.ok_or_else(|| AppError::AncestryPanelMissing(path.clone()))?; let panel = ArchaicMarkerPanel::from_bytes(&bytes)?; let genotypes = self.genotype_archaic_for_alignment(alignment_id, &panel).await?; - Ok(tokio::task::spawn_blocking(move || { - navigator_analysis::archaic::count_archaic_markers(&genotypes, &panel) - }) - .await?) + Ok( + tokio::task::spawn_blocking(move || navigator_analysis::archaic::count_archaic_markers(&genotypes, &panel)) + .await?, + ) } /// The cached archaic (Tier A) marker count for a subject, if one was computed from the @@ -3884,16 +4018,15 @@ impl App { let row = consensus_profile::get(self.store.pool(), biosample_guid, "Auto") .await? .ok_or_else(|| { - AppError::Import( - "build the autosomal consensus first (Autosomal tab) before the archaic report".into(), - ) + AppError::Import("build the autosomal consensus first (Autosomal tab) before the archaic report".into()) })?; // Load the panel BEFORE the cache check: the cache signature is salted with the panel's // hash as well as the consensus signature, because rebuilding the panel changes the site // list and the per-class split, and keying on the consensus alone would serve a stale count // computed against a different panel. let build = ReferenceBuild::Chm13v2; - self.ensure_ancestry_asset(build, &crate::archaic_markers_path(build)).await?; + self.ensure_ancestry_asset(build, &crate::archaic_markers_path(build)) + .await?; let panel_path = crate::archaic_markers_path(build); let bytes = crate::read_verified_asset(build, &panel_path)? .ok_or_else(|| AppError::AncestryPanelMissing(panel_path.clone()))?; @@ -3932,10 +4065,9 @@ impl App { } } - let mut result = tokio::task::spawn_blocking(move || { - navigator_analysis::archaic::count_archaic_markers(&genotypes, &panel) - }) - .await?; + let mut result = + tokio::task::spawn_blocking(move || navigator_analysis::archaic::count_archaic_markers(&genotypes, &panel)) + .await?; // Percentile — valid at ANY coverage now, because the cohort is scored over exactly the // sites this subject called rather than over the whole panel. A chip reaching ~3% of the @@ -4520,8 +4652,8 @@ impl App { ) -> Result, AppError> { let bam = bam.to_path_buf(); let reference = reference.map(|p| p.to_path_buf()); - let names = tokio::task::spawn_blocking(move || caller::header_contig_names(&bam, reference.as_deref())) - .await??; + let names = + tokio::task::spawn_blocking(move || caller::header_contig_names(&bam, reference.as_deref())).await??; // Candidate spellings for the requested contig, in preference order. let bare = navigator_analysis::contig::bare(contig); let mut candidates: Vec = vec![contig.to_string(), bare.to_string()]; @@ -4571,7 +4703,9 @@ impl App { chr_y_gvcf_for_alignment(aln) }; if let Some(gvcf) = gvcf { - return self.gvcf_base_calls(aln.id, contig, &gvcf, tree, tree_source_build).await; + return self + .gvcf_base_calls(aln.id, contig, &gvcf, tree, tree_source_build) + .await; } } self.base_calls(aln.id, contig, tree, tree_source_build).await @@ -4669,13 +4803,8 @@ impl App { let mut calls = caller::call_bases_at(&bam, &resolved, &targets, ¶ms, reference.as_deref())?; if !indel_targets.is_empty() { - let indels = caller::call_indels_at( - &bam, - &resolved, - &indel_targets, - ¶ms, - reference.as_deref(), - )?; + let indels = + caller::call_indels_at(&bam, &resolved, &indel_targets, ¶ms, reference.as_deref())?; calls.extend(indels); // sentinel overlays the anchor's base call } Ok(calls) @@ -4687,7 +4816,8 @@ impl App { // Cache the genotypes (stamped with the BAM source_sig) so a rebuild skips the walk. let pairs: Vec<(i64, char)> = calls.iter().map(|(&p, &b)| (p, b)).collect(); - self.save_analysis(alignment_id, GENOTYPE_KIND, &cache_key, &pairs).await?; + self.save_analysis(alignment_id, GENOTYPE_KIND, &cache_key, &pairs) + .await?; Ok(calls) } @@ -4747,7 +4877,8 @@ impl App { }) .await?; let Ok(pairs) = map else { return Ok(None) }; // chrM absent/unreadable → direct fallback - // rcrs_idx/chrm_idx are 0-based; tree + query positions are 1-based. + + // rcrs_idx/chrm_idx are 0-based; tree + query positions are 1-based. let by_rcrs: HashMap = pairs.into_iter().map(|(r, c)| (r as i64 + 1, c as i64 + 1)).collect(); let lifted = targets .iter() @@ -5025,13 +5156,13 @@ mod vset_autosomal_calls_tests { #[test] fn genotype_becomes_reference_forward_allele_pair() { let s = set(vec![ - call("chr1", 100, "C", "T", "1/1"), // hom-alt → (T, T) - call("chr1", 200, "A", "G", "0/1"), // het → (A, G) - call("chr1", 300, "G", "A", "1/."), // het w/ no-call partner → (G, A) - call("chr7", 400, "A", "C", ""), // no genotype → assume het → (A, C) - call("chr2", 500, "A", "G", "1/2"), // tri-allelic → dropped - call("chrY", 600, "A", "G", "1"), // not autosomal → dropped - call("chrM", 700, "A", "G", "1"), // not autosomal → dropped + call("chr1", 100, "C", "T", "1/1"), // hom-alt → (T, T) + call("chr1", 200, "A", "G", "0/1"), // het → (A, G) + call("chr1", 300, "G", "A", "1/."), // het w/ no-call partner → (G, A) + call("chr7", 400, "A", "C", ""), // no genotype → assume het → (A, C) + call("chr2", 500, "A", "G", "1/2"), // tri-allelic → dropped + call("chrY", 600, "A", "G", "1"), // not autosomal → dropped + call("chrM", 700, "A", "G", "1"), // not autosomal → dropped ]); let mut got = App::vset_autosomal_calls(&s); got.sort_by_key(|(_, p, _, _)| *p); @@ -5114,7 +5245,10 @@ mod painting_anchor_tests { #[test] fn side_labels_from_sex_and_anchor() { // Unphased → neutral Side A/B regardless of anchor. - assert_eq!(build_side_labels(false, Some(0), None), ["Side A".to_string(), "Side B".to_string()]); + assert_eq!( + build_side_labels(false, Some(0), None), + ["Side A".to_string(), "Side B".to_string()] + ); // Phased, anchored to side 0, parent female → side 0 Mother, side 1 Father. let mother = (Some("female".to_string()), "Mum".to_string()); @@ -5135,7 +5269,10 @@ mod painting_anchor_tests { ["Parent: Kim".to_string(), "Other parent".to_string()] ); // Phased but no anchor → neutral. - assert_eq!(build_side_labels(true, None, None), ["Side A".to_string(), "Side B".to_string()]); + assert_eq!( + build_side_labels(true, None, None), + ["Side A".to_string(), "Side B".to_string()] + ); } #[test] diff --git a/crates/navigator-app/src/ibd_exchange.rs b/crates/navigator-app/src/ibd_exchange.rs index 8e7a6ffa..868f0cc8 100644 --- a/crates/navigator-app/src/ibd_exchange.rs +++ b/crates/navigator-app/src/ibd_exchange.rs @@ -64,13 +64,10 @@ impl App { let dev = self.ensure_device_key().await?; let request_uri = format!("exchange:{}", Uuid::new_v4()); let ts = Utc::now().timestamp(); - let sig = dev.sign_fresh(ts, &exchange::messages::request( - &request_uri, - &did, - partner_did, - purpose, - scope, - )); + let sig = dev.sign_fresh( + ts, + &exchange::messages::request(&request_uri, &did, partner_did, purpose, scope), + ); let body = serde_json::json!({ "request_uri": request_uri, "initiator_did": did, diff --git a/crates/navigator-app/src/import_profiles.rs b/crates/navigator-app/src/import_profiles.rs index b9e8639d..b05056be 100644 --- a/crates/navigator-app/src/import_profiles.rs +++ b/crates/navigator-app/src/import_profiles.rs @@ -17,7 +17,11 @@ fn load_ysnp_dictionary_cached() -> Result, String> { .map(|f| dir.join(f)) .find(|p| p.is_file()) .ok_or_else(|| format!("no Y-SNP dictionary in {}", dir.display()))?; - let key = format!("{}|{}", dict_path.display(), file_signature(&dict_path).unwrap_or_default()); + let key = format!( + "{}|{}", + dict_path.display(), + file_signature(&dict_path).unwrap_or_default() + ); let memo = YSNP_MEMO.get_or_init(|| Mutex::new(None)); if let Some((k, d)) = memo.lock().unwrap().as_ref() { if *k == key { @@ -308,8 +312,7 @@ impl App { /// re-publish, not a client change. Best-effort — the caller then loads, degrading clearly if the /// dictionary is still absent. Publish with `packaging/publish-assets.sh ysnp`. pub async fn ensure_ysnp_dictionary(&self) -> Result<(), AppError> { - const YSNP_ASSET_BASE: &str = - "https://github.com/JamesKane/decodingus-navigator/releases/download/assets-ysnp"; + const YSNP_ASSET_BASE: &str = "https://github.com/JamesKane/decodingus-navigator/releases/download/assets-ysnp"; let dir = ysnp_dict::asset_dir(); if YsnpDictionary::ASSET_FILENAMES.iter().any(|f| dir.join(f).is_file()) { diff --git a/crates/navigator-app/src/import_unified.rs b/crates/navigator-app/src/import_unified.rs index 9c768baa..8182a192 100644 --- a/crates/navigator-app/src/import_unified.rs +++ b/crates/navigator-app/src/import_unified.rs @@ -343,7 +343,11 @@ impl App { .iter() .filter(|f| f.kind != navigator_analysis::scan::DiscoveredFileType::Index) { - let name = f.path.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let name = f + .path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); match self.add_data(biosample_guid, &f.path).await { Ok(d) => summary.imported.push((name, d.description().to_string())), Err(e) => summary.skipped.push((name, e.to_string())), @@ -380,13 +384,19 @@ impl App { let existing = alignment::list_for_run(self.store.pool(), run.id).await?; for aln_path in &sample.alignment_files { let path_str = aln_path.to_string_lossy().into_owned(); - if existing.iter().any(|a| a.bam_path.as_deref() == Some(path_str.as_str())) { + if existing + .iter() + .any(|a| a.bam_path.as_deref() == Some(path_str.as_str())) + { summary.alignments_skipped += 1; continue; } let probe_path = aln_path.clone(); let (build, _source) = tokio::task::spawn_blocking(move || detect_build_for(&probe_path)).await?; - let reference_path = self.gateway.cached_reference(&build).map(|p| p.to_string_lossy().into_owned()); + let reference_path = self + .gateway + .cached_reference(&build) + .map(|p| p.to_string_lossy().into_owned()); self.record_alignment(NewAlignment { sequence_run_id: run.id, reference_build: build, @@ -409,14 +419,19 @@ impl App { // also lists as variant files — the guard keeps them out of this loop too. if !sample.sidecars.has_haplogroup_gvcf() { for vcf in &sample.variant_files { - let name = vcf.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let name = vcf + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); match self .import_variants_from_file(biosample_guid, vcf, variants::SourceType::Imported) .await { Ok(_) => { summary.variants_imported += 1; - summary.imported.push((name, DetectedData::Variants.description().to_string())); + summary + .imported + .push((name, DetectedData::Variants.description().to_string())); } Err(e) => summary.skipped.push((name, e.to_string())), } @@ -490,7 +505,10 @@ impl App { /// the CRAM. The external calls land on their own `:ext` keys (they cannot clobber, and with the /// "prefer external caller" policy they win the consensus). Returns `(y_placed, mt_placed)`. /// This is the operational fix for a workspace imported before external-caller precedence. - pub async fn reingest_external_for_biosample(&self, biosample_guid: SampleGuid) -> Result<(usize, usize), AppError> { + pub async fn reingest_external_for_biosample( + &self, + biosample_guid: SampleGuid, + ) -> Result<(usize, usize), AppError> { let alns = alignment::list_for_biosample(self.store.pool(), biosample_guid).await?; let (mut y_placed, mut mt_placed) = (0usize, 0usize); for a in &alns { @@ -554,7 +572,13 @@ impl App { .flat_map(|s| s.alignment_files.iter().cloned()) .collect(); let detected: HashMap = tokio::task::spawn_blocking(move || { - all_paths.into_iter().map(|p| { let d = detect_build_for(&p); (p, d) }).collect() + all_paths + .into_iter() + .map(|p| { + let d = detect_build_for(&p); + (p, d) + }) + .collect() }) .await?; @@ -580,13 +604,12 @@ impl App { // Effective build: keep the detected one when the gateway recognizes it (or an explicit // FASTA overrides everything); otherwise fall back to the default so unlabeled files // still import instead of killing the batch. - let (effective, defaulted) = if explicit.is_some() - || !matches!(self.gateway.reference_status(detected_build), RefStatus::Unknown) - { - (detected_build.clone(), false) - } else { - (DEFAULT_IMPORT_BUILD.to_string(), true) - }; + let (effective, defaulted) = + if explicit.is_some() || !matches!(self.gateway.reference_status(detected_build), RefStatus::Unknown) { + (detected_build.clone(), false) + } else { + (DEFAULT_IMPORT_BUILD.to_string(), true) + }; effective_of.insert(detected_build.clone(), effective.clone()); // Resolve the effective build to a FASTA once (explicit > already-resolved > cache > @@ -603,7 +626,11 @@ impl App { RefStatus::Cached(p) | RefStatus::LocalOverride(p) => Some(p.to_string_lossy().into_owned()), RefStatus::NeedsDownload { url, est_bytes } => { if !needs.iter().any(|n| n.build == effective) { - needs.push(BuildNeed { build: effective.clone(), url, est_bytes }); + needs.push(BuildNeed { + build: effective.clone(), + url, + est_bytes, + }); } None } @@ -669,7 +696,15 @@ impl App { for (i, sample) in discovered.samples.iter().enumerate() { progress(i, total, &sample.sample_id); if let Err(e) = self - .import_project_sample(sample, &project, fast_path, &detected, &effective_of, &resolved, &mut summary) + .import_project_sample( + sample, + &project, + fast_path, + &detected, + &effective_of, + &resolved, + &mut summary, + ) .await { eprintln!( @@ -716,8 +751,14 @@ impl App { }; // Ensure the subject is a member of this project (idempotent on the (guid, project) PK). // A reused subject whose *home* project is another one still joins this project's roster. - biosample_project::add(self.store.pool(), biosample.guid, project.id, None, &Utc::now().to_rfc3339()) - .await?; + biosample_project::add( + self.store.pool(), + biosample.guid, + project.id, + None, + &Utc::now().to_rfc3339(), + ) + .await?; // SequenceRun: reuse the first existing run, else create one (defaults to WGS). let run = match sequence_run::list_for_biosample(self.store.pool(), biosample.guid) @@ -937,13 +978,19 @@ impl App { let previous = std::fs::read(&manifest_path).ok(); let _ = std::fs::remove_file(&manifest_path); // else the gateway serves the cached copy let url = format!("{base}/{manifest_name}"); - match self.gateway.resolve_ancestry_asset(&manifest_name, &url, &mut |_, _| {}).await { + match self + .gateway + .resolve_ancestry_asset(&manifest_name, &url, &mut |_, _| {}) + .await + { Ok(_) => manifest = load_asset_manifest(build), Err(e) => { if let Some(bytes) = previous { let _ = std::fs::write(&manifest_path, bytes); } - eprintln!("ancestry assets: could not fetch {manifest_name} ({e}) — leaving {name} to on-disk state"); + eprintln!( + "ancestry assets: could not fetch {manifest_name} ({e}) — leaving {name} to on-disk state" + ); return Ok(()); } } @@ -1114,7 +1161,11 @@ impl App { /// **no CRAM decode**, re-keys to canonical CHM13 (`resolve_chip`), stores the dosages as an /// `external` source, and refreshes the autosomal consensus. Build is auto-detected from the VCF /// header (`NAVIGATOR_CALLSET_BUILD` overrides). Returns the number of resolved panel sites. - pub async fn import_gvcf_callset_from_file(&self, biosample_guid: SampleGuid, path: &Path) -> Result { + pub async fn import_gvcf_callset_from_file( + &self, + biosample_guid: SampleGuid, + path: &Path, + ) -> Result { let build = callset_build_for(path); let panel = self.load_ibd_panel().await?; @@ -1166,9 +1217,12 @@ impl App { .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "external VCF".into()); - self.store_external_dosages(biosample_guid, &format!("{label} (1240K call set, {build})"), dosages, || { - format!("the VCF genotyped 0 panel sites on {build} ({called} calls) — check the build/VCF") - }) + self.store_external_dosages( + biosample_guid, + &format!("{label} (1240K call set, {build})"), + dosages, + || format!("the VCF genotyped 0 panel sites on {build} ({called} calls) — check the build/VCF"), + ) .await } @@ -1186,11 +1240,14 @@ impl App { if site_count == 0 { return Err(AppError::Import(on_empty())); } - let json = serde_json::to_string(&dosages).map_err(|e| AppError::Import(format!("serializing dosages: {e}")))?; + let json = + serde_json::to_string(&dosages).map_err(|e| AppError::Import(format!("serializing dosages: {e}")))?; let row = navigator_store::external_panel_dosage::StoredPanelDosage { biosample_guid: biosample_guid.0.to_string(), source_label: source_label.to_string(), - provenance: navigator_domain::reconciliation::CallProvenance::External.as_str().to_string(), + provenance: navigator_domain::reconciliation::CallProvenance::External + .as_str() + .to_string(), panel_sig: Some(ibd_panel_cache_kind()), site_count: site_count as i64, dosages: json, @@ -1419,7 +1476,10 @@ pub(crate) enum AssetAction { Skip, } -pub(crate) fn asset_action(entry: Option<&navigator_analysis::manifest::AssetEntry>, on_disk: Option) -> AssetAction { +pub(crate) fn asset_action( + entry: Option<&navigator_analysis::manifest::AssetEntry>, + on_disk: Option, +) -> AssetAction { match (entry, on_disk) { (None, _) => AssetAction::Skip, (Some(_), None) => AssetAction::Download, @@ -1434,7 +1494,10 @@ mod asset_tests { use navigator_analysis::manifest::AssetEntry; fn entry(bytes: u64) -> AssetEntry { - AssetEntry { sha256: "deadbeef".into(), bytes } + AssetEntry { + sha256: "deadbeef".into(), + bytes, + } } #[test] @@ -1446,7 +1509,10 @@ mod asset_tests { assert_eq!(asset_action(Some(&entry(100)), None), AssetAction::Download); assert_eq!(asset_action(Some(&entry(100)), Some(100)), AssetAction::Ready); // The case a plain existence check misses: a locally-present asset the release has revised. - assert_eq!(asset_action(Some(&entry(139_815_581)), Some(13_774_065)), AssetAction::Replace); + assert_eq!( + asset_action(Some(&entry(139_815_581)), Some(13_774_065)), + AssetAction::Replace + ); // …and a truncated download. assert_eq!(asset_action(Some(&entry(100)), Some(41)), AssetAction::Replace); } diff --git a/crates/navigator-app/src/lib.rs b/crates/navigator-app/src/lib.rs index e06d3039..788cf963 100644 --- a/crates/navigator-app/src/lib.rs +++ b/crates/navigator-app/src/lib.rs @@ -28,29 +28,26 @@ pub use navigator_analysis::haplo::{BranchEvidence, CallState, NodeEvidence, Sco pub use navigator_analysis::heteroplasmy::HeteroplasmySite; pub use navigator_analysis::mask::YRegionClass; pub use navigator_analysis::mtvariants::{MtRegion, MtVariant, MtVariantKind}; -pub use navigator_analysis::CancelToken; pub use navigator_analysis::preflight::{ Check as PreflightCheck, Report as PreflightReport, Status as PreflightStatus, }; +pub use navigator_analysis::CancelToken; /// Diagnose a BAM/CRAM **path** with no workspace record behind it — the case that matters when a /// user is reporting a file the app refuses to read and we need the answer before deciding whether /// importing it is even possible. Blocking; call it off the async runtime. -pub fn diagnose_alignment_file( - alignment: &std::path::Path, - reference: Option<&std::path::Path>, -) -> PreflightReport { +pub fn diagnose_alignment_file(alignment: &std::path::Path, reference: Option<&std::path::Path>) -> PreflightReport { navigator_analysis::preflight::diagnose(alignment, reference) } -pub use navigator_analysis::probe::AlignmentProbe; -pub use navigator_analysis::read_metrics::{PairOrientation, ReadMetrics}; -pub use navigator_analysis::archaic_segments::{ - ArchaicConfig, ArchaicSegment, ArchaicSegmentResult, ArchaicSource, ArchaicSummary, -}; pub use navigator_analysis::archaic::{ ArchaicCallable, ArchaicClassify, ArchaicCountDistribution, ArchaicMarkerPanel, ArchaicMarkerResult, ArchaicOutgroup, DiagnosticClass, }; +pub use navigator_analysis::archaic_segments::{ + ArchaicConfig, ArchaicSegment, ArchaicSegmentResult, ArchaicSource, ArchaicSummary, +}; +pub use navigator_analysis::probe::AlignmentProbe; +pub use navigator_analysis::read_metrics::{PairOrientation, ReadMetrics}; pub use navigator_analysis::roh::{RohConfig, RohPattern, RohResult, RohSegment, RohSummary}; pub use navigator_analysis::sex::{Confidence as SexConfidence, InferredSex, SexInferenceResult}; pub use navigator_analysis::sv::types::{SvAnalysisResult, SvCall, SvType}; @@ -304,6 +301,7 @@ mod publish_gate_tests { #[test] fn publish_gate_admits_only_confident_unique_novels() { let g = PublishGate::default(); // af >= 0.9, alt_depth >= 10 + // The one that should publish: novel, unique, homozygous, deep. assert!(g.admits(&var(PrivateClass::Novel, None, 30, 1.0))); // Off-path-known is informational, never a novel-branch claim. @@ -329,7 +327,7 @@ mod publish_gate_tests { let bucket = PrivateBucket { terminal: "R-FGC29071".into(), variants: vec![ - var(PrivateClass::Novel, None, 30, 1.0), // publishable + var(PrivateClass::Novel, None, 30, 1.0), // publishable var(PrivateClass::Novel, Some(YRegionClass::Amplicon), 30, 1.0), // structural → no var(PrivateClass::OffPathKnown("Z".into()), None, 30, 1.0), // off-path → no var(PrivateClass::Novel, None, 2, 1.0), // shallow → no @@ -370,9 +368,9 @@ use navigator_sync::{ /// assert they are *not* on the real keychain. pub use navigator_sync::{os_keychain_enabled, use_os_keychain}; pub use navigator_sync::{ - AlignmentRecord, BiosampleRecord, ContigMetrics, FeedPostRecord, PdsClient, PopulationBreakdownRecord, PrivateVariantsRecord, - RecordRef, SequenceRunRecord, VariantCallEntry, NS_ALIGNMENT, NS_BIOSAMPLE, NS_FEED_POST, NS_POPULATION_BREAKDOWN, - NS_SEQUENCERUN, PRIVATE_VARIANTS_COLLECTION, + AlignmentRecord, BiosampleRecord, ContigMetrics, FeedPostRecord, PdsClient, PopulationBreakdownRecord, + PrivateVariantsRecord, RecordRef, SequenceRunRecord, VariantCallEntry, NS_ALIGNMENT, NS_BIOSAMPLE, NS_FEED_POST, + NS_POPULATION_BREAKDOWN, NS_SEQUENCERUN, PRIVATE_VARIANTS_COLLECTION, }; use navigator_sync::{ AuditEntryRecord, HaplogroupReconciliationRecord, HeteroplasmyObservationRecord, IdentityVerificationRecord, @@ -803,10 +801,10 @@ pub use navigator_store::ibd_exchange::StoredIbdExchange; pub use navigator_store::ibd_request::StoredIbdRequest; pub use navigator_store::source_file::SourceFile; use navigator_store::{ - alignment, ancestry_result, artifact, biosample, biosample_project, chip_profile, consensus_painting, - consensus_archaic, consensus_archaic_segments, consensus_profile, consensus_roh, haplogroup_call, mtdna as mtdna_store, project, reconciliation as recon_store, - sequence_run, - source_file, str_profile, sync_history, sync_outbox, sync_state, variant_set, Store, StoreError, + alignment, ancestry_result, artifact, biosample, biosample_project, chip_profile, consensus_archaic, + consensus_archaic_segments, consensus_painting, consensus_profile, consensus_roh, haplogroup_call, + mtdna as mtdna_store, project, reconciliation as recon_store, sequence_run, source_file, str_profile, sync_history, + sync_outbox, sync_state, variant_set, Store, StoreError, }; use serde::de::DeserializeOwned; use serde::Serialize; @@ -832,9 +830,7 @@ fn tree_cache_path(file: &str) -> PathBuf { let dir = std::env::var("NAVIGATOR_TREE_DIR") .ok() .map(PathBuf::from) - .unwrap_or_else(|| { - navigator_domain::paths::decodingus_dir().join("trees") - }); + .unwrap_or_else(|| navigator_domain::paths::decodingus_dir().join("trees")); dir.join(file) } @@ -909,7 +905,11 @@ where // Highest weight wins; on a tie break by the allele itself so the pooled call is // deterministic (a `HashMap` iteration order otherwise picked the winner at random, // which flipped the placed terminal between runs over identical genotypes). - .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0))) + .max_by(|a, b| { + a.1.partial_cmp(&b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.0.cmp(&b.0)) + }) .map(|(v, _)| (k, v)) }) .collect() @@ -1426,10 +1426,10 @@ fn bundled_masks_dir() -> Option { let dir = exe.parent()?; [ dir.join("../Resources/masks"), // macOS .app/Contents/MacOS → ../Resources - dir.join("masks"), // Windows (alongside) / portable - dir.join("../lib/DUNavigator/masks"), // Linux .deb/AppImage usr/bin → usr/lib/ - dir.join("../share/DUNavigator/masks"), // Linux usr/share/ - dir.join("resources/masks"), // generic + dir.join("masks"), // Windows (alongside) / portable + dir.join("../lib/DUNavigator/masks"), // Linux .deb/AppImage usr/bin → usr/lib/ + dir.join("../share/DUNavigator/masks"), // Linux usr/share/ + dir.join("resources/masks"), // generic ] .into_iter() .find(|c| c.is_dir()) @@ -1461,9 +1461,9 @@ fn bundled_str_dir() -> Option { let exe = std::env::current_exe().ok()?; let dir = exe.parent()?; [ - dir.join("../Resources/str"), // macOS .app/Contents/MacOS → ../Resources - dir.join("str"), // Windows (alongside) / portable - dir.join("../lib/DUNavigator/str"), // Linux .deb/AppImage usr/bin → usr/lib/ + dir.join("../Resources/str"), // macOS .app/Contents/MacOS → ../Resources + dir.join("str"), // Windows (alongside) / portable + dir.join("../lib/DUNavigator/str"), // Linux .deb/AppImage usr/bin → usr/lib/ dir.join("../share/DUNavigator/str"), dir.join("resources/str"), ] @@ -2928,7 +2928,6 @@ fn ibd_panel_cache_kind() -> String { /// Cache kind for per-alignment archaic-panel genotypes. const ARCHAIC_PANEL_KIND: &str = "archaic_panel_genotypes"; - /// The archaic-panel genotype cache kind, salted with the panel asset's manifest sha256 exactly as /// [`ibd_panel_cache_kind`] is — the archaic panel's site list changes whenever its thresholds are /// recalibrated, and serving genotypes taken over an older site set would silently corrupt the count. @@ -3782,7 +3781,8 @@ mod placement_tests { let mut called = gvcf::CalledBases::default(); called.variant_bases.extend([(146, 'G'), (263, 'G'), (1000, 'A')]); called.callable.extend([146, 263, 750, 1000]); // 750 hom-ref → its reference base - // The reference carries the *derived* T at 750 (shared backbone the sample also has). + + // The reference carries the *derived* T at 750 (shared backbone the sample also has). let ref_base: HashMap = [(750, 'T')].into_iter().collect(); let calls = gvcf::assemble_calls(&called, &ref_base); assert_eq!( @@ -3870,14 +3870,18 @@ mod external_precedence_tests { .unwrap(); // No clobber: both rows survive under their distinct keys. - assert!(haplogroup_call::get_one(app.store.pool(), bio.guid, DnaType::Y, &external_y_source_key(1)) - .await - .unwrap() - .is_some()); - assert!(haplogroup_call::get_one(app.store.pool(), bio.guid, DnaType::Y, "aln:1") - .await - .unwrap() - .is_some()); + assert!( + haplogroup_call::get_one(app.store.pool(), bio.guid, DnaType::Y, &external_y_source_key(1)) + .await + .unwrap() + .is_some() + ); + assert!( + haplogroup_call::get_one(app.store.pool(), bio.guid, DnaType::Y, "aln:1") + .await + .unwrap() + .is_some() + ); // Default policy prefers external → external terminal wins despite the walk's higher score. let c = app.haplogroup_consensus(bio.guid, DnaType::Y).await.unwrap().unwrap(); @@ -3927,9 +3931,17 @@ mod publish_tests { .await .unwrap(); // Exact yield → the standardized label's Gbases figure. - sequence_run::set_read_stats(app.store.pool(), run.id, Some(300_000_000), Some(150.0), None, None, Some(45_000_000_000)) - .await - .unwrap(); + sequence_run::set_read_stats( + app.store.pool(), + run.id, + Some(300_000_000), + Some(150.0), + None, + None, + Some(45_000_000_000), + ) + .await + .unwrap(); sequence_run::set_facility(app.store.pool(), run.id, "Dante Labs") .await .unwrap(); @@ -3939,7 +3951,10 @@ mod publish_tests { assert_eq!(value.get("instrumentId").and_then(|v| v.as_str()), Some("A00182")); // The known sequencing lab is published so the AppView can display it (its instrument→lab // map doesn't cover every serial, e.g. PacBio). - assert_eq!(value.get("sequencingFacility").and_then(|v| v.as_str()), Some("Dante Labs")); + assert_eq!( + value.get("sequencingFacility").and_then(|v| v.as_str()), + Some("Dante Labs") + ); // Read-profile fields backing the standardized label are published. assert_eq!(value.get("totalBases").and_then(|v| v.as_i64()), Some(45_000_000_000)); assert_eq!(value.get("readType").and_then(|v| v.as_str()), Some("SHORT")); @@ -3962,12 +3977,18 @@ mod publish_tests { app.add_external_id(b.guid, "PGP", "huF98AFD").await.unwrap(); let value = app.biosample_record("did:plc:test", b.guid).await.unwrap(); - let ids = value.get("externalIds").and_then(|v| v.as_array()).expect("externalIds present"); + let ids = value + .get("externalIds") + .and_then(|v| v.as_array()) + .expect("externalIds present"); let mut pairs: Vec<(String, String)> = ids .iter() .map(|e| { ( - e.get("namespace").and_then(|v| v.as_str()).unwrap_or_default().to_string(), + e.get("namespace") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), e.get("value").and_then(|v| v.as_str()).unwrap_or_default().to_string(), ) }) @@ -4191,7 +4212,9 @@ mod ibd_attest_tests { // The ledger adopts a conversation it never saw opened, so the completed exchange still // reads as one entry with its result attached rather than an orphan row. - app.mark_matching_exchanged(b.guid, &session, "exchange:r").await.unwrap(); + app.mark_matching_exchanged(b.guid, &session, "exchange:r") + .await + .unwrap(); let entries = app.matching_entries().await.unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].status, MatchingStatus::Exchanged); @@ -4212,9 +4235,13 @@ mod ibd_attest_tests { partner_did: "did:key:zC".into(), key: [0u8; 32], }; - app.mark_matching_exchanged(b.guid, &session, "exchange:f").await.unwrap(); + app.mark_matching_exchanged(b.guid, &session, "exchange:f") + .await + .unwrap(); - app.record_matching_failure("exchange:f", "relay timeout").await.unwrap(); + app.record_matching_failure("exchange:f", "relay timeout") + .await + .unwrap(); let e = app.matching_entry("exchange:f").await.unwrap(); assert_eq!(e.status, MatchingStatus::Failed); assert_eq!(e.last_error.as_deref(), Some("relay timeout")); @@ -4267,7 +4294,9 @@ mod ibd_attest_tests { app.record_ibd_exchange(b.guid, &session, "exchange:a", &result) .await .unwrap(); - app.mark_matching_exchanged(b.guid, &session, "exchange:a").await.unwrap(); + app.mark_matching_exchanged(b.guid, &session, "exchange:a") + .await + .unwrap(); // No sample handles (a direct request never carries them) → nothing to attest, no network. assert!(!app.attest_exchange_if_possible("exchange:a").await.unwrap()); @@ -4387,7 +4416,11 @@ mod ibd_federated_tests { assert_eq!(at(0.49), MatchStrength::Possible); assert_eq!(at(0.0), MatchStrength::Possible); // A missing score parses as 0.0, which must read as the weakest claim, never the strongest. - assert_eq!(at(f64::NAN), MatchStrength::Possible, "an unusable score must not overstate"); + assert_eq!( + at(f64::NAN), + MatchStrength::Possible, + "an unusable score must not overstate" + ); } } @@ -4538,10 +4571,16 @@ mod settings_tests { let prod = resolve_oauth_config(None); assert_eq!(prod.client_id(redirect), DEFAULT_OAUTH_CLIENT_ID); assert_eq!(prod.scope, OAUTH_SCOPE); - assert!(prod.scope.contains("transition:generic"), "publishing needs write scope"); + assert!( + prod.scope.contains("transition:generic"), + "publishing needs write scope" + ); // Blank is ignored → still the hosted default. - assert_eq!(resolve_oauth_config(Some(" ".into())).client_id(redirect), DEFAULT_OAUTH_CLIENT_ID); + assert_eq!( + resolve_oauth_config(Some(" ".into())).client_id(redirect), + DEFAULT_OAUTH_CLIENT_ID + ); // `loopback` selects the dev client (client_id derived from the loopback redirect). let dev = resolve_oauth_config(Some("loopback".into())); diff --git a/crates/navigator-app/src/publish.rs b/crates/navigator-app/src/publish.rs index e4437342..11085729 100644 --- a/crates/navigator-app/src/publish.rs +++ b/crates/navigator-app/src/publish.rs @@ -30,7 +30,9 @@ impl App { ); if is_wgs && navigator_analysis::sex::is_y_scoped( - cov.contig_coverage_stats.iter().map(|s| (s.contig.as_str(), s.num_reads)), + cov.contig_coverage_stats + .iter() + .map(|s| (s.contig.as_str(), s.num_reads)), ) { return Err(AppError::Conflict(format!( @@ -279,7 +281,9 @@ impl App { biosample_guid: SampleGuid, ) -> Result { let value = self.biosample_record(client.did(), biosample_guid).await?; - Ok(client.create_record(NS_BIOSAMPLE, value, Some(&biosample_rkey(biosample_guid))).await?) + Ok(client + .create_record(NS_BIOSAMPLE, value, Some(&biosample_rkey(biosample_guid))) + .await?) } /// Publish a sequence-run characterization using an explicit `client`. @@ -289,7 +293,9 @@ impl App { run: &SequenceRun, ) -> Result { let value = self.sequence_run_record(client.did(), run).await?; - Ok(client.create_record(NS_SEQUENCERUN, value, Some(&seqrun_rkey(run.id))).await?) + Ok(client + .create_record(NS_SEQUENCERUN, value, Some(&seqrun_rkey(run.id))) + .await?) } /// Publish an alignment's cached de-novo calls for `contig` using an explicit `client` @@ -433,7 +439,9 @@ mod tests { ], ..Default::default() }; - app.save_analysis(aln, "coverage", COVERAGE_VERSION, &wgs).await.unwrap(); + app.save_analysis(aln, "coverage", COVERAGE_VERSION, &wgs) + .await + .unwrap(); app.coverage_record("did:plc:test", aln) .await .expect("normal WGS coverage should publish"); diff --git a/crates/navigator-app/src/queries.rs b/crates/navigator-app/src/queries.rs index 8f8d5b8a..116124bc 100644 --- a/crates/navigator-app/src/queries.rs +++ b/crates/navigator-app/src/queries.rs @@ -82,11 +82,8 @@ impl App { /// [`haplogroup_terminals`](Self::haplogroup_terminals)). A subject is `Complete` once every /// alignment it owns has a full `coverage` artifact at the current version; otherwise `Pending`. /// Subjects with no alignments are omitted (the list shows no status for them). - pub async fn subject_analysis_status( - &self, - ) -> Result, AppError> { - let census = - artifact::analyzed_census(self.store.pool(), "coverage", coverage::COVERAGE_VERSION).await?; + pub async fn subject_analysis_status(&self) -> Result, AppError> { + let census = artifact::analyzed_census(self.store.pool(), "coverage", coverage::COVERAGE_VERSION).await?; Ok(census .into_iter() .map(|(guid, total, analyzed)| { @@ -191,8 +188,11 @@ impl App { // evidence (resolves sidecar-imported UNKNOWN-platform runs), then an optional file // rescan for long reads that need the read names to tell HiFi from CLR. if run.read_type.is_none() { - let inferred = infer_read_type_cheap(&run.platform_name, &run.test_type) - .or_else(|| metrics.as_ref().and_then(|(_, m)| read_type_from_mean_len(m.mean_read_length))); + let inferred = infer_read_type_cheap(&run.platform_name, &run.test_type).or_else(|| { + metrics + .as_ref() + .and_then(|(_, m)| read_type_from_mean_len(m.mean_read_length)) + }); match inferred { Some(rt) => { sequence_run::set_read_type(self.store.pool(), run.id, rt).await?; @@ -295,7 +295,10 @@ impl App { /// (ancient) breakdown here — a separate report — instead of the modern super-population one. pub async fn donor_ancestry(&self, biosample_guid: SampleGuid) -> Result, AppError> { let all = ancestry_result::for_biosample(self.store.pool(), biosample_guid).await?; - if let Some(c) = all.iter().find(|(id, r)| *id == CONSENSUS_SOURCE_ID && r.method == "ADMIXTURE") { + if let Some(c) = all + .iter() + .find(|(id, r)| *id == CONSENSUS_SOURCE_ID && r.method == "ADMIXTURE") + { return Ok(Some(c.clone())); } Ok(all @@ -434,7 +437,9 @@ impl App { metrics = artifacts.fresh(a.id, "read_metrics", "1"); } if sv_count.is_none() { - sv_count = artifacts.fresh::(a.id, "sv", "1").map(|s| s.sv_calls.len()); + sv_count = artifacts + .fresh::(a.id, "sv", "1") + .map(|s| s.sv_calls.len()); } } let sex = sex.map(|s| match s.inferred_sex { diff --git a/crates/navigator-app/src/sync.rs b/crates/navigator-app/src/sync.rs index 5729d69e..a6cbbee8 100644 --- a/crates/navigator-app/src/sync.rs +++ b/crates/navigator-app/src/sync.rs @@ -65,11 +65,12 @@ impl App { let did = self.require_account()?; // Accounted-for rkeys: everything tracked in sync_state for the alignment collection, plus // the deterministic key for every live local alignment (so a not-yet-drained one isn't culled). - let mut keep: std::collections::HashSet = sync_state::list_for_collection(self.store.pool(), &did, NS_ALIGNMENT) - .await? - .into_iter() - .map(|s| s.rkey) - .collect(); + let mut keep: std::collections::HashSet = + sync_state::list_for_collection(self.store.pool(), &did, NS_ALIGNMENT) + .await? + .into_iter() + .map(|s| s.rkey) + .collect(); for a in alignment::list_all(self.store.pool()).await? { keep.insert(alignment_rkey(a.id)); } diff --git a/crates/navigator-app/tests/app.rs b/crates/navigator-app/tests/app.rs index 27a528c3..04379c1d 100644 --- a/crates/navigator-app/tests/app.rs +++ b/crates/navigator-app/tests/app.rs @@ -952,7 +952,14 @@ async fn save_analysis_no_downgrade_keeps_the_fuller_result() { // No artifact yet → the sidecar write goes through. let wrote = app - .save_analysis_no_downgrade(aln, "coverage", "v1", &serde_json::json!({"m": 1}), "pipeline-sidecar", "partial") + .save_analysis_no_downgrade( + aln, + "coverage", + "v1", + &serde_json::json!({"m": 1}), + "pipeline-sidecar", + "partial", + ) .await .unwrap(); assert!(wrote, "first sidecar write with nothing present"); @@ -964,7 +971,14 @@ async fn save_analysis_no_downgrade_keeps_the_fuller_result() { // Reimport: a partial sidecar must NOT clobber the full deep walk. let wrote = app - .save_analysis_no_downgrade(aln, "coverage", "v1", &serde_json::json!({"m": 3}), "pipeline-sidecar", "partial") + .save_analysis_no_downgrade( + aln, + "coverage", + "v1", + &serde_json::json!({"m": 3}), + "pipeline-sidecar", + "partial", + ) .await .unwrap(); assert!(!wrote, "partial must not downgrade a full result"); @@ -1198,7 +1212,11 @@ async fn add_data_imports_completegenomics_master_var() { assert_eq!(sets.len(), 1); let set = &sets[0]; assert_eq!(set.reference_build.as_deref(), Some("GRCh37")); - assert_eq!(set.calls.len(), 3, "two SNP loci on chr1 + one on chrY; the no-ref span is dropped"); + assert_eq!( + set.calls.len(), + 3, + "two SNP loci on chr1 + one on chrY; the no-ref span is dropped" + ); let hom = set.calls.iter().find(|c| c.position == 21580).unwrap(); assert_eq!((hom.reference.as_str(), hom.alternate.as_str()), ("C", "T")); assert_eq!(hom.genotype.as_deref(), Some("1/1")); @@ -1472,7 +1490,8 @@ async fn diploid_alignment(app: &App) -> i64 { async fn publish_coverage_summary_requires_cached_coverage() { let app = app().await; let aln = diploid_alignment(&app).await; // has a BAM but no coverage run - // Bearer client is never reached — the missing-coverage check fails first. + + // Bearer client is never reached — the missing-coverage check fails first. let client = navigator_app::PdsClient::bearer(reqwest::Client::new(), "http://127.0.0.1:1", "did:plc:x", "tok"); let err = app.publish_coverage_summary(&client, aln).await; assert!( @@ -1681,13 +1700,22 @@ async fn reimport_under_different_project_name_reuses_subject() { }; let a = stage("a"); - let s1 = app.import_project_dir(&a, Some(reference.clone()), "t".into(), false).await.unwrap(); + let s1 = app + .import_project_dir(&a, Some(reference.clone()), "t".into(), false) + .await + .unwrap(); assert_eq!(s1.samples_created, 1); // Same sample, different folder name → a distinct project, but the SAME person. let b = stage("b"); - let s2 = app.import_project_dir(&b, Some(reference), "t".into(), false).await.unwrap(); - assert_ne!(s2.project.id, s1.project.id, "a different folder name is a different project"); + let s2 = app + .import_project_dir(&b, Some(reference), "t".into(), false) + .await + .unwrap(); + assert_ne!( + s2.project.id, s1.project.id, + "a different folder name is a different project" + ); assert_eq!(s2.samples_created, 0, "the subject is reused, not duplicated"); // Exactly one subject in the workspace, and it's a roster member of BOTH projects. @@ -1705,7 +1733,11 @@ async fn delete_project_detaches_members_and_keeps_subjects() { // not refuse ("N subjects still belong to it"). The subjects themselves survive. let app = app().await; let p = app - .create_project(NewProject { name: "P".into(), description: None, administrator: "t".into() }) + .create_project(NewProject { + name: "P".into(), + description: None, + administrator: "t".into(), + }) .await .unwrap(); let b = app.add_biosample(Some(p.id), "S1", None, None).await.unwrap(); @@ -1713,9 +1745,20 @@ async fn delete_project_detaches_members_and_keeps_subjects() { app.delete_project(p.id).await.unwrap(); - assert!(app.project_overview().await.unwrap().iter().all(|o| o.project.id != p.id), "project removed"); assert!( - app.list_all_biosamples().await.unwrap().iter().any(|x| x.guid == b.guid), + app.project_overview() + .await + .unwrap() + .iter() + .all(|o| o.project.id != p.id), + "project removed" + ); + assert!( + app.list_all_biosamples() + .await + .unwrap() + .iter() + .any(|x| x.guid == b.guid), "subject survives the project deletion" ); } @@ -1959,7 +2002,10 @@ async fn analyze_project_runs_coverage_and_attempts_y_per_sample() { .await .unwrap(); - let s = app.analyze_project(p.id, navigator_app::CancelToken::none()).await.unwrap(); + let s = app + .analyze_project(p.id, navigator_app::CancelToken::none()) + .await + .unwrap(); assert_eq!(s.samples, 1); assert_eq!(s.coverage_done, 1, "coverage computed on the CRAM"); // Y was attempted: recorded, or (here) errored on the chrM-only fixture lacking chrY. @@ -2826,8 +2872,16 @@ async fn branch_report_genotypes_the_mt_subtree_end_to_end() { // no-call, and the asymmetric SNV test would have mislabeled the empty allele as a clean SNV. let ins = row("41.1A"); assert_eq!(ins.state, CallState::NoCall); - assert!(ins.note.contains("indel/MNV"), "insertion must be flagged as an indel: {:?}", ins.note); - assert!(ins.note.contains("no call"), "and still surface the no-call: {:?}", ins.note); + assert!( + ins.note.contains("indel/MNV"), + "insertion must be flagged as an indel: {:?}", + ins.note + ); + assert!( + ins.note.contains("no call"), + "and still surface the no-call: {:?}", + ins.note + ); // The tallies match the rows (the insertion is a no-call). let (d, a, n) = report.counts(); @@ -2844,7 +2898,10 @@ async fn mt_alignment_pick_skips_a_y_only_run() { use navigator_app::DnaType; let app = app().await; - let b = app.add_biosample(None, "S-pick", None, Some("male".into())).await.unwrap(); + let b = app + .add_biosample(None, "S-pick", None, Some("male".into())) + .await + .unwrap(); // A Big-Y (Y-only) run, recorded first so it's a candidate for both pickers. let y_run = app @@ -2915,7 +2972,10 @@ async fn mt_alignment_pick_skips_a_y_only_run() { "Y still prefers the CHM13/pbmm2 Big-Y alignment" ); // Dispatch helper routes each DNA type to its picker. - assert_eq!(app.pick_alignment_for(b.guid, DnaType::Mt).await.unwrap(), Some(wgs_aln)); + assert_eq!( + app.pick_alignment_for(b.guid, DnaType::Mt).await.unwrap(), + Some(wgs_aln) + ); assert_eq!(app.pick_alignment_for(b.guid, DnaType::Y).await.unwrap(), Some(y_aln)); } @@ -2935,7 +2995,10 @@ async fn add_sample_dir_records_alignment_from_header_no_decode_and_is_idempoten std::fs::copy(fx.join("coverage.cram.crai"), dir.join("s.chm13.chrYM.cram.crai")).unwrap(); std::fs::write(dir.join("coverage.txt"), "#rname\tstartpos\tendpos\tnumreads\n").unwrap(); - let subject = app.add_biosample(None, "S-KIT", None, Some("male".into())).await.unwrap(); + let subject = app + .add_biosample(None, "S-KIT", None, Some("male".into())) + .await + .unwrap(); let s = app.add_sample_dir(subject.guid, &dir, false).await.unwrap(); assert_eq!(s.alignments_created, 1); @@ -3000,11 +3063,17 @@ async fn add_sample_dir_skips_called_vcf_when_gvcf_present() { std::fs::write(dir.join("gatk4/chrY.g.vcf.gz"), b"not-a-real-gvcf").unwrap(); std::fs::write(dir.join("gatk4/chrY.vcf.gz"), b"##fileformat=VCFv4.2\n").unwrap(); - let subject = app.add_biosample(None, "S-GVCF", None, Some("male".into())).await.unwrap(); + let subject = app + .add_biosample(None, "S-GVCF", None, Some("male".into())) + .await + .unwrap(); let s = app.add_sample_dir(subject.guid, &dir, true).await.unwrap(); assert_eq!(s.alignments_created, 1); - assert_eq!(s.variants_imported, 0, "called chrY.vcf.gz must be skipped when a GVCF is present"); + assert_eq!( + s.variants_imported, 0, + "called chrY.vcf.gz must be skipped when a GVCF is present" + ); assert!(s.sidecars_ingested, "the GVCF fast path was attempted"); assert_eq!(app.list_variant_sets(subject.guid).await.unwrap().len(), 0); diff --git a/crates/navigator-app/tests/mastervar_autosomal_real.rs b/crates/navigator-app/tests/mastervar_autosomal_real.rs index 86bf42cc..5578d10f 100644 --- a/crates/navigator-app/tests/mastervar_autosomal_real.rs +++ b/crates/navigator-app/tests/mastervar_autosomal_real.rs @@ -52,9 +52,15 @@ async fn mastervar_feeds_autosomal_and_ancestry() { profile.variants.len() ); for s in &profile.sources { - println!(" source: {} ({:?}) — {} sites", s.label, s.source_type, s.variant_count); + println!( + " source: {} ({:?}) — {} sites", + s.label, s.source_type, s.variant_count + ); } - assert!(!profile.sources.is_empty(), "the masterVar should be an autosomal source"); + assert!( + !profile.sources.is_empty(), + "the masterVar should be an autosomal source" + ); assert!( profile.variants.len() > 10_000, "a genome-wide source should densify to a large panel overlap, got {}", diff --git a/crates/navigator-domain/src/brief.rs b/crates/navigator-domain/src/brief.rs index 9eda4734..496bc6af 100644 --- a/crates/navigator-domain/src/brief.rs +++ b/crates/navigator-domain/src/brief.rs @@ -565,8 +565,14 @@ mod tests { #[test] fn age_rounding_is_friendly() { - assert_eq!(age_phrase(Lang::En, Some(4237)).unwrap(), "formed roughly 4,200 years ago"); - assert_eq!(age_phrase(Lang::En, Some(63500)).unwrap(), "formed roughly 64,000 years ago"); + assert_eq!( + age_phrase(Lang::En, Some(4237)).unwrap(), + "formed roughly 4,200 years ago" + ); + assert_eq!( + age_phrase(Lang::En, Some(63500)).unwrap(), + "formed roughly 64,000 years ago" + ); assert_eq!(age_phrase(Lang::En, Some(842)).unwrap(), "formed roughly 850 years ago"); assert_eq!(age_phrase(Lang::En, None), None); assert_eq!(age_phrase(Lang::En, Some(0)), None); @@ -574,7 +580,10 @@ mod tests { #[test] fn origin_phrasing() { - assert_eq!(origin_phrase(Lang::En, Some("the steppe")).unwrap(), "associated with the steppe"); + assert_eq!( + origin_phrase(Lang::En, Some("the steppe")).unwrap(), + "associated with the steppe" + ); assert_eq!(origin_phrase(Lang::En, None), None); assert_eq!(origin_phrase(Lang::En, Some(" ")), None); } @@ -598,7 +607,10 @@ mod tests { let mixed = roh_brief(Lang::En, RohPattern::Mixed, 0.05, 30, 150.0, 18.0); assert_eq!(mixed.pattern, "Mixed shared ancestry"); // No runs at all always reads as outbred, whatever the classifier says of an empty set. - assert_eq!(roh_brief(Lang::En, RohPattern::Mixed, 0.0, 0, 0.0, 0.0).pattern, "Outbred"); + assert_eq!( + roh_brief(Lang::En, RohPattern::Mixed, 0.0, 0, 0.0, 0.0).pattern, + "Outbred" + ); } #[test] @@ -664,8 +676,14 @@ mod tests { #[test] fn ancestry_summary_framing() { - assert_eq!(ancestry_summary(Lang::En, &[]), "Ancestry composition not yet estimated"); - assert_eq!(ancestry_summary(Lang::En, &[sp("European", 92.0)]), "Predominantly European"); + assert_eq!( + ancestry_summary(Lang::En, &[]), + "Ancestry composition not yet estimated" + ); + assert_eq!( + ancestry_summary(Lang::En, &[sp("European", 92.0)]), + "Predominantly European" + ); // Unsorted input is sorted by share. assert_eq!( ancestry_summary(Lang::En, &[sp("African", 30.0), sp("European", 70.0)]), diff --git a/crates/navigator-domain/src/consensus.rs b/crates/navigator-domain/src/consensus.rs index 69109c95..d34f6d42 100644 --- a/crates/navigator-domain/src/consensus.rs +++ b/crates/navigator-domain/src/consensus.rs @@ -896,9 +896,11 @@ mod tests { // Opposite-strand reads match via the complement (non-ambiguous A>C: comp T/G). assert_eq!(impute_state(Some('G'), "A", "C"), ConsensusState::Derived); // comp(G)=C=derived assert_eq!(impute_state(Some('T'), "A", "C"), ConsensusState::Ancestral); // comp(T)=A=ancestral + // Strand-ambiguous C/G: complement of derived G is ancestral C → keep literal only. assert_eq!(impute_state(Some('C'), "C", "G"), ConsensusState::Ancestral); assert_eq!(impute_state(Some('A'), "C", "G"), ConsensusState::NoCall); // genuine third allele + // No base → no call. assert_eq!(impute_state(None, "A", "G"), ConsensusState::NoCall); } @@ -909,7 +911,8 @@ mod tests { // can't evaluate it — must be no-call, not a false derived. assert_eq!(impute_state(Some('G'), "G", "GAGC"), ConsensusState::NoCall); // insertion assert_eq!(impute_state(Some('G'), "GAGC", "G"), ConsensusState::NoCall); // deletion - assert_eq!(impute_state(Some('A'), "AT", "GC"), ConsensusState::NoCall); // MNP + assert_eq!(impute_state(Some('A'), "AT", "GC"), ConsensusState::NoCall); + // MNP } #[test] @@ -937,8 +940,9 @@ mod tests { assert_eq!(v0[0].consensus_base.as_deref(), Some("T")); // Corrected polarity C>T → the same base is now Derived. - let polarity: BTreeMap = - [("PF1016".to_string(), ("C".to_string(), "T".to_string()))].into_iter().collect(); + let polarity: BTreeMap = [("PF1016".to_string(), ("C".to_string(), "T".to_string()))] + .into_iter() + .collect(); let (v1, _) = interpret(&observed, &polarity); assert_eq!(v1[0].consensus, ConsensusState::Derived); assert_eq!(v1[0].consensus_base.as_deref(), Some("T")); @@ -953,8 +957,16 @@ mod tests { // A/G alleles, and comp(T)=A is ancestral — so T is treated as an opposite-strand ancestral // read here). A cleaner third-allele case: strand-ambiguous A/T with a C read stays C. let observed = to_observed(&[ - ("a".into(), SourceType::WgsShortRead, vec![ConsensusObs::observed("S1", 1, "A", "T", Some('C'), true)]), - ("b".into(), SourceType::WgsShortRead, vec![ConsensusObs::observed("S1", 1, "A", "T", Some('C'), true)]), + ( + "a".into(), + SourceType::WgsShortRead, + vec![ConsensusObs::observed("S1", 1, "A", "T", Some('C'), true)], + ), + ( + "b".into(), + SourceType::WgsShortRead, + vec![ConsensusObs::observed("S1", 1, "A", "T", Some('C'), true)], + ), ]); let (v, _) = interpret(&observed, &BTreeMap::new()); // A/T is strand-ambiguous, so a C read matches no allele and is kept as itself — the @@ -1241,7 +1253,8 @@ mod tests { assert_eq!(s.total, 2); assert_eq!(s.confirmed, 1); // rs1 (both hom-alt) assert_eq!(s.conflict, 1); // rs2 (0 vs 2) - // (1 confirmed − 0.5·1 conflict) / 2 = 0.25 + + // (1 confirmed − 0.5·1 conflict) / 2 = 0.25 assert!((s.overall_confidence - 0.25).abs() < 1e-9); } } diff --git a/crates/navigator-domain/src/filetype.rs b/crates/navigator-domain/src/filetype.rs index b1f91e3c..64dd841f 100644 --- a/crates/navigator-domain/src/filetype.rs +++ b/crates/navigator-domain/src/filetype.rs @@ -423,8 +423,14 @@ chr1\t246193\t.\tG\tA\t225\t.\tDP=29\tGT\t1/1 >locus\tploidy\tallele\tchromosome\tbegin\tend\tvarType\treference\talleleSeq\tvarScoreVAF\tvarScoreEAF\tvarQuality\thapLink\txRef\n\ 1\t2\tall\tchr1\t0\t10000\tno-ref\t=\t?\t\t\t\t\t\n"; // Both the raw name and a `.tsv.bz2` (extension isn't consulted for this format) detect. - assert_eq!(detect("var-GS00253-DNA_A01_200_37-ASM.tsv", head), DetectedData::CompleteGenomicsVar); - assert_eq!(detect("var-GS00253-DNA_A01_200_37-ASM.tsv.bz2", head), DetectedData::CompleteGenomicsVar); + assert_eq!( + detect("var-GS00253-DNA_A01_200_37-ASM.tsv", head), + DetectedData::CompleteGenomicsVar + ); + assert_eq!( + detect("var-GS00253-DNA_A01_200_37-ASM.tsv.bz2", head), + DetectedData::CompleteGenomicsVar + ); } #[test] diff --git a/crates/navigator-domain/src/ftdna_csv.rs b/crates/navigator-domain/src/ftdna_csv.rs index fd9e5346..6c6d0981 100644 --- a/crates/navigator-domain/src/ftdna_csv.rs +++ b/crates/navigator-domain/src/ftdna_csv.rs @@ -38,7 +38,9 @@ const CONTIG: &str = "chrY"; /// Split a CSV line into trimmed, unquoted cells. fn cells(line: &str) -> Vec { - line.split(',').map(|s| s.trim().trim_matches('"').to_string()).collect() + line.split(',') + .map(|s| s.trim().trim_matches('"').to_string()) + .collect() } /// Recognize the report flavor from a header row's columns, or `None` if it isn't an FTDNA Big Y @@ -71,8 +73,8 @@ pub fn parse(text: &str) -> Result<(FtdnaReport, Vec), String> { let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); let header = lines.next().ok_or("empty FTDNA variant CSV")?; let hcols = cells(header); - let report = report_of_header(&hcols) - .ok_or("not an FTDNA Big Y Named/Private Variants CSV (unrecognized header)")?; + let report = + report_of_header(&hcols).ok_or("not an FTDNA Big Y Named/Private Variants CSV (unrecognized header)")?; let col = |name: &str| hcols.iter().position(|c| c.eq_ignore_ascii_case(name)); let i_name = col("SNP_Name"); @@ -84,7 +86,9 @@ pub fn parse(text: &str) -> Result<(FtdnaReport, Vec), String> { for line in lines { let c = cells(line); let get = |i: usize| c.get(i).map(String::as_str).unwrap_or(""); - let Ok(position) = get(i_pos).parse::() else { continue }; + let Ok(position) = get(i_pos).parse::() else { + continue; + }; let name = i_name.map(|i| get(i).to_string()).filter(|s| !s.is_empty()); // Each row is a derived (positive) call: ref = ancestral, alt = derived, gt = "1". if let Some(call) = variants::snp_call(CONTIG, position, get(i_anc), get(i_der), name, Some("1".into())) { diff --git a/crates/navigator-domain/src/i18n.rs b/crates/navigator-domain/src/i18n.rs index 8f48e6b4..09ede66e 100644 --- a/crates/navigator-domain/src/i18n.rs +++ b/crates/navigator-domain/src/i18n.rs @@ -173,7 +173,11 @@ mod tests { fn brief_prose_is_translated_in_every_language() { let en = catalog(Lang::En); let brief_keys: Vec<&&str> = en.keys().filter(|k| k.starts_with("brief.")).collect(); - assert!(brief_keys.len() > 20, "expected the brief catalog, found {}", brief_keys.len()); + assert!( + brief_keys.len() > 20, + "expected the brief catalog, found {}", + brief_keys.len() + ); for lang in Lang::all() { for key in &brief_keys { assert!( diff --git a/crates/navigator-domain/src/identity.rs b/crates/navigator-domain/src/identity.rs index 8a25f295..9287a692 100644 --- a/crates/navigator-domain/src/identity.rs +++ b/crates/navigator-domain/src/identity.rs @@ -65,7 +65,14 @@ impl IdSource { pub fn is_public(source: &str) -> bool { matches!( source, - Self::PGP | Self::IGSR | Self::THOUSAND_GENOMES | Self::ENA | Self::SRA | Self::BIOSAMPLE | Self::HGDP | Self::SGDP + Self::PGP + | Self::IGSR + | Self::THOUSAND_GENOMES + | Self::ENA + | Self::SRA + | Self::BIOSAMPLE + | Self::HGDP + | Self::SGDP ) } } @@ -116,7 +123,10 @@ fn is_hgdp_name(s: &str) -> bool { /// Used both by [`catalog_ids_from_provenance`] and by the API-driven accession backfill. pub fn insdc_sample_namespace(acc: &str) -> Option<&'static str> { let u = acc.to_ascii_uppercase(); - let digits_after = |p: &str| u.strip_prefix(p).is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())); + let digits_after = |p: &str| { + u.strip_prefix(p) + .is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) + }; if u.starts_with("SAMN") || u.starts_with("SAMEA") || u.starts_with("SAMD") { Some(IdSource::BIOSAMPLE) } else if digits_after("ERS") { diff --git a/crates/navigator-domain/src/lib.rs b/crates/navigator-domain/src/lib.rs index 678eff76..686bf717 100644 --- a/crates/navigator-domain/src/lib.rs +++ b/crates/navigator-domain/src/lib.rs @@ -16,8 +16,8 @@ pub mod chipprofile; pub mod consensus; pub mod contig; pub mod filetype; -pub mod ftdna_csv; pub mod ftdna; +pub mod ftdna_csv; pub mod i18n; pub mod identity; pub mod labs; diff --git a/crates/navigator-domain/src/llm_prompt.rs b/crates/navigator-domain/src/llm_prompt.rs index 818c1aea..a6d4984e 100644 --- a/crates/navigator-domain/src/llm_prompt.rs +++ b/crates/navigator-domain/src/llm_prompt.rs @@ -115,7 +115,10 @@ pub fn narrate_fact_sheet(b: &SubjectBrief) -> String { s.push_str("\nAncestry:\n"); s.push_str(&format!("- summary: {}\n", a.summary_phrase)); for sp in a.super_populations.iter().filter(|p| p.percentage >= 0.5) { - s.push_str(&format!("- continental: {}: {:.1}%\n", sp.super_population, sp.percentage)); + s.push_str(&format!( + "- continental: {}: {:.1}%\n", + sp.super_population, sp.percentage + )); } // Fine/modern populations (present-day reference groups the person most resembles). Without // these the story leans entirely on the ancient components — this is the recent-ancestry layer. @@ -138,7 +141,10 @@ pub fn narrate_fact_sheet(b: &SubjectBrief) -> String { // Shared ancestry between the parents' lines (genealogical relatedness) — NOT a health signal. s.push_str("\nShared ancestry (runs of homozygosity):\n"); s.push_str(&format!("- pattern: {}\n", r.pattern)); - s.push_str(&format!("- F_ROH: {:.4} (share of DNA in long identical runs)\n", r.f_roh)); + s.push_str(&format!( + "- F_ROH: {:.4} (share of DNA in long identical runs)\n", + r.f_roh + )); s.push_str(&format!( "- {} run(s), about {:.0} Mb in total, longest {:.0} Mb\n", r.n_segments, r.total_mb, r.longest_mb @@ -269,7 +275,10 @@ mod tests { assert!(s.contains("Predominantly European")); assert!(s.contains("Western Hunter-Gatherer")); // Modern/fine populations must reach the model too — not only the ancient sources. - assert!(s.contains("closest modern population: British (55.0%)"), "fine pops missing: {s}"); + assert!( + s.contains("closest modern population: British (55.0%)"), + "fine pops missing: {s}" + ); assert!(s.contains("Iberian (12.0%)")); assert!(s.contains("high-quality (30× average depth)")); } diff --git a/crates/navigator-domain/src/paths.rs b/crates/navigator-domain/src/paths.rs index f1681928..e3c3b0f5 100644 --- a/crates/navigator-domain/src/paths.rs +++ b/crates/navigator-domain/src/paths.rs @@ -49,7 +49,11 @@ pub fn decodingus_dir() -> PathBuf { /// than the fallback. // Compiled on every platform so its precedence stays under test anywhere; only *called* on Windows. #[cfg_attr(not(windows), allow(dead_code))] -fn windows_home(userprofile: Option, homedrive: Option, homepath: Option) -> Option { +fn windows_home( + userprofile: Option, + homedrive: Option, + homepath: Option, +) -> Option { if let Some(p) = userprofile.filter(|p| !p.is_empty()) { return Some(PathBuf::from(p)); } diff --git a/crates/navigator-domain/src/reconciliation.rs b/crates/navigator-domain/src/reconciliation.rs index a52cabcd..0d8b038e 100644 --- a/crates/navigator-domain/src/reconciliation.rs +++ b/crates/navigator-domain/src/reconciliation.rs @@ -332,9 +332,10 @@ pub fn reconcile_with_provenance( lower.sort_unstable(); lower.dedup(); if !lower.is_empty() { - consensus - .warnings - .push(format!("lower-precedence sources place elsewhere: {} (external caller preferred)", lower.join(", "))); + consensus.warnings.push(format!( + "lower-precedence sources place elsewhere: {} (external caller preferred)", + lower.join(", ") + )); } Some(consensus) } @@ -436,7 +437,10 @@ mod tests { let external = call("gatk4 gvcf", 0.60, &["root", "R", "R-M269", "R-L21"]); let walk = call("cram walk", 0.95, &["root", "R", "R-M269", "R-L2"]); let c = reconcile_with_provenance( - &[(CallProvenance::External, external), (CallProvenance::NavigatorWalk, walk)], + &[ + (CallProvenance::External, external), + (CallProvenance::NavigatorWalk, walk), + ], true, ) .unwrap(); @@ -450,7 +454,10 @@ mod tests { let external = call("gatk4 gvcf", 0.60, &["root", "R", "R-M269"]); let walk = call("cram walk", 0.95, &["root", "R", "R-M269", "R-L21", "R-DF13"]); let c = reconcile_with_provenance( - &[(CallProvenance::External, external), (CallProvenance::NavigatorWalk, walk)], + &[ + (CallProvenance::External, external), + (CallProvenance::NavigatorWalk, walk), + ], false, ) .unwrap(); diff --git a/crates/navigator-domain/src/results_context.rs b/crates/navigator-domain/src/results_context.rs index 630e5e00..4807491b 100644 --- a/crates/navigator-domain/src/results_context.rs +++ b/crates/navigator-domain/src/results_context.rs @@ -137,7 +137,10 @@ impl SignalKind { fn sex_section(sex: &Option) -> Option { let sex = sex.as_ref()?; - Some(format!("\nGenetic sex:\n- {} ({} confidence)\n", sex.label, sex.confidence)) + Some(format!( + "\nGenetic sex:\n- {} ({} confidence)\n", + sex.label, sex.confidence + )) } fn ystr_section(ystr: &[YStrPanelFact]) -> Option { @@ -219,7 +222,10 @@ fn roh_section(brief: &SubjectBrief) -> Option { let r = brief.roh.as_ref()?; let mut s = String::from("\nShared ancestry (runs of homozygosity):\n"); s.push_str(&format!("- pattern: {}\n", r.pattern)); - s.push_str(&format!("- F_ROH: {:.4} (share of the genome in long identical runs)\n", r.f_roh)); + s.push_str(&format!( + "- F_ROH: {:.4} (share of the genome in long identical runs)\n", + r.f_roh + )); s.push_str(&format!( "- {} run(s), about {:.0} Mb in total, longest {:.0} Mb\n", r.n_segments, r.total_mb, r.longest_mb @@ -388,10 +394,20 @@ mod tests { confidence: "high".into(), }), ystr: vec![ - YStrPanelFact { panel: "Y-111".into(), markers: 111 }, - YStrPanelFact { panel: "Y-37".into(), markers: 37 }, + YStrPanelFact { + panel: "Y-111".into(), + markers: 111, + }, + YStrPanelFact { + panel: "Y-37".into(), + markers: 37, + }, ], - private_y: Some(PrivateYFact { novel_unique: 12, off_path: 3, structural: 2 }), + private_y: Some(PrivateYFact { + novel_unique: 12, + off_path: 3, + structural: 2, + }), mt_mutations: Some(MtMutationsFact { total: 41, hvr1: 5, @@ -460,7 +476,10 @@ mod tests { // The grounding must actively steer the model off the two framings the design forbids: // restating a count as a percent-Neanderthal, and reporting a Denisovan finding. - assert!(section.contains("not a percentage"), "must warn against percent framing"); + assert!( + section.contains("not a percentage"), + "must warn against percent framing" + ); assert!(section.contains("no Denisovan result is reported")); assert!(!mentions_health(§ion), "archaic must not read as a health result"); @@ -517,7 +536,11 @@ mod tests { #[test] fn structural_line_only_when_nonzero() { let mut ctx = full_context(); - ctx.private_y = Some(PrivateYFact { novel_unique: 4, off_path: 1, structural: 0 }); + ctx.private_y = Some(PrivateYFact { + novel_unique: 4, + off_path: 1, + structural: 0, + }); let s = results_fact_sheet(&ctx); assert!(!s.contains("structural/paralog-prone")); } diff --git a/crates/navigator-domain/src/testtype.rs b/crates/navigator-domain/src/testtype.rs index 9780a0e5..5f7d5e93 100644 --- a/crates/navigator-domain/src/testtype.rs +++ b/crates/navigator-domain/src/testtype.rs @@ -190,8 +190,25 @@ pub fn target_of(test_type: &str) -> Option { return Some(t.target); } let s = test_type.trim().to_ascii_lowercase(); - const Y: &[&str] = &["big y", "big-y", "bigy", "y elite", "y-elite", "y prime", "y-prime", "targeted y"]; - const MT: &[&str] = &["mt full", "mtfull", "mt-full", "full mtdna", "full mitochondrial", "mtdna", "targeted mt"]; + const Y: &[&str] = &[ + "big y", + "big-y", + "bigy", + "y elite", + "y-elite", + "y prime", + "y-prime", + "targeted y", + ]; + const MT: &[&str] = &[ + "mt full", + "mtfull", + "mt-full", + "full mtdna", + "full mitochondrial", + "mtdna", + "targeted mt", + ]; if Y.iter().any(|p| s.contains(p)) { Some(TargetType::YChromosome) } else if MT.iter().any(|p| s.contains(p)) { diff --git a/crates/navigator-domain/src/ysnp_dict.rs b/crates/navigator-domain/src/ysnp_dict.rs index 2a08aa2b..155a98fb 100644 --- a/crates/navigator-domain/src/ysnp_dict.rs +++ b/crates/navigator-domain/src/ysnp_dict.rs @@ -264,16 +264,30 @@ M269\tCTS10003 let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let hdr = "name\tbuild\tchrom\tposition\tstrand\tancestral\tderived\n"; - std::fs::write(dir.join("dictionary.tsv"), format!("{hdr}FullOnlySnp\ths1\tchrY\t123\t+\tA\tG\n")).unwrap(); - std::fs::write(dir.join("chromo2-panel.tsv"), format!("{hdr}PanelOnlySnp\ths1\tchrY\t456\t+\tA\tG\n")).unwrap(); + std::fs::write( + dir.join("dictionary.tsv"), + format!("{hdr}FullOnlySnp\ths1\tchrY\t123\t+\tA\tG\n"), + ) + .unwrap(); + std::fs::write( + dir.join("chromo2-panel.tsv"), + format!("{hdr}PanelOnlySnp\ths1\tchrY\t456\t+\tA\tG\n"), + ) + .unwrap(); let d = YsnpDictionary::load(&dir).unwrap(); assert!(d.resolve("FullOnlySnp", "hs1").is_some(), "loaded the full catalog"); - assert!(d.resolve("PanelOnlySnp", "hs1").is_none(), "did not load the chromo2 panel"); + assert!( + d.resolve("PanelOnlySnp", "hs1").is_none(), + "did not load the chromo2 panel" + ); std::fs::remove_file(dir.join("dictionary.tsv")).unwrap(); let d2 = YsnpDictionary::load(&dir).unwrap(); - assert!(d2.resolve("PanelOnlySnp", "hs1").is_some(), "fell back to the chromo2 panel"); + assert!( + d2.resolve("PanelOnlySnp", "hs1").is_some(), + "fell back to the chromo2 panel" + ); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/navigator-panelbuild/examples/ascertain_chip.rs b/crates/navigator-panelbuild/examples/ascertain_chip.rs index 0768860b..6816720e 100644 --- a/crates/navigator-panelbuild/examples/ascertain_chip.rs +++ b/crates/navigator-panelbuild/examples/ascertain_chip.rs @@ -10,7 +10,9 @@ use std::io::{BufRead, BufReader}; fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); - let ancient_path = args.next().expect("usage: ascertain_chip "); + let ancient_path = args + .next() + .expect("usage: ascertain_chip "); let ibd_path = args.next().expect("ibd.bin"); let out_path = args.next().expect("out.bin"); let chip_files: Vec = args.collect(); @@ -44,7 +46,9 @@ fn main() -> anyhow::Result<()> { let mut ancient = AncestryPanel::from_bytes(&std::fs::read(&ancient_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; let before = ancient.sites.len(); - ancient.sites.retain(|s| chip_loci.contains(&(s.contig.clone(), s.position))); + ancient + .sites + .retain(|s| chip_loci.contains(&(s.contig.clone(), s.position))); let after = ancient.sites.len(); std::fs::write(&out_path, ancient.to_bytes().map_err(|e| anyhow::anyhow!("{e}"))?)?; diff --git a/crates/navigator-panelbuild/examples/ascertainment.rs b/crates/navigator-panelbuild/examples/ascertainment.rs index e469110c..a367b1b3 100644 --- a/crates/navigator-panelbuild/examples/ascertainment.rs +++ b/crates/navigator-panelbuild/examples/ascertainment.rs @@ -7,16 +7,28 @@ use navigator_analysis::ancestry::AncestryPanel; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let ancient_path = std::env::args().nth(1).expect("usage: ascertainment "); - let super_path = std::env::args().nth(2).expect("usage: ascertainment "); + let ancient_path = std::env::args() + .nth(1) + .expect("usage: ascertainment "); + let super_path = std::env::args() + .nth(2) + .expect("usage: ascertainment "); let ancient = AncestryPanel::from_bytes(&std::fs::read(&ancient_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; let sup = AncestryPanel::from_bytes(&std::fs::read(&super_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; - let eur = sup.populations.iter().position(|p| p == "EUR").expect("super panel has no EUR"); + let eur = sup + .populations + .iter() + .position(|p| p == "EUR") + .expect("super panel has no EUR"); let eur_maf: HashMap<(String, i64), f64> = sup .sites .iter() - .filter_map(|s| s.freqs.get(eur).map(|&f| ((s.contig.clone(), s.position), (f as f64).min(1.0 - f as f64)))) + .filter_map(|s| { + s.freqs + .get(eur) + .map(|&f| ((s.contig.clone(), s.position), (f as f64).min(1.0 - f as f64))) + }) .collect(); // Bin edges on EUR MAF. Last bin captures the "common" (chip-like) sites. @@ -30,7 +42,9 @@ fn main() -> anyhow::Result<()> { let mut joined = 0usize; for s in &ancient.sites { - let Some(&m) = eur_maf.get(&(s.contig.clone(), s.position)) else { continue }; + let Some(&m) = eur_maf.get(&(s.contig.clone(), s.position)) else { + continue; + }; joined += 1; let b = bin_of(m); n[b] += 1; diff --git a/crates/navigator-panelbuild/examples/check_liftover.rs b/crates/navigator-panelbuild/examples/check_liftover.rs index 6453fc7e..49ce6e3c 100644 --- a/crates/navigator-panelbuild/examples/check_liftover.rs +++ b/crates/navigator-panelbuild/examples/check_liftover.rs @@ -14,11 +14,16 @@ fn base_at( ) -> Option { let region: Region = format!("{contig}:{pos}-{pos}").parse().ok()?; let rec = reader.query(®ion).ok()?; - rec.sequence().as_ref().first().map(|&b| (b as char).to_ascii_uppercase()) + rec.sequence() + .as_ref() + .first() + .map(|&b| (b as char).to_ascii_uppercase()) } fn main() -> anyhow::Result<()> { - let panel_path = std::env::args().nth(1).expect("usage: check_liftover "); + let panel_path = std::env::args() + .nth(1) + .expect("usage: check_liftover "); let chm13_fa = std::env::args().nth(2).expect("chm13.fa"); let grch38_fa = std::env::args().nth(3).expect("grch38.fa"); let panel = IbdPanel::from_bytes(&std::fs::read(&panel_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -60,8 +65,16 @@ fn main() -> anyhow::Result<()> { if examples.len() < 12 { examples.push(format!( "{} chm13 {}:{} {}/{} -> grch38 {}:{} REF={} ALT={} genome={}", - s.rsid, s.chm13.contig, s.chm13.position, s.chm13.reference, s.chm13.alternate, - l.contig, l.position, l.reference, l.alternate, b + s.rsid, + s.chm13.contig, + s.chm13.position, + s.chm13.reference, + s.chm13.alternate, + l.contig, + l.position, + l.reference, + l.alternate, + b )); } } @@ -72,11 +85,19 @@ fn main() -> anyhow::Result<()> { println!("sampled every {step}th of {} sites\n", panel.sites.len()); println!( "CHM13 control : genome base == panel REF|ALT at {}/{} ({:.1}%)", - chm_ok, chm_n, 100.0 * chm_ok as f64 / chm_n.max(1) as f64 + chm_ok, + chm_n, + 100.0 * chm_ok as f64 / chm_n.max(1) as f64 ); println!("GRCh38 locus : n={g38_n}"); - println!(" genome == build REF : {g38_ref} ({:.1}%)", 100.0 * g38_ref as f64 / g38_n.max(1) as f64); - println!(" genome == build ALT : {g38_alt} ({:.1}%)", 100.0 * g38_alt as f64 / g38_n.max(1) as f64); + println!( + " genome == build REF : {g38_ref} ({:.1}%)", + 100.0 * g38_ref as f64 / g38_n.max(1) as f64 + ); + println!( + " genome == build ALT : {g38_alt} ({:.1}%)", + 100.0 * g38_alt as f64 / g38_n.max(1) as f64 + ); println!( " genome == NEITHER : {g38_other} ({:.1}%) <- wrong coordinate", 100.0 * g38_other as f64 / g38_n.max(1) as f64 diff --git a/crates/navigator-panelbuild/examples/filter_maf.rs b/crates/navigator-panelbuild/examples/filter_maf.rs index b2a64621..868363bc 100644 --- a/crates/navigator-panelbuild/examples/filter_maf.rs +++ b/crates/navigator-panelbuild/examples/filter_maf.rs @@ -6,7 +6,9 @@ use navigator_analysis::ancestry::AncestryPanel; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let a = std::env::args().nth(1).expect("usage: filter_maf "); + let a = std::env::args() + .nth(1) + .expect("usage: filter_maf "); let s = std::env::args().nth(2).expect("super.bin"); let t: f64 = std::env::args().nth(3).expect("min_maf").parse()?; let out = std::env::args().nth(4).expect("out.bin"); @@ -17,11 +19,17 @@ fn main() -> anyhow::Result<()> { let maf: HashMap<(String, i64), f64> = sup .sites .iter() - .filter_map(|x| x.freqs.get(eur).map(|&f| ((x.contig.clone(), x.position), (f as f64).min(1.0 - f as f64)))) + .filter_map(|x| { + x.freqs + .get(eur) + .map(|&f| ((x.contig.clone(), x.position), (f as f64).min(1.0 - f as f64))) + }) .collect(); let before = ancient.sites.len(); - ancient.sites.retain(|x| maf.get(&(x.contig.clone(), x.position)).is_some_and(|&m| m >= t)); + ancient + .sites + .retain(|x| maf.get(&(x.contig.clone(), x.position)).is_some_and(|&m| m >= t)); let after = ancient.sites.len(); std::fs::write(&out, ancient.to_bytes().map_err(|e| anyhow::anyhow!("{e}"))?)?; println!("min_maf={t}: kept {after}/{before} sites -> {out}"); diff --git a/crates/navigator-panelbuild/examples/filter_sites.rs b/crates/navigator-panelbuild/examples/filter_sites.rs index 308800bd..cce985d6 100644 --- a/crates/navigator-panelbuild/examples/filter_sites.rs +++ b/crates/navigator-panelbuild/examples/filter_sites.rs @@ -7,7 +7,9 @@ use navigator_analysis::ancestry::AncestryPanel; use std::collections::HashSet; fn main() -> anyhow::Result<()> { - let ancient = std::env::args().nth(1).expect("usage: filter_sites "); + let ancient = std::env::args() + .nth(1) + .expect("usage: filter_sites "); let sites_tsv = std::env::args().nth(2).expect("sites.tsv"); let out = std::env::args().nth(3).expect("out.bin"); let keep: HashSet<(String, i64)> = std::fs::read_to_string(&sites_tsv)? @@ -25,6 +27,9 @@ fn main() -> anyhow::Result<()> { panel.sites.retain(|s| keep.contains(&(s.contig.clone(), s.position))); let after = panel.sites.len(); std::fs::write(&out, panel.to_bytes().map_err(|e| anyhow::anyhow!("{e}"))?)?; - println!("kept {after}/{before} sites (ascertainment set {}) -> {out}", keep.len()); + println!( + "kept {after}/{before} sites (ascertainment set {}) -> {out}", + keep.len() + ); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/filter_tv.rs b/crates/navigator-panelbuild/examples/filter_tv.rs index f16bba59..edf5d4ae 100644 --- a/crates/navigator-panelbuild/examples/filter_tv.rs +++ b/crates/navigator-panelbuild/examples/filter_tv.rs @@ -8,13 +8,20 @@ fn is_transition(r: char, a: char) -> bool { } fn main() -> anyhow::Result<()> { - let path = std::env::args().nth(1).expect("usage: filter_tv "); + let path = std::env::args() + .nth(1) + .expect("usage: filter_tv "); let out = std::env::args().nth(2).expect("out.bin"); let mut panel = AncestryPanel::from_bytes(&std::fs::read(&path)?).map_err(|e| anyhow::anyhow!("{e}"))?; let before = panel.sites.len(); - panel.sites.retain(|s| !is_transition(s.reference_allele, s.alternate_allele)); + panel + .sites + .retain(|s| !is_transition(s.reference_allele, s.alternate_allele)); let after = panel.sites.len(); std::fs::write(&out, panel.to_bytes().map_err(|e| anyhow::anyhow!("{e}"))?)?; - println!("transversions only: kept {after}/{before} sites ({:.1}%) -> {out}", 100.0 * after as f64 / before as f64); + println!( + "transversions only: kept {after}/{before} sites ({:.1}%) -> {out}", + 100.0 * after as f64 / before as f64 + ); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/genotype_bed.rs b/crates/navigator-panelbuild/examples/genotype_bed.rs index d44ae998..eae97854 100644 --- a/crates/navigator-panelbuild/examples/genotype_bed.rs +++ b/crates/navigator-panelbuild/examples/genotype_bed.rs @@ -7,7 +7,9 @@ use std::io::{BufWriter, Write}; use std::path::PathBuf; fn main() -> anyhow::Result<()> { - let bed = std::env::args().nth(1).expect("usage: genotype_bed [ref.fa]"); + let bed = std::env::args() + .nth(1) + .expect("usage: genotype_bed [ref.fa]"); let bam = PathBuf::from(std::env::args().nth(2).expect("bam")); let out = std::env::args().nth(3).expect("out.tsv"); let reference = std::env::args().nth(4).map(PathBuf::from); @@ -25,7 +27,11 @@ fn main() -> anyhow::Result<()> { let contig = f[0].to_string(); let pos: i64 = f[2].parse()?; // BED end = 1-based position let name: Vec<&str> = f[3].split('|').collect(); - let (rsid, r, a) = (name[0], name.get(1).copied().unwrap_or("N"), name.get(2).copied().unwrap_or("N")); + let (rsid, r, a) = ( + name[0], + name.get(1).copied().unwrap_or("N"), + name.get(2).copied().unwrap_or("N"), + ); rsids.push(rsid.to_string()); sites.push(Site { name: rsid.to_string(), @@ -37,7 +43,15 @@ fn main() -> anyhow::Result<()> { } eprintln!("genotyping {} sites from {} ...", sites.len(), bam.display()); let params = HaploidCallerParams::default(); - let gts = genotype_sites_all_contigs(&bam, &sites, 2, ¶ms, reference.as_deref(), &navigator_analysis::CancelToken::none()).map_err(|e| anyhow::anyhow!("{e}"))?; + let gts = genotype_sites_all_contigs( + &bam, + &sites, + 2, + ¶ms, + reference.as_deref(), + &navigator_analysis::CancelToken::none(), + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; // genotype_sites_all_contigs returns genotypes REORDERED (per-contig), so we must key each // returned genotype to its rsID by (contig,position) — NOT by input order. Zipping with `rsids` diff --git a/crates/navigator-panelbuild/examples/ibd_qpadm_orient.rs b/crates/navigator-panelbuild/examples/ibd_qpadm_orient.rs index 2dca7cfb..50cf14b1 100644 --- a/crates/navigator-panelbuild/examples/ibd_qpadm_orient.rs +++ b/crates/navigator-panelbuild/examples/ibd_qpadm_orient.rs @@ -3,12 +3,33 @@ use navigator_analysis::ancestry::AncestryPanel; use navigator_analysis::ibd_panel::IbdPanel; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let ibd = IbdPanel::from_bytes(&std::fs::read(std::env::args().nth(1).unwrap())?).map_err(|e| anyhow::anyhow!("{e}"))?; - let qp = AncestryPanel::from_bytes(&std::fs::read(std::env::args().nth(2).unwrap())?).map_err(|e| anyhow::anyhow!("{e}"))?; - let m: HashMap<(String,i64),(char,char)> = ibd.sites.iter().map(|s| ((s.chm13.contig.clone(), s.chm13.position),(s.chm13.reference,s.chm13.alternate))).collect(); - let (mut ov, mut same, mut swap, mut other)=(0,0,0,0); - for s in &qp.sites { if let Some(&(r,a))=m.get(&(s.contig.clone(),s.position)) { ov+=1; - if (s.reference_allele,s.alternate_allele)==(r,a){same+=1} else if (s.reference_allele,s.alternate_allele)==(a,r){swap+=1} else {other+=1} } } + let ibd = + IbdPanel::from_bytes(&std::fs::read(std::env::args().nth(1).unwrap())?).map_err(|e| anyhow::anyhow!("{e}"))?; + let qp = AncestryPanel::from_bytes(&std::fs::read(std::env::args().nth(2).unwrap())?) + .map_err(|e| anyhow::anyhow!("{e}"))?; + let m: HashMap<(String, i64), (char, char)> = ibd + .sites + .iter() + .map(|s| { + ( + (s.chm13.contig.clone(), s.chm13.position), + (s.chm13.reference, s.chm13.alternate), + ) + }) + .collect(); + let (mut ov, mut same, mut swap, mut other) = (0, 0, 0, 0); + for s in &qp.sites { + if let Some(&(r, a)) = m.get(&(s.contig.clone(), s.position)) { + ov += 1; + if (s.reference_allele, s.alternate_allele) == (r, a) { + same += 1 + } else if (s.reference_allele, s.alternate_allele) == (a, r) { + swap += 1 + } else { + other += 1 + } + } + } println!("overlap {ov}: same {same}, swapped {swap}, other {other}"); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/inspect_panel.rs b/crates/navigator-panelbuild/examples/inspect_panel.rs index 9daa317c..354a4598 100644 --- a/crates/navigator-panelbuild/examples/inspect_panel.rs +++ b/crates/navigator-panelbuild/examples/inspect_panel.rs @@ -5,7 +5,12 @@ fn main() -> anyhow::Result<()> { let path = std::env::args().nth(1).expect("usage: inspect_panel "); let bytes = std::fs::read(&path)?; let panel = AncestryPanel::from_bytes(&bytes).map_err(|e| anyhow::anyhow!("{e}"))?; - println!("build={} sites={} pops={}", panel.build, panel.sites.len(), panel.populations.len()); + println!( + "build={} sites={} pops={}", + panel.build, + panel.sites.len(), + panel.populations.len() + ); println!("populations: {:?}", panel.populations); let k = panel.populations.len(); @@ -38,7 +43,9 @@ fn main() -> anyhow::Result<()> { } // Pairwise Nei Fst between a few populations of interest. - let want = ["WHG", "ANF", "Steppe", "EHG", "CHG", "Iran_N", "GBR", "CEU", "TSI", "YRI", "Han"]; + let want = [ + "WHG", "ANF", "Steppe", "EHG", "CHG", "Iran_N", "GBR", "CEU", "TSI", "YRI", "Han", + ]; let idx: Vec<(usize, &str)> = want .iter() .filter_map(|w| panel.populations.iter().position(|p| p == w).map(|i| (i, *w))) diff --git a/crates/navigator-panelbuild/examples/overlap.rs b/crates/navigator-panelbuild/examples/overlap.rs index 8bb6bfe8..7c82786a 100644 --- a/crates/navigator-panelbuild/examples/overlap.rs +++ b/crates/navigator-panelbuild/examples/overlap.rs @@ -9,7 +9,13 @@ fn main() -> anyhow::Result<()> { println!("{} sites={} pops={:?}", a[0], p1.sites.len(), p1.populations); println!("{} sites={} pops={:?}", a[1], p2.sites.len(), p2.populations); println!("overlap = {}", s1.intersection(&s2).count()); - println!("p1 sample contig: {:?}", p1.sites.first().map(|s| (&s.contig, s.position))); - println!("p2 sample contig: {:?}", p2.sites.first().map(|s| (&s.contig, s.position))); + println!( + "p1 sample contig: {:?}", + p1.sites.first().map(|s| (&s.contig, s.position)) + ); + println!( + "p2 sample contig: {:?}", + p2.sites.first().map(|s| (&s.contig, s.position)) + ); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/panel_overlap.rs b/crates/navigator-panelbuild/examples/panel_overlap.rs index 02cbc51a..625c8187 100644 --- a/crates/navigator-panelbuild/examples/panel_overlap.rs +++ b/crates/navigator-panelbuild/examples/panel_overlap.rs @@ -4,8 +4,10 @@ use navigator_analysis::ancestry::AncestryPanel; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let a = AncestryPanel::from_bytes(&std::fs::read(std::env::args().nth(1).unwrap())?).map_err(|e| anyhow::anyhow!("{e}"))?; - let b = AncestryPanel::from_bytes(&std::fs::read(std::env::args().nth(2).unwrap())?).map_err(|e| anyhow::anyhow!("{e}"))?; + let a = AncestryPanel::from_bytes(&std::fs::read(std::env::args().nth(1).unwrap())?) + .map_err(|e| anyhow::anyhow!("{e}"))?; + let b = AncestryPanel::from_bytes(&std::fs::read(std::env::args().nth(2).unwrap())?) + .map_err(|e| anyhow::anyhow!("{e}"))?; let bm: HashMap<(String, i64), (char, char)> = b .sites .iter() diff --git a/crates/navigator-panelbuild/examples/phaploid_fit.rs b/crates/navigator-panelbuild/examples/phaploid_fit.rs index b8d603e4..776da617 100644 --- a/crates/navigator-panelbuild/examples/phaploid_fit.rs +++ b/crates/navigator-panelbuild/examples/phaploid_fit.rs @@ -30,7 +30,12 @@ fn draw_alt(contig: &str, pos: i64, ref_d: u32, alt_d: u32) -> Option { fn fit(label: &str, gts: &[SiteGenotype], panel: &AncestryPanel) { match ancient_admixture_fit(gts, panel, "chm13v2.0") { Some(r) => { - let get = |c: &str| r.components.iter().find(|x| x.population_code == c).map_or(0.0, |x| x.percentage); + let get = |c: &str| { + r.components + .iter() + .find(|x| x.population_code == c) + .map_or(0.0, |x| x.percentage) + }; println!( "{:<26} {:>6} WHG {:>5.1} ANF {:>5.1} Steppe {:>5.1} disp {:>5.2}", label, @@ -46,7 +51,9 @@ fn fit(label: &str, gts: &[SiteGenotype], panel: &AncestryPanel) { } fn main() -> anyhow::Result<()> { - let panel_path = std::env::args().nth(1).expect("usage: phaploid_fit [ref.fa]"); + let panel_path = std::env::args() + .nth(1) + .expect("usage: phaploid_fit [ref.fa]"); let bam = PathBuf::from(std::env::args().nth(2).expect("bam")); let reference = std::env::args().nth(3).map(PathBuf::from); let panel = AncestryPanel::from_bytes(&std::fs::read(&panel_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -65,7 +72,15 @@ fn main() -> anyhow::Result<()> { eprintln!("genotyping {} ancient sites from {} ...", sites.len(), bam.display()); let params = HaploidCallerParams::default(); - let gts = genotype_sites_all_contigs(&bam, &sites, 2, ¶ms, reference.as_deref(), &navigator_analysis::CancelToken::none()).map_err(|e| anyhow::anyhow!("{e}"))?; + let gts = genotype_sites_all_contigs( + &bam, + &sites, + 2, + ¶ms, + reference.as_deref(), + &navigator_analysis::CancelToken::none(), + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; let called = gts.iter().filter(|g| g.dosage >= 0).count(); eprintln!("genotyped: {} sites called (of {})", called, gts.len()); diff --git a/crates/navigator-panelbuild/examples/polarity.rs b/crates/navigator-panelbuild/examples/polarity.rs index 70e2d294..58a5fa31 100644 --- a/crates/navigator-panelbuild/examples/polarity.rs +++ b/crates/navigator-panelbuild/examples/polarity.rs @@ -6,7 +6,9 @@ use navigator_analysis::ancestry::AncestryPanel; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let a = std::env::args().nth(1).expect("usage: polarity "); + let a = std::env::args() + .nth(1) + .expect("usage: polarity "); let s = std::env::args().nth(2).expect("super.bin"); let ancient = AncestryPanel::from_bytes(&std::fs::read(&a)?).map_err(|e| anyhow::anyhow!("{e}"))?; let sup = AncestryPanel::from_bytes(&std::fs::read(&s)?).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -17,9 +19,12 @@ fn main() -> anyhow::Result<()> { .sites .iter() .filter_map(|x| { - x.freqs - .get(eur) - .map(|&f| ((x.contig.clone(), x.position), (x.reference_allele, x.alternate_allele, (f as f64).min(1.0 - f as f64)))) + x.freqs.get(eur).map(|&f| { + ( + (x.contig.clone(), x.position), + (x.reference_allele, x.alternate_allele, (f as f64).min(1.0 - f as f64)), + ) + }) }) .collect(); @@ -29,7 +34,9 @@ fn main() -> anyhow::Result<()> { let (mut aligned, mut swapped, mut other) = (vec![0usize; nb], vec![0usize; nb], vec![0usize; nb]); for x in &ancient.sites { - let Some(&(sr, sa, m)) = sup_idx.get(&(x.contig.clone(), x.position)) else { continue }; + let Some(&(sr, sa, m)) = sup_idx.get(&(x.contig.clone(), x.position)) else { + continue; + }; let b = bin(m); let (ar, aa) = (x.reference_allele, x.alternate_allele); if ar == sr && aa == sa { @@ -41,7 +48,10 @@ fn main() -> anyhow::Result<()> { } } - println!("{:<14}{:>8}{:>9}{:>9}{:>9}", "EUR MAF bin", "aligned", "SWAPPED", "other", "%swap"); + println!( + "{:<14}{:>8}{:>9}{:>9}{:>9}", + "EUR MAF bin", "aligned", "SWAPPED", "other", "%swap" + ); for b in 0..nb { let tot = (aligned[b] + swapped[b] + other[b]).max(1); println!( @@ -57,6 +67,9 @@ fn main() -> anyhow::Result<()> { let ta: usize = aligned.iter().sum(); let ts: usize = swapped.iter().sum(); let to: usize = other.iter().sum(); - println!("\ntotal: aligned={ta} swapped={ts} other={to} ({:.1}% swapped)", 100.0 * ts as f64 / (ta + ts + to).max(1) as f64); + println!( + "\ntotal: aligned={ta} swapped={ts} other={to} ({:.1}% swapped)", + 100.0 * ts as f64 / (ta + ts + to).max(1) as f64 + ); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/qpadm_check.rs b/crates/navigator-panelbuild/examples/qpadm_check.rs index d7739b35..c72be071 100644 --- a/crates/navigator-panelbuild/examples/qpadm_check.rs +++ b/crates/navigator-panelbuild/examples/qpadm_check.rs @@ -11,7 +11,9 @@ use navigator_analysis::caller::{genotype_sites_all_contigs, HaploidCallerParams use std::path::PathBuf; fn main() -> anyhow::Result<()> { - let panel_path = std::env::args().nth(1).expect("usage: qpadm_check [ref.fa]"); + let panel_path = std::env::args() + .nth(1) + .expect("usage: qpadm_check [ref.fa]"); let bam = PathBuf::from(std::env::args().nth(2).expect("bam")); let reference = std::env::args().nth(3).map(PathBuf::from); let panel = AncestryPanel::from_bytes(&std::fs::read(&panel_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -20,14 +22,23 @@ fn main() -> anyhow::Result<()> { let src_codes = ["WHG", "EEF", "Steppe"]; let sources: Vec = src_codes .iter() - .map(|c| panel.populations.iter().position(|p| p == c).unwrap_or_else(|| panic!("panel missing source {c}"))) + .map(|c| { + panel + .populations + .iter() + .position(|p| p == c) + .unwrap_or_else(|| panic!("panel missing source {c}")) + }) .collect(); let outgroups: Vec = (0..panel.populations.len()).filter(|i| !sources.contains(i)).collect(); eprintln!( "panel: {} sites, sources {:?}, outgroups {:?}", panel.sites.len(), src_codes, - outgroups.iter().map(|&i| panel.populations[i].as_str()).collect::>() + outgroups + .iter() + .map(|&i| panel.populations[i].as_str()) + .collect::>() ); let sites: Vec = panel @@ -44,7 +55,15 @@ fn main() -> anyhow::Result<()> { eprintln!("genotyping {} sites from {} ...", sites.len(), bam.display()); let params = HaploidCallerParams::default(); - let gts = genotype_sites_all_contigs(&bam, &sites, 2, ¶ms, reference.as_deref(), &navigator_analysis::CancelToken::none()).map_err(|e| anyhow::anyhow!("{e}"))?; + let gts = genotype_sites_all_contigs( + &bam, + &sites, + 2, + ¶ms, + reference.as_deref(), + &navigator_analysis::CancelToken::none(), + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; let called = gts.iter().filter(|g| g.dosage >= 0).count(); eprintln!("genotyped: {called} of {} sites called", gts.len()); @@ -52,14 +71,25 @@ fn main() -> anyhow::Result<()> { .ok_or_else(|| anyhow::anyhow!("qpadm_fit returned None (too few sites/blocks or singular system)"))?; println!("\n(reference: chip ~58% Steppe · old frequency-EM on WGS ~80% Steppe · NW-Eur band 40–55)\n"); - println!("sites {} blocks {} dof {} chi2 {:.2} p {:.4}", fit.n_sites, fit.n_blocks, fit.dof, fit.chi2, fit.p_value); + println!( + "sites {} blocks {} dof {} chi2 {:.2} p {:.4}", + fit.n_sites, fit.n_blocks, fit.dof, fit.chi2, fit.p_value + ); for (code, i) in src_codes.iter().zip(0..) { - println!(" {code:<8} {:>6.1} % (SE {:.1})", fit.weights[i] * 100.0, fit.std_errors[i] * 100.0); + println!( + " {code:<8} {:>6.1} % (SE {:.1})", + fit.weights[i] * 100.0, + fit.std_errors[i] * 100.0 + ); } println!( "\nmodel {} at p=0.05; weights {}", if fit.p_value >= 0.05 { "ACCEPTED" } else { "REJECTED" }, - if fit.weights_feasible(0.02) { "feasible" } else { "INFEASIBLE (outside [0,1])" } + if fit.weights_feasible(0.02) { + "feasible" + } else { + "INFEASIBLE (outside [0,1])" + } ); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/qpadm_from_tsv.rs b/crates/navigator-panelbuild/examples/qpadm_from_tsv.rs index a67d6fa3..6f77f409 100644 --- a/crates/navigator-panelbuild/examples/qpadm_from_tsv.rs +++ b/crates/navigator-panelbuild/examples/qpadm_from_tsv.rs @@ -10,7 +10,9 @@ use navigator_analysis::caller::SiteGenotype; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let panel_path = std::env::args().nth(1).expect("usage: qpadm_from_tsv "); + let panel_path = std::env::args() + .nth(1) + .expect("usage: qpadm_from_tsv "); let tsv = std::env::args().nth(2).expect("dosage.tsv"); let panel = AncestryPanel::from_bytes(&std::fs::read(&panel_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -18,8 +20,12 @@ fn main() -> anyhow::Result<()> { let mut dosage: HashMap<(String, i64), i32> = HashMap::new(); for line in std::fs::read_to_string(&tsv)?.lines() { let mut it = line.split('\t'); - let (Some(c), Some(p), Some(d)) = (it.next(), it.next(), it.next()) else { continue }; - let (Ok(p), Ok(d)) = (p.trim().parse::(), d.trim().parse::()) else { continue }; + let (Some(c), Some(p), Some(d)) = (it.next(), it.next(), it.next()) else { + continue; + }; + let (Ok(p), Ok(d)) = (p.trim().parse::(), d.trim().parse::()) else { + continue; + }; dosage.insert((c.to_string(), p), d); } @@ -46,26 +52,44 @@ fn main() -> anyhow::Result<()> { }) .collect(); let called = gts.iter().filter(|g| g.dosage >= 0).count(); - eprintln!("{} panel sites, {} matched in TSV, {called} called", panel.sites.len(), gts.len()); + eprintln!( + "{} panel sites, {} matched in TSV, {called} called", + panel.sites.len(), + gts.len() + ); // Source codes: 3rd arg (comma-separated), else the default frequency-EM sources. let src_codes: Vec = std::env::args() .nth(3) .map(|s| s.split(',').map(|x| x.trim().to_string()).collect()) .unwrap_or_else(|| vec!["WHG".into(), "ANF".into(), "Steppe".into()]); - let sources: Vec = src_codes.iter().map(|c| panel.populations.iter().position(|p| p == c).unwrap()).collect(); + let sources: Vec = src_codes + .iter() + .map(|c| panel.populations.iter().position(|p| p == c).unwrap()) + .collect(); let outgroups: Vec = (0..panel.populations.len()).filter(|i| !sources.contains(i)).collect(); let fit = qpadm_fit(>s, &panel, &sources, &outgroups, F4_BLOCK_BP) .ok_or_else(|| anyhow::anyhow!("qpadm_fit returned None"))?; - println!("\nsites {} blocks {} dof {} chi2 {:.2} p {:.4}", fit.n_sites, fit.n_blocks, fit.dof, fit.chi2, fit.p_value); + println!( + "\nsites {} blocks {} dof {} chi2 {:.2} p {:.4}", + fit.n_sites, fit.n_blocks, fit.dof, fit.chi2, fit.p_value + ); for (code, i) in src_codes.iter().zip(0..) { - println!(" {code:<8} {:>6.1} % (SE {:.1})", fit.weights[i] * 100.0, fit.std_errors[i] * 100.0); + println!( + " {code:<8} {:>6.1} % (SE {:.1})", + fit.weights[i] * 100.0, + fit.std_errors[i] * 100.0 + ); } println!( "\nmodel {} at p=0.05; weights {}", if fit.p_value >= 0.05 { "ACCEPTED" } else { "REJECTED" }, - if fit.weights_feasible(0.02) { "feasible" } else { "INFEASIBLE" } + if fit.weights_feasible(0.02) { + "feasible" + } else { + "INFEASIBLE" + } ); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/qpadm_selftest.rs b/crates/navigator-panelbuild/examples/qpadm_selftest.rs index aaac8469..472bf2a0 100644 --- a/crates/navigator-panelbuild/examples/qpadm_selftest.rs +++ b/crates/navigator-panelbuild/examples/qpadm_selftest.rs @@ -12,7 +12,10 @@ use navigator_analysis::caller::SiteGenotype; struct Lcg(u64); impl Lcg { fn f(&mut self) -> f64 { - self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); (self.0 >> 11) as f64 / (1u64 << 53) as f64 } fn dosage(&mut self, p: f64) -> i32 { @@ -64,7 +67,11 @@ fn run(panel: &AncestryPanel, label: &str, truth: [f64; 3], seed: u64) { f.std_errors[1] * 100.0, f.std_errors[2] * 100.0, f.p_value, - if f.weights_feasible(0.02) { "feasible" } else { "INFEASIBLE" }, + if f.weights_feasible(0.02) { + "feasible" + } else { + "INFEASIBLE" + }, ); } None => println!("{label:<28} qpadm_fit -> None"), @@ -72,7 +79,9 @@ fn run(panel: &AncestryPanel, label: &str, truth: [f64; 3], seed: u64) { } fn main() -> anyhow::Result<()> { - let path = std::env::args().nth(1).expect("usage: qpadm_selftest "); + let path = std::env::args() + .nth(1) + .expect("usage: qpadm_selftest "); let panel = AncestryPanel::from_bytes(&std::fs::read(&path)?).map_err(|e| anyhow::anyhow!("{e}"))?; println!( "panel {} sites, outgroups {:?}\ntruth order [WHG, ANF, Steppe]:\n", diff --git a/crates/navigator-panelbuild/examples/resolve_chip_dosage.rs b/crates/navigator-panelbuild/examples/resolve_chip_dosage.rs index 56d7c031..2812dcb0 100644 --- a/crates/navigator-panelbuild/examples/resolve_chip_dosage.rs +++ b/crates/navigator-panelbuild/examples/resolve_chip_dosage.rs @@ -10,7 +10,9 @@ use navigator_domain::chipprofile; use std::io::{BufWriter, Write}; fn main() -> anyhow::Result<()> { - let ibd_path = std::env::args().nth(1).expect("usage: resolve_chip_dosage "); + let ibd_path = std::env::args() + .nth(1) + .expect("usage: resolve_chip_dosage "); let chip_path = std::env::args().nth(2).expect("chip.txt"); let out = std::env::args().nth(3).expect("out.tsv"); @@ -20,7 +22,8 @@ fn main() -> anyhow::Result<()> { eprintln!("chip build {build}: {} autosomal calls", calls.len()); let ibd = IbdPanel::from_bytes(&std::fs::read(&ibd_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; - let tuples: Vec<(String, i64, char, char)> = calls.into_iter().map(|c| (c.contig, c.position, c.a1, c.a2)).collect(); + let tuples: Vec<(String, i64, char, char)> = + calls.into_iter().map(|c| (c.contig, c.position, c.a1, c.a2)).collect(); let gts = ibd.resolve_chip(&build, &tuples); let mut w = BufWriter::new(std::fs::File::create(&out)?); diff --git a/crates/navigator-panelbuild/examples/score_modern_from_tsv.rs b/crates/navigator-panelbuild/examples/score_modern_from_tsv.rs index 1dfa7f4f..7f2d7ebc 100644 --- a/crates/navigator-panelbuild/examples/score_modern_from_tsv.rs +++ b/crates/navigator-panelbuild/examples/score_modern_from_tsv.rs @@ -13,7 +13,9 @@ use navigator_analysis::caller::SiteGenotype; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let fine_path = std::env::args().nth(1).expect("usage: score_modern_from_tsv [pca.bin]"); + let fine_path = std::env::args() + .nth(1) + .expect("usage: score_modern_from_tsv [pca.bin]"); let tsv = std::env::args().nth(2).expect("dosage.tsv"); let pca_path = std::env::args().nth(3).filter(|s| !s.is_empty()); @@ -32,7 +34,9 @@ fn main() -> anyhow::Result<()> { 3 => (f[0], f[1], f[2]), _ => continue, }; - let (Ok(p), Ok(d)) = (p.trim().parse::(), d.trim().parse::()) else { continue }; + let (Ok(p), Ok(d)) = (p.trim().parse::(), d.trim().parse::()) else { + continue; + }; dosage.insert((c.to_string(), p), d); } @@ -81,7 +85,10 @@ fn main() -> anyhow::Result<()> { let mut comps = result.components.clone(); comps.sort_by(|a, b| b.percentage.total_cmp(&a.percentage)); for c in comps.iter().filter(|c| c.percentage >= 0.5) { - println!(" {:<5} {:<22} {:>6.1} %", c.population_code, c.population_name, c.percentage); + println!( + " {:<5} {:<22} {:>6.1} %", + c.population_code, c.population_name, c.percentage + ); } if let Some(pca_path) = pca_path { diff --git a/crates/navigator-panelbuild/examples/score_superpop_from_tsv.rs b/crates/navigator-panelbuild/examples/score_superpop_from_tsv.rs index 0e80c222..97bdfa5e 100644 --- a/crates/navigator-panelbuild/examples/score_superpop_from_tsv.rs +++ b/crates/navigator-panelbuild/examples/score_superpop_from_tsv.rs @@ -6,7 +6,9 @@ use navigator_analysis::caller::SiteGenotype; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let panel_path = std::env::args().nth(1).expect("usage: score_superpop_from_tsv "); + let panel_path = std::env::args() + .nth(1) + .expect("usage: score_superpop_from_tsv "); let tsv = std::env::args().nth(2).expect("dosage.tsv"); let panel = AncestryPanel::from_bytes(&std::fs::read(&panel_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -21,7 +23,9 @@ fn main() -> anyhow::Result<()> { 3 => (f[0], f[1], f[2]), _ => continue, }; - let (Ok(p), Ok(d)) = (p.trim().parse::(), d.trim().parse::()) else { continue }; + let (Ok(p), Ok(d)) = (p.trim().parse::(), d.trim().parse::()) else { + continue; + }; dosage.insert((c.to_string(), p), d); } let gts: Vec = panel @@ -47,11 +51,19 @@ fn main() -> anyhow::Result<()> { }) .collect(); let r = estimate_admixture(>s, &panel, "chm13v2.0"); - println!("super-pop panel: {} pops, {} sites used", panel.populations.len(), r.snps_with_genotype); + println!( + "super-pop panel: {} pops, {} sites used", + panel.populations.len(), + r.snps_with_genotype + ); let mut comps = r.components.clone(); comps.sort_by(|a, b| b.percentage.total_cmp(&a.percentage)); for c in &comps { - let gated = if c.percentage < 2.0 { " <- dropped by 2% gate" } else { "" }; + let gated = if c.percentage < 2.0 { + " <- dropped by 2% gate" + } else { + "" + }; println!(" {:<5} {:>6.2} %{}", c.population_code, c.percentage, gated); } Ok(()) diff --git a/crates/navigator-panelbuild/examples/verify_qpadm_fit.rs b/crates/navigator-panelbuild/examples/verify_qpadm_fit.rs index cde620cf..e96fe3c2 100644 --- a/crates/navigator-panelbuild/examples/verify_qpadm_fit.rs +++ b/crates/navigator-panelbuild/examples/verify_qpadm_fit.rs @@ -24,7 +24,15 @@ fn main() -> anyhow::Result<()> { let (traw, ind, rschm, rsba, james) = (&a[1], &a[2], &a[3], &a[4], &a[5]); // Population order: sources first, then outgroups. - let pops = ["WHG", "EEF", "Steppe", "AnatoliaOG", "Afanasievo", "IronGates", "African"]; + let pops = [ + "WHG", + "EEF", + "Steppe", + "AnatoliaOG", + "Afanasievo", + "IronGates", + "African", + ]; let pop_idx: HashMap<&str, usize> = pops.iter().enumerate().map(|(i, &p)| (p, i)).collect(); // .ind → label per traw sample column (skip the appended Target row). @@ -68,7 +76,9 @@ fn main() -> anyhow::Result<()> { } let f: Vec<&str> = line.split('\t').collect(); let rsid = f[1]; - let Some(&(ref contig, pos)) = chm.get(rsid) else { continue }; + let Some(&(ref contig, pos)) = chm.get(rsid) else { + continue; + }; let Some(&ba) = bedalt.get(rsid) else { continue }; let counted = f[4].as_bytes()[0]; let alt = f[5].as_bytes()[0]; @@ -142,14 +152,25 @@ fn main() -> anyhow::Result<()> { let fit = qpadm_fit(&genos, &panel, &sources, &outgroups, F4_BLOCK_BP) .ok_or_else(|| anyhow::anyhow!("qpadm_fit returned None"))?; println!("\n== our qpadm_fit — James (Patterson config) =="); - println!("sites {} blocks {} dof {} chi2 {:.2} p {:.4}", fit.n_sites, fit.n_blocks, fit.dof, fit.chi2, fit.p_value); + println!( + "sites {} blocks {} dof {} chi2 {:.2} p {:.4}", + fit.n_sites, fit.n_blocks, fit.dof, fit.chi2, fit.p_value + ); for (c, i) in ["WHG", "EEF", "Steppe"].iter().zip(0..) { - println!(" {c:<8} {:>6.1} % (SE {:.1})", fit.weights[i] * 100.0, fit.std_errors[i] * 100.0); + println!( + " {c:<8} {:>6.1} % (SE {:.1})", + fit.weights[i] * 100.0, + fit.std_errors[i] * 100.0 + ); } println!( "model {} at p=0.05; weights {}", if fit.p_value >= 0.05 { "ACCEPTED" } else { "REJECTED" }, - if fit.weights_feasible(0.02) { "feasible" } else { "INFEASIBLE" } + if fit.weights_feasible(0.02) { + "feasible" + } else { + "INFEASIBLE" + } ); Ok(()) } diff --git a/crates/navigator-panelbuild/src/archaic.rs b/crates/navigator-panelbuild/src/archaic.rs index ad2983d2..380c1f4e 100644 --- a/crates/navigator-panelbuild/src/archaic.rs +++ b/crates/navigator-panelbuild/src/archaic.rs @@ -257,9 +257,7 @@ pub fn build_archaic_candidates(args: ArchaicCandidatesArgs) -> Result<()> { // reference-confident record states only the REF base and cannot define the pair. At least // one genome must vary, otherwise the site is invariant across all four and carries no // information regardless of polarity. - let Some((reference_allele, alternate_allele)) = present - .iter() - .find_map(|(r, a, _)| a.map(|alt| (*r, alt))) + let Some((reference_allele, alternate_allele)) = present.iter().find_map(|(r, a, _)| a.map(|alt| (*r, alt))) else { continue; }; @@ -520,7 +518,11 @@ fn derived_freq(derived: char, og_ref: char, og_alt: char, af_alt: f32) -> Optio /// /// Same discipline as the CHM13 pass: `CrossMap bed` is not allele-aware, so each site is oriented /// against the hg38 reference base (swap ref/alt where reversed, drop where neither matches). -fn build_hg38_loci(bed: &Path, reference: &Path, candidates: &HashMap) -> Result> { +fn build_hg38_loci( + bed: &Path, + reference: &Path, + candidates: &HashMap, +) -> Result> { let lifted = load_lifted(bed)?; let mut rows: Vec<(usize, String, i64)> = lifted.into_iter().map(|(i, (c, p))| (i, c, p)).collect(); rows.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2))); diff --git a/crates/navigator-panelbuild/src/archaic_dist.rs b/crates/navigator-panelbuild/src/archaic_dist.rs index bdc735df..63ee48bf 100644 --- a/crates/navigator-panelbuild/src/archaic_dist.rs +++ b/crates/navigator-panelbuild/src/archaic_dist.rs @@ -341,7 +341,11 @@ pub fn build_archaic_dist(args: ArchaicDistArgs) -> Result<()> { let mean = vals.iter().sum::() / vals.len() as f64; let observed = vals.iter().map(|v| (v - mean).powi(2)).sum::() / (vals.len() - 1) as f64; // Actual density realised by the hash, not the nominal target. - let realised = site_freqs[pi].iter().enumerate().filter(|(si, _)| in_rung(*si, r)).count() as f32 + let realised = site_freqs[pi] + .iter() + .enumerate() + .filter(|(si, _)| in_rung(*si, r)) + .count() as f32 / panel.len().max(1) as f32; ladder.push((realised, (observed / predicted).max(1.0) as f32)); } @@ -386,7 +390,10 @@ pub fn build_archaic_dist(args: ArchaicDistArgs) -> Result<()> { ladder = kept; } ladder.sort_by(|a, b| a.0.total_cmp(&b.0)); - let shown: Vec = ladder.iter().map(|(d, i)| format!("{:.1}%:{:.1}x", d * 100.0, i)).collect(); + let shown: Vec = ladder + .iter() + .map(|(d, i)| format!("{:.1}%:{:.1}x", d * 100.0, i)) + .collect(); eprintln!(" {sup:<5} variance inflation by density {}", shown.join(" ")); variance_inflation.push(ladder); } diff --git a/crates/navigator-panelbuild/src/archaic_tierb.rs b/crates/navigator-panelbuild/src/archaic_tierb.rs index ae873fb8..b725262a 100644 --- a/crates/navigator-panelbuild/src/archaic_tierb.rs +++ b/crates/navigator-panelbuild/src/archaic_tierb.rs @@ -16,8 +16,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use clap::Parser; use navigator_analysis::archaic::{ - ArchaicCallable, ArchaicClassify, ArchaicOutgroup, CallableContig, ClassifyContig, DiagnosticClass, - PositionStream, + ArchaicCallable, ArchaicClassify, ArchaicOutgroup, CallableContig, ClassifyContig, DiagnosticClass, PositionStream, }; use crate::pca::{open_maybe_gz, write_bin}; @@ -49,7 +48,9 @@ fn load_positions(path: &Path) -> Result>> { continue; } let mut it = line.split_whitespace(); - let (Some(c), Some(p)) = (it.next(), it.next()) else { continue }; + let (Some(c), Some(p)) = (it.next(), it.next()) else { + continue; + }; let Ok(pos) = p.parse::() else { continue }; by_contig.entry(c.to_string()).or_default().push(pos); } @@ -62,7 +63,11 @@ fn load_positions(path: &Path) -> Result>> { pub fn build_archaic_outgroup(args: ArchaicOutgroupArgs) -> Result<()> { let by_contig = load_positions(&args.sites)?; - anyhow::ensure!(!by_contig.is_empty(), "no outgroup sites read from {}", args.sites.display()); + anyhow::ensure!( + !by_contig.is_empty(), + "no outgroup sites read from {}", + args.sites.display() + ); let mut contigs = Vec::with_capacity(by_contig.len()); let mut total = 0usize; @@ -167,7 +172,10 @@ pub fn build_archaic_classify(args: ArchaicClassifyArgs) -> Result<()> { } } if let Some(&(derived, class)) = payload.get(&idx) { - by_contig.entry(f[0].to_string()).or_default().push((end, derived, class)); + by_contig + .entry(f[0].to_string()) + .or_default() + .push((end, derived, class)); } } anyhow::ensure!(!by_contig.is_empty(), "no classification sites survived the join"); @@ -249,7 +257,11 @@ pub fn build_archaic_callable(args: ArchaicCallableArgs) -> Result<()> { spans.entry(f[0].to_string()).or_default().push((s, e)); } } - anyhow::ensure!(!spans.is_empty(), "no callable intervals read from {}", args.bed.display()); + anyhow::ensure!( + !spans.is_empty(), + "no callable intervals read from {}", + args.bed.display() + ); let mut contigs = Vec::with_capacity(spans.len()); let mut total_bp = 0f64; @@ -267,7 +279,9 @@ pub fn build_archaic_callable(args: ArchaicCallableArgs) -> Result<()> { let win_end = start + (idx as i64 + 1) * args.window_bp; let take = end.min(win_end) - cur; if let Some(slot) = callable_bp.get_mut(idx) { - *slot = slot.saturating_add(take.clamp(0, u16::MAX as i64) as u16).min(args.window_bp as u16); + *slot = slot + .saturating_add(take.clamp(0, u16::MAX as i64) as u16) + .min(args.window_bp as u16); } cur = win_end; } diff --git a/crates/navigator-panelbuild/src/hap_panel.rs b/crates/navigator-panelbuild/src/hap_panel.rs index 009754ca..bbd1e244 100644 --- a/crates/navigator-panelbuild/src/hap_panel.rs +++ b/crates/navigator-panelbuild/src/hap_panel.rs @@ -133,7 +133,11 @@ fn allele(s: Option<&str>) -> (u8, bool) { /// A biallelic SNV has single-base ref and alt over `{A,C,G,T}` (skips indels / multiallelic). fn is_biallelic_snv(ref_s: &str, alt_s: &str) -> bool { - let single = |s: &str| s.len() == 1 && s.chars().all(|c| matches!(c.to_ascii_uppercase(), 'A' | 'C' | 'G' | 'T')); + let single = |s: &str| { + s.len() == 1 + && s.chars() + .all(|c| matches!(c.to_ascii_uppercase(), 'A' | 'C' | 'G' | 'T')) + }; single(ref_s) && single(alt_s) } @@ -171,7 +175,10 @@ pub fn build_hap_panel(args: HapPanelArgs) -> Result<()> { .map(|((c, p), (rf, alt, _))| (c.clone(), *p, *rf, *alt)) .collect(); site_keys.sort_by(|a, b| (a.0.as_str(), a.1).cmp(&(b.0.as_str(), b.1))); - anyhow::ensure!(!site_keys.is_empty(), "no shared biallelic SNV sites across the sources"); + anyhow::ensure!( + !site_keys.is_empty(), + "no shared biallelic SNV sites across the sources" + ); let sites: Vec = site_keys .iter() .map(|(c, p, r, a)| HapSite { @@ -191,7 +198,10 @@ pub fn build_hap_panel(args: HapPanelArgs) -> Result<()> { let mut per_source_labelled = vec![0usize; sources.len()]; for (src_i, src) in sources.iter().enumerate() { // Per-site aligned genotype vectors for this source (one map lookup per site, not per sample). - let aligned: Vec<&Vec<(u8, u8)>> = site_keys.iter().map(|(c, p, _, _)| &src.sites[&(c.clone(), *p)].2).collect(); + let aligned: Vec<&Vec<(u8, u8)>> = site_keys + .iter() + .map(|(c, p, _, _)| &src.sites[&(c.clone(), *p)].2) + .collect(); for (si, sample) in src.samples.iter().enumerate() { let Some(pop) = fine.get(sample) else { continue; @@ -215,7 +225,11 @@ pub fn build_hap_panel(args: HapPanelArgs) -> Result<()> { per_source_labelled[src_i] += 1; } } - anyhow::ensure!(!rows.is_empty(), "no labelled samples across the sources (check {})", args.pops.display()); + anyhow::ensure!( + !rows.is_empty(), + "no labelled samples across the sources (check {})", + args.pops.display() + ); let reference = HaplotypeReference::from_rows(BUILD.to_string(), sites, populations, hap_pop, &rows); eprintln!( diff --git a/crates/navigator-panelbuild/src/pca.rs b/crates/navigator-panelbuild/src/pca.rs index 4492bd9b..731a9785 100644 --- a/crates/navigator-panelbuild/src/pca.rs +++ b/crates/navigator-panelbuild/src/pca.rs @@ -567,7 +567,10 @@ pub fn build_fine_panel(args: FinePanelArgs) -> Result<()> { /// floor — not the diploid coding — is what matters. pub fn build_ancient_panel(args: AncientPanelArgs) -> Result<()> { let parse_list = |s: &str| -> Vec { - s.split(',').map(|c| c.trim().to_string()).filter(|c| !c.is_empty()).collect() + s.split(',') + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()) + .collect() }; let sources: Vec = parse_list(&args.components); let outgroup_comps: Vec = parse_list(&args.outgroups); @@ -582,7 +585,13 @@ pub fn build_ancient_panel(args: AncientPanelArgs) -> Result<()> { ); // Per-population call floor: sources use --min-called, outgroups the lower --outgroup-min-called. let floor: Vec = (0..comps.len()) - .map(|i| if i < n_src { args.min_called } else { args.outgroup_min_called }) + .map(|i| { + if i < n_src { + args.min_called + } else { + args.outgroup_min_called + } + }) .collect(); let pop_of = load_fine_map(&args.pops)?; @@ -618,7 +627,11 @@ pub fn build_ancient_panel(args: AncientPanelArgs) -> Result<()> { (!contig.eq_ignore_ascii_case("contig")).then(|| (contig.to_string(), pos)) }) .collect(); - anyhow::ensure!(!set.is_empty(), "ascertainment file {} had no usable contigpos rows", p.display()); + anyhow::ensure!( + !set.is_empty(), + "ascertainment file {} had no usable contigpos rows", + p.display() + ); eprintln!("ascertainment floor: {} sites from {}", set.len(), p.display()); Some(set) } @@ -734,7 +747,10 @@ pub fn build_ancient_panel(args: AncientPanelArgs) -> Result<()> { oriented.len() ); sites = oriented; - anyhow::ensure!(!sites.is_empty(), "no site survived CHM13 orientation — wrong reference?"); + anyhow::ensure!( + !sites.is_empty(), + "no site survived CHM13 orientation — wrong reference?" + ); } let panel = AncestryPanel { diff --git a/crates/navigator-refgenome/src/cache.rs b/crates/navigator-refgenome/src/cache.rs index 39f8f0f1..0c3e235a 100644 --- a/crates/navigator-refgenome/src/cache.rs +++ b/crates/navigator-refgenome/src/cache.rs @@ -143,7 +143,10 @@ mod tests { atomic_write(&path, b"{\"x\":1}").unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"x\":1}"); // The rename consumes the temp, so only the target remains — no stray `*.tmp.*` files. - let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + let entries: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); assert_eq!(entries, vec![std::ffi::OsString::from("cfg.json")]); let _ = std::fs::remove_dir_all(&dir); } @@ -157,8 +160,7 @@ mod tests { let dir = std::env::temp_dir().join(format!("atomicw_{}_conc", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let path = Arc::new(dir.join("cfg.json")); - let payloads: Arc> = - Arc::new((0..8).map(|i| format!("[{i}{}]", ",0".repeat(i * 400))).collect()); + let payloads: Arc> = Arc::new((0..8).map(|i| format!("[{i}{}]", ",0".repeat(i * 400))).collect()); atomic_write(&path, payloads[0].as_bytes()).unwrap(); let mut handles = Vec::new(); for _ in 0..24 { @@ -191,7 +193,10 @@ mod tests { let started = std::time::Instant::now(); let err = read_atomic(&path).expect_err("missing file must error"); assert_eq!(err.kind(), std::io::ErrorKind::NotFound); - assert!(started.elapsed() < std::time::Duration::from_millis(100), "missing file was retried"); + assert!( + started.elapsed() < std::time::Duration::from_millis(100), + "missing file was retried" + ); atomic_write(&path, b"{\"x\":1}").unwrap(); assert_eq!(read_atomic(&path).unwrap(), b"{\"x\":1}"); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/navigator-refgenome/src/gateway.rs b/crates/navigator-refgenome/src/gateway.rs index a7b1d500..d2407fde 100644 --- a/crates/navigator-refgenome/src/gateway.rs +++ b/crates/navigator-refgenome/src/gateway.rs @@ -195,6 +195,7 @@ impl ReferenceGateway { })?; let sha = download::download(&self.http, &src.url, &path, progress).await?; verify_pinned(&path, src.sha256.as_deref(), &sha)?; // verify the artifact exactly as served + // The cache stores chains as plain text (`load_liftover` reads them with `read_to_string`). // Every chain flows through the same path: if the downloaded artifact is gzipped (UCSC // serves `.over.chain.gz`; the curated bucket serves plain `.chain`), decompress it in place @@ -770,7 +771,8 @@ mod tests { // Disk hit (any alias / the masked variant share CHM13's regions). let r = g.cached_genome_regions("hs1").expect("disk-cached regions"); assert!(r.chromosome("chrY").unwrap().par.len() == 2); // PAR overlaid by the parser - // Second call is an in-memory hit (same Arc). + + // Second call is an in-memory hit (same Arc). let r2 = g.cached_genome_regions("chm13v2.0_maskedY_rCRS").unwrap(); assert!(Arc::ptr_eq(&r, &r2)); diff --git a/crates/navigator-refgenome/src/registry.rs b/crates/navigator-refgenome/src/registry.rs index 714f46b7..5ee5e8c3 100644 --- a/crates/navigator-refgenome/src/registry.rs +++ b/crates/navigator-refgenome/src/registry.rs @@ -217,9 +217,9 @@ impl UserConfig { // `read_atomic`, not `fs::read_to_string`: a save racing this read leaves the path briefly // delete-pending on Windows, and an unreadable config here means the user's overrides // silently vanish — the same disappearing-override symptom as issue #26, by another route. - let Ok(text) = crate::cache::read_atomic(path).and_then(|b| { - String::from_utf8(b).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) - }) else { + let Ok(text) = crate::cache::read_atomic(path) + .and_then(|b| String::from_utf8(b).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))) + else { return Self::default(); // absent / unreadable → empty (the normal no-config case) }; match serde_json::from_str(&text) { @@ -312,7 +312,12 @@ impl Registry { } _ => return None, }; - Some(ChainSource { from, to, url, sha256: None }) + Some(ChainSource { + from, + to, + url, + sha256: None, + }) } /// The UCSC `cytoBand` table URL for a build (gzipped) — the source for genome-region diff --git a/crates/navigator-store/src/artifact.rs b/crates/navigator-store/src/artifact.rs index 242acfa1..b042157d 100644 --- a/crates/navigator-store/src/artifact.rs +++ b/crates/navigator-store/src/artifact.rs @@ -265,9 +265,19 @@ mod tests { } async fn full_coverage(pool: &SqlitePool, aln: i64) { - upsert(pool, aln, "coverage", "coverage-1", Utc::now(), "{}", "navigator-walk", "full", None) - .await - .unwrap(); + upsert( + pool, + aln, + "coverage", + "coverage-1", + Utc::now(), + "{}", + "navigator-walk", + "full", + None, + ) + .await + .unwrap(); } #[tokio::test] @@ -289,9 +299,19 @@ mod tests { // C: one alignment with only a *partial* (sidecar) coverage → does not count → Pending. let c = subject(pool, "C").await; let c_aln = alignment(pool, c).await; - upsert(pool, c_aln, "coverage", "coverage-1", Utc::now(), "{}", "pipeline-sidecar", "partial", None) - .await - .unwrap(); + upsert( + pool, + c_aln, + "coverage", + "coverage-1", + Utc::now(), + "{}", + "pipeline-sidecar", + "partial", + None, + ) + .await + .unwrap(); // D: a subject with no alignments → absent from the census. let _d = subject(pool, "D").await; diff --git a/crates/navigator-store/src/consensus_archaic_segments.rs b/crates/navigator-store/src/consensus_archaic_segments.rs index 87d62367..5c5401f8 100644 --- a/crates/navigator-store/src/consensus_archaic_segments.rs +++ b/crates/navigator-store/src/consensus_archaic_segments.rs @@ -44,10 +44,11 @@ pub async fn upsert( /// The cached segments marker count result for a biosample, if one exists (caller checks the signature for staleness). pub async fn get(pool: &SqlitePool, guid: SampleGuid) -> Result, StoreError> { - let row: Option = sqlx::query_as("SELECT * FROM consensus_archaic_segments WHERE biosample_guid = ?") - .bind(guid.0.to_string()) - .fetch_optional(pool) - .await?; + let row: Option = + sqlx::query_as("SELECT * FROM consensus_archaic_segments WHERE biosample_guid = ?") + .bind(guid.0.to_string()) + .fetch_optional(pool) + .await?; Ok(row) } diff --git a/crates/navigator-store/src/external_panel_dosage.rs b/crates/navigator-store/src/external_panel_dosage.rs index 49602ad0..aa3e93b6 100644 --- a/crates/navigator-store/src/external_panel_dosage.rs +++ b/crates/navigator-store/src/external_panel_dosage.rs @@ -43,10 +43,7 @@ pub async fn upsert(pool: &SqlitePool, row: &StoredPanelDosage) -> Result<(), St } /// All external panel-dosage rows for a biosample (each a distinct source). -pub async fn list_for_biosample( - pool: &SqlitePool, - guid: SampleGuid, -) -> Result, StoreError> { +pub async fn list_for_biosample(pool: &SqlitePool, guid: SampleGuid) -> Result, StoreError> { let rows: Vec = sqlx::query_as( "SELECT biosample_guid, source_label, provenance, panel_sig, site_count, dosages, created_at \ FROM external_panel_dosage WHERE biosample_guid = ? ORDER BY id", diff --git a/crates/navigator-store/tests/store.rs b/crates/navigator-store/tests/store.rs index 14203918..68f73f9d 100644 --- a/crates/navigator-store/tests/store.rs +++ b/crates/navigator-store/tests/store.rs @@ -114,7 +114,8 @@ async fn run_alignment_chain_persists() { run ); assert_eq!(run.mean_insert_size, Some(580.7)); // flat metric column round-trips - // The lab/instrument identity block is None at create, then filled by set_library_stats. + + // The lab/instrument identity block is None at create, then filled by set_library_stats. assert_eq!(run.instrument_id, None); sequence_run::set_library_stats( s.pool(), @@ -161,6 +162,7 @@ async fn run_alignment_chain_persists() { assert_eq!(reloaded.total_reads, Some(9_100_000)); assert_eq!(reloaded.library_layout.as_deref(), Some("PAIRED")); assert_eq!(reloaded.total_bases, Some(1_365_000_000)); // preserved by COALESCE + // The descriptive + identity columns are untouched by the read-stats write. assert_eq!(reloaded.instrument_id.as_deref(), Some("A00182")); @@ -709,18 +711,24 @@ async fn bulk_member_counts_match_the_per_project_count() { // M:N member of p1 only. let a = sample(None); biosample::create(s.pool(), &a).await.unwrap(); - biosample_project::add(s.pool(), a.guid, p1.id, None, "2026-07-25").await.unwrap(); + biosample_project::add(s.pool(), a.guid, p1.id, None, "2026-07-25") + .await + .unwrap(); // Legacy home column of p1 only. let b = sample(Some(p1.id)); biosample::create(s.pool(), &b).await.unwrap(); // Both M:N and home for p1 — the UNION must count this once, not twice. let c = sample(Some(p1.id)); biosample::create(s.pool(), &c).await.unwrap(); - biosample_project::add(s.pool(), c.guid, p1.id, None, "2026-07-25").await.unwrap(); + biosample_project::add(s.pool(), c.guid, p1.id, None, "2026-07-25") + .await + .unwrap(); // Member of p2 only, so the GROUP BY has to key correctly. let d = sample(None); biosample::create(s.pool(), &d).await.unwrap(); - biosample_project::add(s.pool(), d.guid, p2.id, None, "2026-07-25").await.unwrap(); + biosample_project::add(s.pool(), d.guid, p2.id, None, "2026-07-25") + .await + .unwrap(); let counts: std::collections::HashMap = biosample::member_counts(s.pool()).await.unwrap().into_iter().collect(); @@ -729,7 +737,11 @@ async fn bulk_member_counts_match_the_per_project_count() { assert_eq!(counts.get(&p).copied().unwrap_or(0), one, "project {p}"); } assert_eq!(counts.get(&p1.id).copied().unwrap_or(0), 3, "a, b, c — c counted once"); - assert_eq!(counts.get(&empty.id), None, "a project with no members is absent, not zero"); + assert_eq!( + counts.get(&empty.id), + None, + "a project with no members is absent, not zero" + ); // Removing a subject drops its membership first (a foreign key forbids a dangling // `biosample_project` row, which is why both count forms can join to `biosample` safely). diff --git a/crates/navigator-sync/src/oauth.rs b/crates/navigator-sync/src/oauth.rs index c7220e5e..c30d0698 100644 --- a/crates/navigator-sync/src/oauth.rs +++ b/crates/navigator-sync/src/oauth.rs @@ -204,7 +204,10 @@ async fn post_with_dpop( let Some(nonce) = nonce else { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - return Err(SyncError::Oauth(format!("{post_url}: {status} {}", truncate_body(&body)))); + return Err(SyncError::Oauth(format!( + "{post_url}: {status} {}", + truncate_body(&body) + ))); }; let proof = dpop_proof(key, "POST", htu, now(), Some(&nonce), None); let retry = http.post(post_url).header("DPoP", proof).form(form).send().await?; @@ -213,7 +216,10 @@ async fn post_with_dpop( } else { let status = retry.status(); let body = retry.text().await.unwrap_or_default(); - Err(SyncError::Oauth(format!("{post_url}: {status} {}", truncate_body(&body)))) + Err(SyncError::Oauth(format!( + "{post_url}: {status} {}", + truncate_body(&body) + ))) } } diff --git a/crates/navigator-ui/src/charts.rs b/crates/navigator-ui/src/charts.rs index dfd9d19e..b941d76e 100644 --- a/crates/navigator-ui/src/charts.rs +++ b/crates/navigator-ui/src/charts.rs @@ -157,7 +157,9 @@ pub(crate) fn draw_roh(ui: &mut egui::Ui, result: &RohResult, regions: Option<&G .unwrap_or_else(|| segs.iter().map(|s| s.end_bp).max().unwrap_or(1)) .max(1) as f32; ui.horizontal(|ui| { - ui.allocate_ui(egui::vec2(label_w, bar_h), |ui| ui.label(egui::RichText::new(chr).small())); + ui.allocate_ui(egui::vec2(label_w, bar_h), |ui| { + ui.label(egui::RichText::new(chr).small()) + }); let (rect, resp) = ui.allocate_exact_size(egui::vec2(bar_w, bar_h), egui::Sense::hover()); let painter = ui.painter_at(rect); painter.rect_filled(rect, 2.0, egui::Color32::from_gray(30)); @@ -173,8 +175,14 @@ pub(crate) fn draw_roh(ui: &mut egui::Ui, result: &RohResult, regions: Option<&G if hx >= block.left() && hx <= block.right() { hover = Some(format!( "{}:{}–{} · {:.1} Mb ({:.2} cM) · {} sites ({} het) · conf {:.2}", - seg.chromosome, seg.start_bp, seg.end_bp, seg.length_mb, seg.length_cm, seg.n_sites, - seg.n_het, seg.mean_posterior + seg.chromosome, + seg.start_bp, + seg.end_bp, + seg.length_mb, + seg.length_cm, + seg.n_sites, + seg.n_het, + seg.mean_posterior )); } } @@ -213,7 +221,10 @@ pub(crate) fn top_populations_for_side(segments: &[AncestrySegment], side: u8, k use std::collections::HashMap; let mut bp: HashMap = HashMap::new(); for s in segments.iter().filter(|s| s.copy == side) { - let code = s.fine_population_code.clone().unwrap_or_else(|| s.population_code.clone()); + let code = s + .fine_population_code + .clone() + .unwrap_or_else(|| s.population_code.clone()); *bp.entry(code).or_insert(0) += (s.end - s.start + 1).max(0); } let mut v: Vec<(String, i64)> = bp.into_iter().collect(); @@ -250,7 +261,9 @@ pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySe let hovered: Option = ui.data(|d| d.get_temp(hover_id)); let mut next_hovered: Option = None; let seg_code = |s: &AncestrySegment| -> String { - s.fine_population_code.clone().unwrap_or_else(|| s.population_code.clone()) + s.fine_population_code + .clone() + .unwrap_or_else(|| s.population_code.clone()) }; // Header: which stacked track is which side (▲ top, ▼ bottom). @@ -278,8 +291,7 @@ pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySe ui.allocate_ui(egui::vec2(label_w, copy_h * 2.0 + gap), |ui| { ui.label(format!("chr{n}")) }); - let (rect, response) = - ui.allocate_exact_size(egui::vec2(bar_w, copy_h * 2.0 + gap), egui::Sense::hover()); + let (rect, response) = ui.allocate_exact_size(egui::vec2(bar_w, copy_h * 2.0 + gap), egui::Sense::hover()); let painter = ui.painter_at(rect); for (c, segs) in copies.iter().enumerate() { let top = rect.top() + c as f32 * (copy_h + gap); @@ -302,7 +314,11 @@ pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySe } // Per-segment hover: highlight that population + show side / population / Mb-range tooltip. if let Some(pos) = response.hover_pos() { - let c = if pos.y < rect.top() + copy_h + gap * 0.5 { 0usize } else { 1usize }; + let c = if pos.y < rect.top() + copy_h + gap * 0.5 { + 0usize + } else { + 1usize + }; let bp = lo + (((pos.x - rect.left()) / rect.width().max(1.0)) * span) as i64; if let Some(s) = copies[c].iter().find(|s| bp >= s.start && bp <= s.end) { next_hovered = Some(seg_code(s)); @@ -336,7 +352,11 @@ pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySe let active = hovered.as_deref() == Some(code.as_str()); let inner = ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); - let swatch = if active || hovered.is_none() { *color } else { color.gamma_multiply(0.5) }; + let swatch = if active || hovered.is_none() { + *color + } else { + color.gamma_multiply(0.5) + }; ui.painter().circle_filled(r.center(), 4.0, swatch); let mut txt = egui::RichText::new(population_name(code)).small(); if active { @@ -346,7 +366,9 @@ pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySe } ui.label(txt); }); - let over = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| inner.response.rect.contains(p)); + let over = ui + .input(|i| i.pointer.hover_pos()) + .is_some_and(|p| inner.response.rect.contains(p)); if over { next_hovered = Some(code.clone()); } @@ -467,7 +489,8 @@ pub(crate) fn draw_population_components(ui: &mut egui::Ui, result: &AncestryRes for (name, code, pct) in shown { ui.horizontal(|ui| { let (sw, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); - ui.painter().rect_filled(sw, 2.0, parse_hex_color(&population_color(code))); + ui.painter() + .rect_filled(sw, 2.0, parse_hex_color(&population_color(code))); ui.add_space(2.0); ui.label(egui::RichText::new(format!("{pct:.1}%")).strong()); ui.label(*name); @@ -749,11 +772,7 @@ pub(crate) fn draw_archaic_segments(ui: &mut egui::Ui, result: &navigator_app::A // chr1, chr10, chr11 … chr19, chr2, chr20 — which reads as a bug to anyone scanning the track. let mut by_chr: BTreeMap<(u32, &str), Vec<&navigator_app::ArchaicSegment>> = BTreeMap::new(); for s in &result.segments { - let n = s - .contig - .trim_start_matches("chr") - .parse::() - .unwrap_or(u32::MAX); // non-numeric contigs sort last, keeping their own order + let n = s.contig.trim_start_matches("chr").parse::().unwrap_or(u32::MAX); // non-numeric contigs sort last, keeping their own order by_chr.entry((n, s.contig.as_str())).or_default().push(s); } if by_chr.is_empty() { diff --git a/crates/navigator-ui/src/cli.rs b/crates/navigator-ui/src/cli.rs index caeb47c6..d10064ce 100644 --- a/crates/navigator-ui/src/cli.rs +++ b/crates/navigator-ui/src/cli.rs @@ -468,7 +468,10 @@ async fn backfill_accessions(args: AccessionArgs) -> i32 { Ok(v) => v, Err(c) => return c, }; - let r = match app.backfill_accessions(project_id, args.apply, args.all, args.limit).await { + let r = match app + .backfill_accessions(project_id, args.apply, args.all, args.limit) + .await + { Ok(r) => r, Err(e) => return report(e), }; @@ -483,10 +486,16 @@ async fn backfill_accessions(args: AccessionArgs) -> i32 { println!(" ids attached (name + accession): {}", r.ids_added); println!(" local accession fixed: {}", r.accession_updated); if r.conflicts > 0 { - println!(" conflicts: {} (id already owned by another subject)", r.conflicts); + println!( + " conflicts: {} (id already owned by another subject)", + r.conflicts + ); } } else { - println!(" ids to attach (name + accession): {} (dry run — pass --apply)", r.ids_to_add); + println!( + " ids to attach (name + accession): {} (dry run — pass --apply)", + r.ids_to_add + ); } for ex in &r.examples { println!(" e.g. {ex}"); @@ -519,7 +528,10 @@ async fn backfill_catalog_ids(args: CatalogArgs) -> i32 { if r.applied { println!(" ids added: {}", r.ids_added); if r.conflicts > 0 { - println!(" conflicts: {} (id already owned by another subject — skipped)", r.conflicts); + println!( + " conflicts: {} (id already owned by another subject — skipped)", + r.conflicts + ); } } else { println!(" ids to add: {} (dry run — pass --apply)", r.ids_to_add); @@ -562,7 +574,10 @@ async fn prune_orphans(args: PruneArgs) -> i32 { if args.json { println!("{}", serde_json::to_string_pretty(&report).unwrap_or_default()); } else if report.applied { - println!("Examined {} alignment record(s); deleted {} orphan(s).", report.examined, report.deleted); + println!( + "Examined {} alignment record(s); deleted {} orphan(s).", + report.examined, report.deleted + ); for rk in &report.orphans { println!(" deleted {rk}"); } @@ -876,16 +891,15 @@ async fn analyze(args: AnalyzeArgs) -> i32 { .build_autosomal_profile(*biosample_guid) .await .map(|p| format!("{} site(s)", p.variants.len())), - AnalysisStep::Ancestry { biosample_guid } => app - .estimate_ancestry_from_consensus(*biosample_guid) - .await - .map(|r| { + AnalysisStep::Ancestry { biosample_guid } => { + app.estimate_ancestry_from_consensus(*biosample_guid).await.map(|r| { // The top super-population is the headline the brief shows. r.super_population_summary .first() .map(|p| format!("{} {:.0}%", p.super_population, p.percentage)) .unwrap_or_else(|| "(none)".into()) - }), + }) + } }; match outcome { Ok(summary) => eprintln!(" [{:>8.1?}] {n}/{total} {} — {summary}", t.elapsed(), step.label()), @@ -903,7 +917,6 @@ async fn analyze(args: AnalyzeArgs) -> i32 { 0 } - fn db_path(over: Option) -> PathBuf { over.unwrap_or_else(crate::default_db_path) } @@ -1348,7 +1361,10 @@ async fn private_y(args: DebugCallsArgs) -> i32 { Ok(calls) => { eprintln!("raw de-novo chrY calls: {}", calls.len()); for c in &calls { - println!("DENOVO\t{}\t{}\t{}\t{}\t{:.2}", c.position, c.depth, c.alt_depth, c.alternate_allele, c.allele_fraction); + println!( + "DENOVO\t{}\t{}\t{}\t{}\t{:.2}", + c.position, c.depth, c.alt_depth, c.alternate_allele, c.allele_fraction + ); } return 0; } @@ -1365,10 +1381,7 @@ async fn private_y(args: DebugCallsArgs) -> i32 { return 1; } }; - let gate = app - .publish_gate_for_alignment(alignment_id) - .await - .unwrap_or_default(); + let gate = app.publish_gate_for_alignment(alignment_id).await.unwrap_or_default(); println!("alignment {alignment_id} — terminal {}", bucket.terminal); println!(" DISPLAY (filtered) total: {}", bucket.variants.len()); println!(" off-path known: {}", bucket.off_path()); @@ -1525,10 +1538,13 @@ async fn branch_report(args: BranchReportArgs) -> i32 { r.position, r.ancestral, r.derived, - r.observed_base.map(|c| c.to_string()).unwrap_or_else(|| ".".to_string()), + r.observed_base + .map(|c| c.to_string()) + .unwrap_or_else(|| ".".to_string()), status(r.state), gt(r.state), - r.ad.map(|(rf, al)| format!("{rf},{al}")).unwrap_or_else(|| ".".to_string()), + r.ad.map(|(rf, al)| format!("{rf},{al}")) + .unwrap_or_else(|| ".".to_string()), opt(r.dp), opt(r.gq), r.source, @@ -1611,7 +1627,10 @@ async fn archaic_segments(args: ShowArgs) -> i32 { return 0; } let s = &r.summary; - println!("Archaic segments (Tier B): {:.1} Mb in {} tracts", s.total_mb, s.n_segments); + println!( + "Archaic segments (Tier B): {:.1} Mb in {} tracts", + s.total_mb, s.n_segments + ); println!(" {:.2}% of the {:.0} Mb callable", s.pct_callable, s.callable_mb); println!(" lineage split withheld — attribution is not yet reliable enough to report"); 0 @@ -1646,7 +1665,10 @@ async fn archaic(args: ArchaicArgs) -> i32 { println!("{}", serde_json::to_string_pretty(&r).unwrap_or_default()); return 0; } - println!("Archaic markers (Tier A): {} of {} copies", r.total_copies, r.possible_copies); + println!( + "Archaic markers (Tier A): {} of {} copies", + r.total_copies, r.possible_copies + ); println!( " {} of {} panel sites called ({:.1}%)", r.called_sites, @@ -1656,7 +1678,10 @@ async fn archaic(args: ArchaicArgs) -> i32 { println!(" rate {:.4} copies/site", r.rate()); println!(" Neanderthal {}", r.neanderthal_copies); println!(" shared archaic {}", r.shared_copies); - println!(" Denisovan {} (near the noise floor outside Oceania — not a finding)", r.denisovan_copies); + println!( + " Denisovan {} (near the noise floor outside Oceania — not a finding)", + r.denisovan_copies + ); match (r.percentile, &r.cohort) { (Some(p), Some(c)) => println!(" percentile more than {p:.0}% of {c}"), _ => println!(" percentile not reported (coverage not comparable to the reference cohort)"), @@ -1868,10 +1893,8 @@ pub struct DoctorArgs { async fn doctor(args: DoctorArgs) -> i32 { let diagnosis = if let Some(file) = args.file { let reference = args.reference; - match tokio::task::spawn_blocking(move || { - navigator_app::diagnose_alignment_file(&file, reference.as_deref()) - }) - .await + match tokio::task::spawn_blocking(move || navigator_app::diagnose_alignment_file(&file, reference.as_deref())) + .await { Ok(r) => r, Err(e) => { @@ -1952,10 +1975,14 @@ async fn call(args: CallArgs) -> i32 { let scope = args.contig.clone().unwrap_or_else(|| "whole genome".into()); eprintln!("calling de-novo diploid variants on alignment #{alignment_id} ({scope})…"); let vcf = match args.contig { - Some(contig) => app.diploid_vcf(alignment_id, contig, navigator_app::CancelToken::none()) - .await, - None => app.diploid_vcf_genome(alignment_id, navigator_app::CancelToken::none()) - .await, + Some(contig) => { + app.diploid_vcf(alignment_id, contig, navigator_app::CancelToken::none()) + .await + } + None => { + app.diploid_vcf_genome(alignment_id, navigator_app::CancelToken::none()) + .await + } }; let vcf = match vcf { Ok(v) => v, diff --git a/crates/navigator-ui/src/ui/branch.rs b/crates/navigator-ui/src/ui/branch.rs index 9e6ffb37..026797b2 100644 --- a/crates/navigator-ui/src/ui/branch.rs +++ b/crates/navigator-ui/src/ui/branch.rs @@ -51,7 +51,12 @@ impl NavigatorApp { }; self.branch_reports.retain(|(g, d, _)| !(*g == guid && *d == dna)); self.branch_loading.push((guid, dna)); - let _ = self.tx.send(Command::LoadBranchReport { guid, dna, node, depth: None }); + let _ = self.tx.send(Command::LoadBranchReport { + guid, + dna, + node, + depth: None, + }); } if self.branch_loading.iter().any(|(g, d)| *g == guid && *d == dna) { @@ -72,7 +77,11 @@ impl NavigatorApp { .iter() .any(|(g, d, r)| *g == guid && *d == dna && r.is_none()); ui.add_space(4.0); - let key = if no_alignment { "branch.noAlignment" } else { "branch.hint" }; + let key = if no_alignment { + "branch.noAlignment" + } else { + "branch.hint" + }; ui.label(egui::RichText::new(self.tr(key)).weak()); return; } @@ -173,7 +182,11 @@ impl NavigatorApp { cell(ui, W_NODE, egui::RichText::new(&r.node)); cell(ui, W_MARKER, egui::RichText::new(&r.marker)); cell(ui, W_POS, egui::RichText::new(r.position.to_string())); - cell(ui, W_ALLELES, egui::RichText::new(format!("{}>{}", r.ancestral, r.derived))); + cell( + ui, + W_ALLELES, + egui::RichText::new(format!("{}>{}", r.ancestral, r.derived)), + ); cell( ui, W_OBS, diff --git a/crates/navigator-ui/src/ui/central.rs b/crates/navigator-ui/src/ui/central.rs index 5ed48650..ce3d6a4b 100644 --- a/crates/navigator-ui/src/ui/central.rs +++ b/crates/navigator-ui/src/ui/central.rs @@ -706,7 +706,9 @@ impl NavigatorApp { /// The subject-detail header: big name, ID + sex, and Add Data / Edit / Delete actions. fn subject_detail_header(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { - let Some(bio) = self.find_subject(guid).cloned() else { return }; + let Some(bio) = self.find_subject(guid).cloned() else { + return; + }; ui.add_space(6.0); // When the subject was opened from a project's report, offer a way back to that project. if let Some(pid) = self.return_to_project { @@ -873,8 +875,24 @@ impl NavigatorApp { /// left, so the caller can fall back to a localized default. The user can rename later. fn first_run_subject_name(paths: &[std::path::PathBuf]) -> Option { const EXTS: [&str; 18] = [ - ".g.vcf.gz", ".vcf.gz", ".vcf.bgz", ".fasta.gz", ".fa.gz", ".fna.gz", ".bam", ".cram", ".vcf", ".fasta", - ".fa", ".fna", ".fas", ".csv", ".tsv", ".txt", ".gz", ".bgz", + ".g.vcf.gz", + ".vcf.gz", + ".vcf.bgz", + ".fasta.gz", + ".fa.gz", + ".fna.gz", + ".bam", + ".cram", + ".vcf", + ".fasta", + ".fa", + ".fna", + ".fas", + ".csv", + ".tsv", + ".txt", + ".gz", + ".bgz", ]; let name = paths.first()?.file_name()?.to_str()?; let lower = name.to_ascii_lowercase(); @@ -898,7 +916,10 @@ mod tests { #[test] fn derives_subject_name_from_file_stem() { assert_eq!(name("/data/HG002.bam").as_deref(), Some("HG002")); - assert_eq!(name("HG00096.chm13.chrY.g.vcf.gz").as_deref(), Some("HG00096.chm13.chrY")); + assert_eq!( + name("HG00096.chm13.chrY.g.vcf.gz").as_deref(), + Some("HG00096.chm13.chrY") + ); assert_eq!(name("MyKit.vcf.gz").as_deref(), Some("MyKit")); assert_eq!(name("genome_Full.CRAM").as_deref(), Some("genome_Full")); // extension match is case-insensitive assert_eq!(name("relative.fasta").as_deref(), Some("relative")); diff --git a/crates/navigator-ui/src/ui/descent.rs b/crates/navigator-ui/src/ui/descent.rs index 9551d7b2..766b8aec 100644 --- a/crates/navigator-ui/src/ui/descent.rs +++ b/crates/navigator-ui/src/ui/descent.rs @@ -196,7 +196,11 @@ impl NavigatorApp { .color(egui::Color32::WHITE) .strong(), ); - ui.label(egui::RichText::new(format!("{d}/{t} {}", self.tr("descent.derivedShort"))).weak().small()); + ui.label( + egui::RichText::new(format!("{d}/{t} {}", self.tr("descent.derivedShort"))) + .weak() + .small(), + ); }); ui.horizontal_wrapped(|ui| { for s in &node.snps { @@ -224,7 +228,11 @@ impl NavigatorApp { /// (derived, total) defining-SNP counts for one node. fn node_counts(node: &navigator_app::NodeEvidence) -> (usize, usize) { - let derived = node.snps.iter().filter(|s| matches!(s.state, CallState::Derived)).count(); + let derived = node + .snps + .iter() + .filter(|s| matches!(s.state, CallState::Derived)) + .count(); (derived, node.snps.len()) } diff --git a/crates/navigator-ui/src/ui/detail.rs b/crates/navigator-ui/src/ui/detail.rs index 64247a1f..9c43e485 100644 --- a/crates/navigator-ui/src/ui/detail.rs +++ b/crates/navigator-ui/src/ui/detail.rs @@ -395,12 +395,12 @@ impl NavigatorApp { /// (ancient) breakdown is a frequency model and has no position in PC space. fn sample_pca(&self) -> Option<(f64, f64)> { [self.donor_ancestry.as_ref().map(|(_, r)| r)] - .into_iter() - .flatten() - .find_map(|r| { - let c = r.pca_coordinates.as_ref()?; - (c.len() >= 2).then(|| (c[0], c[1])) - }) + .into_iter() + .flatten() + .find_map(|r| { + let c = r.pca_coordinates.as_ref()?; + (c.len() >= 2).then(|| (c[0], c[1])) + }) } /// PCA scatter: the donor's PC1×PC2 against the reference population centroids. The donor's @@ -625,7 +625,14 @@ impl NavigatorApp { }; let mut filter = self.auto_profile_filter; let mut query = std::mem::take(&mut self.auto_profile_query); - draw_diploid_profile(ui, profile, &mut filter, &mut query, self.data_epoch, &mut self.auto_profile_rows); + draw_diploid_profile( + ui, + profile, + &mut filter, + &mut query, + self.data_epoch, + &mut self.auto_profile_rows, + ); self.auto_profile_filter = filter; self.auto_profile_query = query; } @@ -887,7 +894,11 @@ impl NavigatorApp { .weak() .small(), ); - self.publish_row(ui, "Publish subject to PDS", Command::PublishBiosample { biosample_guid: guid }); + self.publish_row( + ui, + "Publish subject to PDS", + Command::PublishBiosample { biosample_guid: guid }, + ); }); self.genealogy_card(ui, guid); } @@ -1012,7 +1023,11 @@ impl NavigatorApp { if ui.small_button("✎").on_hover_text(self.tr("geneal.editMdka")).clicked() { want_edit_mdka = Some(edit_from(mk)); } - if ui.small_button("✕").on_hover_text(self.tr("geneal.removeMdka")).clicked() { + if ui + .small_button("✕") + .on_hover_text(self.tr("geneal.removeMdka")) + .clicked() + { want_del_mdka = Some(lineage.to_string()); } }); @@ -1136,9 +1151,10 @@ impl NavigatorApp { let q = self.private_y_query.to_ascii_lowercase(); let bucket = self.donor_private_y.as_ref().unwrap(); let names = &self.y_snp_names; // catalogued Y-SNP name at a novel call's site, if any - // Filter to matching variants (position, off-path name, "novel", or the catalogued name); the - // table is bounded to a fixed-height scroll pane (a WGS bucket runs to thousands of rows). A - // hard cap keeps a pathological bucket from flooding even the pane. + + // Filter to matching variants (position, off-path name, "novel", or the catalogued name); the + // table is bounded to a fixed-height scroll pane (a WGS bucket runs to thousands of rows). A + // hard cap keeps a pathological bucket from flooding even the pane. const CAP: usize = 1000; let matched: Vec<_> = bucket .variants diff --git a/crates/navigator-ui/src/ui/events.rs b/crates/navigator-ui/src/ui/events.rs index 0e2585ca..c8e02cd1 100644 --- a/crates/navigator-ui/src/ui/events.rs +++ b/crates/navigator-ui/src/ui/events.rs @@ -354,8 +354,11 @@ impl NavigatorApp { // `.take()` so it applies once; a stale GUID (deleted subject) simply no-ops. if self.selected_sample.is_none() { if let Some(guid_str) = self.pending_restore_subject.take() { - if let Some(guid) = - self.all_biosamples.iter().find(|b| b.guid.0.to_string() == guid_str).map(|b| b.guid) + if let Some(guid) = self + .all_biosamples + .iter() + .find(|b| b.guid.0.to_string() == guid_str) + .map(|b| b.guid) { self.select_sample(guid); } @@ -544,10 +547,8 @@ impl NavigatorApp { if self.selected_sample == Some(biosample_guid) { self.roh_running = false; if let Some(r) = &result { - self.status = format!( - "ROH: {} segments, F_ROH {:.3}", - r.summary.n_segments, r.summary.f_roh - ); + self.status = + format!("ROH: {} segments, F_ROH {:.3}", r.summary.n_segments, r.summary.f_roh); } self.roh = result.map(|b| *b); } @@ -797,11 +798,13 @@ impl NavigatorApp { } self.y_profile = profile; self.y_snp_names_requested = false; // re-resolve names incl. the new positions - // A rebuild re-places the genome consensus (consensus_label); refresh the - // Overview's cached Y/mt consensus so it doesn't lag until the next reload. + + // A rebuild re-places the genome consensus (consensus_label); refresh the + // Overview's cached Y/mt consensus so it doesn't lag until the next reload. let _ = self.tx.send(Command::LoadConsensus(biosample_guid)); // The descent report is drawn from this profile — drop its cache so it rebuilds. - self.descent_reports.retain(|(g, d, _)| !(*g == biosample_guid && *d == DnaType::Y)); + self.descent_reports + .retain(|(g, d, _)| !(*g == biosample_guid && *d == DnaType::Y)); } } Event::YSnpNames { names } => { @@ -820,7 +823,8 @@ impl NavigatorApp { // A rebuild re-places the mt genome consensus; refresh the Overview's cache. let _ = self.tx.send(Command::LoadConsensus(biosample_guid)); // The descent report is drawn from this profile — drop its cache so it rebuilds. - self.descent_reports.retain(|(g, d, _)| !(*g == biosample_guid && *d == DnaType::Mt)); + self.descent_reports + .retain(|(g, d, _)| !(*g == biosample_guid && *d == DnaType::Mt)); } } Event::AutosomalProfile { @@ -1010,13 +1014,15 @@ impl NavigatorApp { // A candidate that became a request is no longer a candidate. let requested: std::collections::HashSet<&str> = entries.iter().filter_map(|e| e.partner_sample_ref.as_deref()).collect(); - self.ibd_suggestions.retain(|s| !requested.contains(s.suggested_sample_guid.as_str())); + self.ibd_suggestions + .retain(|s| !requested.contains(s.suggested_sample_guid.as_str())); self.matching = entries; } Event::CandidateDismissed { suggested_sample_guid } => { self.exchange_busy = false; self.status = self.tr("matching.dismissed").to_string(); - self.ibd_suggestions.retain(|s| s.suggested_sample_guid != suggested_sample_guid); + self.ibd_suggestions + .retain(|s| s.suggested_sample_guid != suggested_sample_guid); self.dismissed_candidates.insert(suggested_sample_guid); } Event::IbdExchangeDone { diff --git a/crates/navigator-ui/src/ui/mod.rs b/crates/navigator-ui/src/ui/mod.rs index 1f88c41c..ba3f3818 100644 --- a/crates/navigator-ui/src/ui/mod.rs +++ b/crates/navigator-ui/src/ui/mod.rs @@ -16,14 +16,13 @@ use crate::widgets::{ }; use eframe::egui; use navigator_app::{ - AncestryResult, AppSettings, AuditEntry, BatchImportSummary, BuildNeed, CallState, ChatTurn, - CompatibilityLevel, Consensus, Coverage, DenovoCall, DescentReport, DnaType, FtdnaGenealogy, FtdnaImportPlan, - FtdnaResolution, + AncestryResult, AppSettings, AuditEntry, BatchImportSummary, BuildNeed, CallState, ChatTurn, CompatibilityLevel, + Consensus, Coverage, DenovoCall, DescentReport, DnaType, FtdnaGenealogy, FtdnaImportPlan, FtdnaResolution, HaploAssignment, HeteroplasmySite, IbdComparison, IbdSuggestion, IdentityVerification, LineageBrief, LineageKind, - MatchKind, MatchStrength, MtRegion, MtVariant, NarratedBrief, PackStatus, PaintingResult, PrivateBucket, PrivateClass, - ProjectOverview, ProjectSampleReport, ProjectStrChart, ReadMetrics, RefBuildStatus, SexInferenceResult, - SignalKind, SnpEvidence, SourceType, StrConcordanceRow, SubjectAnalysisStatus, SubjectBrief, SvAnalysisResult, - UiMode, VerificationStatus, YMatch, YProfile, YSignal, YState, YVariantStatus, YstrClustering, + MatchKind, MatchStrength, MtRegion, MtVariant, NarratedBrief, PackStatus, PaintingResult, PrivateBucket, + PrivateClass, ProjectOverview, ProjectSampleReport, ProjectStrChart, ReadMetrics, RefBuildStatus, + SexInferenceResult, SignalKind, SnpEvidence, SourceType, StrConcordanceRow, SubjectAnalysisStatus, SubjectBrief, + SvAnalysisResult, UiMode, VerificationStatus, YMatch, YProfile, YSignal, YState, YVariantStatus, YstrClustering, }; use navigator_domain::chipprofile::{self, ChipProfile}; use navigator_domain::du_domain::ids::SampleGuid; @@ -1190,6 +1189,7 @@ impl NavigatorApp { let _ = tx.send(Command::BackfillLabs); // resolve labs for runs imported before D8 landed let _ = tx.send(Command::VerifySourceFiles); // flag any imported file that moved/disappeared let _ = tx.send(Command::LoadAssetStatus); // ancestry/IBD "data sources" line + // Check for a newer installer at startup (unless the user opted out). Non-fatal — a failed // check just logs to the status line; the app never auto-updates. // One read of settings.json for the whole constructor — it was loaded six separate times. @@ -1197,7 +1197,7 @@ impl NavigatorApp { if settings.check_for_updates != Some(false) { let _ = tx.send(Command::CheckForUpdate); } - // Persisted theme wins; default dark. (Must match `dark_mode` below.) + // Persisted theme wins; default dark. (Must match `dark_mode` below.) let dark = !matches!(settings.theme.as_deref(), Some("light")); apply_theme(&cc.egui_ctx, dark); // Persisted UI scale (egui zoom) — fixes tiny text on a native-4K display the OS reports at @@ -1208,7 +1208,11 @@ impl NavigatorApp { // (nav is then reconciled to the interface mode by `normalize_for_mode`). Seed `saved_ui_sig` // with the restored intent so a matching restore doesn't trigger a redundant re-save. let restore = &settings; - let restored_nav = restore.last_nav.as_deref().and_then(Nav::from_key).unwrap_or(Nav::Subjects); + let restored_nav = restore + .last_nav + .as_deref() + .and_then(Nav::from_key) + .unwrap_or(Nav::Subjects); let restored_tab = restore .last_detail_tab .as_deref() @@ -2212,7 +2216,10 @@ mod window_geometry_tests { let mon = [1440.0, 900.0]; let got = fit_window_to_monitor([3000.0, 2000.0], mon, MIN_WINDOW); assert!(got[0] <= mon[0] && got[1] <= mon[1], "must fit: {got:?} in {mon:?}"); - assert!(got[0] <= mon[0] * 0.98 + 0.5 && got[1] <= mon[1] * 0.94 + 0.5, "margin respected"); + assert!( + got[0] <= mon[0] * 0.98 + 0.5 && got[1] <= mon[1] * 0.94 + 0.5, + "margin respected" + ); } #[test] @@ -2277,7 +2284,7 @@ mod icon_glyph_tests { use super::SimplePanel; use ab_glyph::{Font, FontRef}; - /// True when at least one font in `Proportional`'s fallback chain has a glyph for `c`. + /// True when at least one font in `Proportional`'s fallback chain has a glyph for `c`. /// /// Reads egui's own `FontDefinitions::default()` rather than a vendored copy of the `.ttf`s, so /// the test keeps testing the fonts the app actually ships as egui is upgraded. `glyph_id` @@ -2296,7 +2303,11 @@ mod icon_glyph_tests { // Guards the test itself: if these ever start reporting renderable, the check has broken // rather than the fonts having improved. for c in ['◆', '⚭', '✓', '🧬'] { - assert!(!renderable(c), "{c} (U+{:04X}) should be missing from Proportional", c as u32); + assert!( + !renderable(c), + "{c} (U+{:04X}) should be missing from Proportional", + c as u32 + ); } assert!(renderable('♂'), "sanity: ♂ is present"); } diff --git a/crates/navigator-ui/src/ui/modals.rs b/crates/navigator-ui/src/ui/modals.rs index aa3facaf..a907e9bf 100644 --- a/crates/navigator-ui/src/ui/modals.rs +++ b/crates/navigator-ui/src/ui/modals.rs @@ -238,11 +238,19 @@ impl NavigatorApp { ui.horizontal(|ui| { ui.vertical(|ui| { ui.label(self.tr("mdka.birth")); - ui.add(egui::TextEdit::singleline(&mut edit.birth_year).hint_text("e.g. 1830").desired_width(150.0)); + ui.add( + egui::TextEdit::singleline(&mut edit.birth_year) + .hint_text("e.g. 1830") + .desired_width(150.0), + ); }); ui.vertical(|ui| { ui.label(self.tr("mdka.death")); - ui.add(egui::TextEdit::singleline(&mut edit.death_year).hint_text("e.g. 1908").desired_width(150.0)); + ui.add( + egui::TextEdit::singleline(&mut edit.death_year) + .hint_text("e.g. 1908") + .desired_width(150.0), + ); }); }); ui.add_space(4.0); @@ -251,11 +259,19 @@ impl NavigatorApp { ui.horizontal(|ui| { ui.vertical(|ui| { ui.label(self.tr("mdka.lat")); - ui.add(egui::TextEdit::singleline(&mut edit.latitude).hint_text("e.g. 52.75").desired_width(150.0)); + ui.add( + egui::TextEdit::singleline(&mut edit.latitude) + .hint_text("e.g. 52.75") + .desired_width(150.0), + ); }); ui.vertical(|ui| { ui.label(self.tr("mdka.lon")); - ui.add(egui::TextEdit::singleline(&mut edit.longitude).hint_text("e.g. -9.43").desired_width(150.0)); + ui.add( + egui::TextEdit::singleline(&mut edit.longitude) + .hint_text("e.g. -9.43") + .desired_width(150.0), + ); }); }); ui.add_space(4.0); @@ -332,11 +348,7 @@ impl NavigatorApp { // modals here. let (mut close, mut copy) = (false, false); modal_frame(ctx, "diagnosis_modal", 640.0, |ui| { - ui.label( - egui::RichText::new(self.tr("diagnosis.title")) - .strong() - .size(16.0), - ); + ui.label(egui::RichText::new(self.tr("diagnosis.title")).strong().size(16.0)); ui.label(egui::RichText::new(self.tr("diagnosis.subtitle")).weak()); ui.separator(); egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { @@ -413,340 +425,364 @@ impl NavigatorApp { settings_tab = self.sub_bar(ui, settings_tab, &SettingsTab::ALL); egui::ScrollArea::vertical().max_height(460.0).show(ui, |ui| { match settings_tab { - SettingsTab::General => { - // --- Appearance --- - ui.horizontal(|ui| { - ui.label(self.tr("settings.theme")); - ui.selectable_value(&mut theme_dark, true, self.tr("settings.dark")); - ui.selectable_value(&mut theme_dark, false, self.tr("settings.light")); - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.uiScale")); - let scale_resp = ui.add( - egui::Slider::new(&mut form.ui_scale, 0.8..=2.5) - .step_by(0.05) - .fixed_decimals(2), - ); - scale_dragging = scale_resp.dragged(); - if ui.small_button("100%").clicked() { - form.ui_scale = 1.0; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.language")); - egui::ComboBox::from_id_salt("settings_lang") - .selected_text(lang.label()) - .show_ui(ui, |ui| { - for &l in crate::i18n::Lang::all() { - ui.selectable_value(&mut lang, l, l.label()); + SettingsTab::General => { + // --- Appearance --- + ui.horizontal(|ui| { + ui.label(self.tr("settings.theme")); + ui.selectable_value(&mut theme_dark, true, self.tr("settings.dark")); + ui.selectable_value(&mut theme_dark, false, self.tr("settings.light")); + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.uiScale")); + let scale_resp = ui.add( + egui::Slider::new(&mut form.ui_scale, 0.8..=2.5) + .step_by(0.05) + .fixed_decimals(2), + ); + scale_dragging = scale_resp.dragged(); + if ui.small_button("100%").clicked() { + form.ui_scale = 1.0; } }); - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.interfaceMode")); - ui.selectable_value(&mut ui_mode, UiMode::Simple, self.tr("settings.modeSimple")); - ui.selectable_value(&mut ui_mode, UiMode::Advanced, self.tr("settings.modeAdvanced")); - }); - } - - SettingsTab::Connection => { - ui.horizontal(|ui| { - ui.label(self.tr("settings.appviewUrl")); - ui.add( - egui::TextEdit::singleline(&mut form.appview_url) - .hint_text("https://decoding-us.org") - .desired_width(320.0), - ); - }); - ui.label( - egui::RichText::new(self.tr("settings.appviewUrlHint")) - .weak() - .small(), - ); - ui.horizontal(|ui| { - ui.label(self.tr("settings.yTreeProvider")); - let cur = if form.y_tree_provider.eq_ignore_ascii_case("ftdna") { - "FTDNA" - } else { - "Decoding-Us" - }; - egui::ComboBox::from_id_salt("settings_y_provider") - .selected_text(cur) - .show_ui(ui, |ui| { - ui.selectable_value(&mut form.y_tree_provider, "decodingus".to_string(), "Decoding-Us"); - ui.selectable_value(&mut form.y_tree_provider, "ftdna".to_string(), "FTDNA"); + ui.horizontal(|ui| { + ui.label(self.tr("settings.language")); + egui::ComboBox::from_id_salt("settings_lang") + .selected_text(lang.label()) + .show_ui(ui, |ui| { + for &l in crate::i18n::Lang::all() { + ui.selectable_value(&mut lang, l, l.label()); + } + }); + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.interfaceMode")); + ui.selectable_value(&mut ui_mode, UiMode::Simple, self.tr("settings.modeSimple")); + ui.selectable_value(&mut ui_mode, UiMode::Advanced, self.tr("settings.modeAdvanced")); }); - }); - ui.horizontal(|ui| { - if ui.button(self.tr("settings.refreshTrees")).clicked() { - refresh_trees = true; } - ui.label(egui::RichText::new(self.tr("settings.refreshTreesHint")).weak().small()); - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.treeTtl")); - ui.add(egui::TextEdit::singleline(&mut form.tree_ttl_days).desired_width(60.0)); - }); - } - SettingsTab::Ancestry => { - // --- Chromosome painter (copying-LAI) calibration --- - // Reset buttons restore the painter's calibrated defaults (see `lai_knob_defaults`). - let lai = navigator_app::lai_knob_defaults(); - ui.label(egui::RichText::new(self.tr("settings.painterHint")).small().weak()); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.recomb")); - ui.add(egui::Slider::new(&mut form.lai_recomb_per_cm, 0.02..=3.0).step_by(0.01).fixed_decimals(2)); - if ui.small_button(format!("{:.2}", lai.recomb_per_cm)).clicked() { - form.lai_recomb_per_cm = lai.recomb_per_cm; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.cap")); - ui.add(egui::Slider::new(&mut form.lai_max_ref_haps, 10..=400)); - if ui.small_button(lai.max_ref_haps.to_string()).clicked() { - form.lai_max_ref_haps = lai.max_ref_haps; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.gate")); - ui.add(egui::Slider::new(&mut form.lai_min_ancestry, 0.0..=0.20).step_by(0.005).fixed_decimals(3)); - if ui.small_button(format!("{:.2}", lai.min_ancestry)).clicked() { - form.lai_min_ancestry = lai.min_ancestry; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.switch")); - ui.add(egui::Slider::new(&mut form.lai_switch_per_cm, 0.01..=0.5).step_by(0.01).fixed_decimals(2)); - if ui.small_button(format!("{:.2}", lai.switch_per_cm)).clicked() { - form.lai_switch_per_cm = lai.switch_per_cm; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.minSeg")); - ui.add(egui::Slider::new(&mut form.lai_min_segment_cm, 0.5..=12.0).step_by(0.5).fixed_decimals(1)); - if ui.small_button(format!("{:.1}", lai.min_segment_cm)).clicked() { - form.lai_min_segment_cm = lai.min_segment_cm; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.sizeNorm")); - ui.add(egui::Slider::new(&mut form.lai_size_normalize, 0.0..=1.0).step_by(0.05).fixed_decimals(2)); - if ui.small_button(format!("{:.2}", lai.size_normalize)).clicked() { - form.lai_size_normalize = lai.size_normalize; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.mismatch")); - ui.add(egui::Slider::new(&mut form.lai_mismatch, 0.005..=0.10).step_by(0.005).fixed_decimals(3)); - if ui.small_button(format!("{:.3}", lai.mismatch)).clicked() { - form.lai_mismatch = lai.mismatch; + SettingsTab::Connection => { + ui.horizontal(|ui| { + ui.label(self.tr("settings.appviewUrl")); + ui.add( + egui::TextEdit::singleline(&mut form.appview_url) + .hint_text("https://decoding-us.org") + .desired_width(320.0), + ); + }); + ui.label(egui::RichText::new(self.tr("settings.appviewUrlHint")).weak().small()); + ui.horizontal(|ui| { + ui.label(self.tr("settings.yTreeProvider")); + let cur = if form.y_tree_provider.eq_ignore_ascii_case("ftdna") { + "FTDNA" + } else { + "Decoding-Us" + }; + egui::ComboBox::from_id_salt("settings_y_provider") + .selected_text(cur) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut form.y_tree_provider, + "decodingus".to_string(), + "Decoding-Us", + ); + ui.selectable_value(&mut form.y_tree_provider, "ftdna".to_string(), "FTDNA"); + }); + }); + ui.horizontal(|ui| { + if ui.button(self.tr("settings.refreshTrees")).clicked() { + refresh_trees = true; + } + ui.label(egui::RichText::new(self.tr("settings.refreshTreesHint")).weak().small()); + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.treeTtl")); + ui.add(egui::TextEdit::singleline(&mut form.tree_ttl_days).desired_width(60.0)); + }); } - }); - ui.label(egui::RichText::new(self.tr("settings.painterApply")).small().weak()); - } - SettingsTab::Ai => { - // --- AI assistant (local LLM) --- - ui.checkbox(&mut form.llm_enabled, self.tr("settings.ai.enable")); - ui.add_enabled_ui(form.llm_enabled, |ui| { - ui.horizontal(|ui| { - ui.label(self.tr("settings.ai.baseUrl")); - ui.add( - egui::TextEdit::singleline(&mut form.llm_base_url) - .hint_text(navigator_app::llm::DEFAULT_LLM_BASE_URL) - .desired_width(300.0), - ); - }); - // Quick-pick host ports. - ui.horizontal(|ui| { - ui.label(self.tr("settings.ai.presets")); - if ui.small_button("LM Studio").clicked() { - form.llm_base_url = "http://localhost:1234/v1".into(); - } - if ui.small_button("Ollama").clicked() { - form.llm_base_url = "http://localhost:11434/v1".into(); - } - if ui.small_button("llama.cpp").clicked() { - form.llm_base_url = "http://localhost:8080/v1".into(); - } - }); - ui.horizontal(|ui| { - if ui - .add_enabled(!self.llm_testing, egui::Button::new(self.tr("settings.ai.test"))) - .clicked() - { - test_llm = Some(form.llm_base_url.trim().to_string()); - } - if self.llm_testing { - ui.spinner(); - } - if let Some(msg) = &self.llm_test_msg { - ui.label(egui::RichText::new(msg).weak().small()); - } - }); - // Model picker — populated by a successful Test connection. - ui.horizontal(|ui| { - ui.label(self.tr("settings.ai.model")); - let current = if form.llm_model.is_empty() { - self.tr("settings.ai.modelAuto").to_string() - } else { - form.llm_model.clone() - }; - egui::ComboBox::from_id_salt("settings_llm_model") - .selected_text(current) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut form.llm_model, - String::new(), - self.tr("settings.ai.modelAuto"), - ); - for m in &self.llm_models { - ui.selectable_value(&mut form.llm_model, m.clone(), m); - } - }); - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.ai.maxTokens")); - ui.add(egui::TextEdit::singleline(&mut form.llm_max_tokens).desired_width(80.0)); - ui.label(egui::RichText::new(self.tr("settings.ai.maxTokensHint")).weak().small()); - }); - // Privacy line — turns to a warning for a non-loopback URL. - if navigator_app::llm::is_loopback_url(&form.llm_base_url) { - ui.label(egui::RichText::new(self.tr("settings.ai.local")).weak().small()); - } else { - ui.label( - egui::RichText::new(self.tr("settings.ai.remoteWarn")) - .small() - .color(egui::Color32::from_rgb(230, 170, 80)), - ); + SettingsTab::Ancestry => { + // --- Chromosome painter (copying-LAI) calibration --- + // Reset buttons restore the painter's calibrated defaults (see `lai_knob_defaults`). + let lai = navigator_app::lai_knob_defaults(); + ui.label(egui::RichText::new(self.tr("settings.painterHint")).small().weak()); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.recomb")); + ui.add( + egui::Slider::new(&mut form.lai_recomb_per_cm, 0.02..=3.0) + .step_by(0.01) + .fixed_decimals(2), + ); + if ui.small_button(format!("{:.2}", lai.recomb_per_cm)).clicked() { + form.lai_recomb_per_cm = lai.recomb_per_cm; + } + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.cap")); + ui.add(egui::Slider::new(&mut form.lai_max_ref_haps, 10..=400)); + if ui.small_button(lai.max_ref_haps.to_string()).clicked() { + form.lai_max_ref_haps = lai.max_ref_haps; + } + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.gate")); + ui.add( + egui::Slider::new(&mut form.lai_min_ancestry, 0.0..=0.20) + .step_by(0.005) + .fixed_decimals(3), + ); + if ui.small_button(format!("{:.2}", lai.min_ancestry)).clicked() { + form.lai_min_ancestry = lai.min_ancestry; + } + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.switch")); + ui.add( + egui::Slider::new(&mut form.lai_switch_per_cm, 0.01..=0.5) + .step_by(0.01) + .fixed_decimals(2), + ); + if ui.small_button(format!("{:.2}", lai.switch_per_cm)).clicked() { + form.lai_switch_per_cm = lai.switch_per_cm; + } + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.minSeg")); + ui.add( + egui::Slider::new(&mut form.lai_min_segment_cm, 0.5..=12.0) + .step_by(0.5) + .fixed_decimals(1), + ); + if ui.small_button(format!("{:.1}", lai.min_segment_cm)).clicked() { + form.lai_min_segment_cm = lai.min_segment_cm; + } + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.sizeNorm")); + ui.add( + egui::Slider::new(&mut form.lai_size_normalize, 0.0..=1.0) + .step_by(0.05) + .fixed_decimals(2), + ); + if ui.small_button(format!("{:.2}", lai.size_normalize)).clicked() { + form.lai_size_normalize = lai.size_normalize; + } + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.mismatch")); + ui.add( + egui::Slider::new(&mut form.lai_mismatch, 0.005..=0.10) + .step_by(0.005) + .fixed_decimals(3), + ); + if ui.small_button(format!("{:.3}", lai.mismatch)).clicked() { + form.lai_mismatch = lai.mismatch; + } + }); + ui.label(egui::RichText::new(self.tr("settings.painterApply")).small().weak()); } - }); - } - SettingsTab::References => { - // --- Reference genomes --- - ui.checkbox(&mut form.prompt_before_download, self.tr("settings.promptDownload")); - egui::Grid::new("settings_refs") - .striped(true) - .num_columns(5) - .show(ui, |ui| { - for h in [ - "settings.build", - "settings.status", - "settings.localFasta", - "settings.autoDownload", - "settings.integrity", - ] { - ui.strong(self.tr(h)); - } - ui.end_row(); - for row in &mut form.references { - ui.label(&row.build); - ui.label(egui::RichText::new(&row.status).weak()); + SettingsTab::Ai => { + // --- AI assistant (local LLM) --- + ui.checkbox(&mut form.llm_enabled, self.tr("settings.ai.enable")); + ui.add_enabled_ui(form.llm_enabled, |ui| { ui.horizontal(|ui| { + ui.label(self.tr("settings.ai.baseUrl")); ui.add( - egui::TextEdit::singleline(&mut row.local_path) - .hint_text("(none)") - .desired_width(180.0), + egui::TextEdit::singleline(&mut form.llm_base_url) + .hint_text(navigator_app::llm::DEFAULT_LLM_BASE_URL) + .desired_width(300.0), ); - if ui.button("📂").on_hover_text(self.tr("settings.browse")).clicked() { - if let Some(p) = rfd::FileDialog::new() - .add_filter("FASTA", &["fa", "fasta", "fna", "gz"]) - .pick_file() - { - row.local_path = p.display().to_string(); - } + }); + // Quick-pick host ports. + ui.horizontal(|ui| { + ui.label(self.tr("settings.ai.presets")); + if ui.small_button("LM Studio").clicked() { + form.llm_base_url = "http://localhost:1234/v1".into(); + } + if ui.small_button("Ollama").clicked() { + form.llm_base_url = "http://localhost:11434/v1".into(); + } + if ui.small_button("llama.cpp").clicked() { + form.llm_base_url = "http://localhost:8080/v1".into(); } }); - ui.checkbox(&mut row.auto_download, ""); ui.horizontal(|ui| { - if ui.small_button(self.tr("settings.verify")).clicked() { - verify_build = Some(row.build.clone()); + if ui + .add_enabled(!self.llm_testing, egui::Button::new(self.tr("settings.ai.test"))) + .clicked() + { + test_llm = Some(form.llm_base_url.trim().to_string()); + } + if self.llm_testing { + ui.spinner(); } - if !row.verify.is_empty() { - ui.label(egui::RichText::new(&row.verify).small().weak()); + if let Some(msg) = &self.llm_test_msg { + ui.label(egui::RichText::new(msg).weak().small()); } }); - ui.end_row(); - } - }); - if form.references.is_empty() { - ui.label(egui::RichText::new(self.tr("settings.loadingRefs")).weak()); - } - } + // Model picker — populated by a successful Test connection. + ui.horizontal(|ui| { + ui.label(self.tr("settings.ai.model")); + let current = if form.llm_model.is_empty() { + self.tr("settings.ai.modelAuto").to_string() + } else { + form.llm_model.clone() + }; + egui::ComboBox::from_id_salt("settings_llm_model") + .selected_text(current) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut form.llm_model, + String::new(), + self.tr("settings.ai.modelAuto"), + ); + for m in &self.llm_models { + ui.selectable_value(&mut form.llm_model, m.clone(), m); + } + }); + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.ai.maxTokens")); + ui.add(egui::TextEdit::singleline(&mut form.llm_max_tokens).desired_width(80.0)); + ui.label(egui::RichText::new(self.tr("settings.ai.maxTokensHint")).weak().small()); + }); + // Privacy line — turns to a warning for a non-loopback URL. + if navigator_app::llm::is_loopback_url(&form.llm_base_url) { + ui.label(egui::RichText::new(self.tr("settings.ai.local")).weak().small()); + } else { + ui.label( + egui::RichText::new(self.tr("settings.ai.remoteWarn")) + .small() + .color(egui::Color32::from_rgb(230, 170, 80)), + ); + } + }); + } - SettingsTab::Tools => { - // --- Tools: VCF liftover --- - ui.label(egui::RichText::new(self.tr("liftvcf.title")).strong()); - ui.label(egui::RichText::new(self.tr("liftvcf.hint")).weak().small()); - ui.horizontal(|ui| { - ui.label(self.tr("liftvcf.input")); - ui.add( - egui::TextEdit::singleline(&mut form.lift_in) - .hint_text("input.vcf[.gz]") - .desired_width(260.0), - ); - if ui.button("📂").clicked() { - if let Some(p) = rfd::FileDialog::new().add_filter("VCF", &["vcf", "gz"]).pick_file() { - form.lift_in = p.display().to_string(); + SettingsTab::References => { + // --- Reference genomes --- + ui.checkbox(&mut form.prompt_before_download, self.tr("settings.promptDownload")); + egui::Grid::new("settings_refs") + .striped(true) + .num_columns(5) + .show(ui, |ui| { + for h in [ + "settings.build", + "settings.status", + "settings.localFasta", + "settings.autoDownload", + "settings.integrity", + ] { + ui.strong(self.tr(h)); + } + ui.end_row(); + for row in &mut form.references { + ui.label(&row.build); + ui.label(egui::RichText::new(&row.status).weak()); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut row.local_path) + .hint_text("(none)") + .desired_width(180.0), + ); + if ui.button("📂").on_hover_text(self.tr("settings.browse")).clicked() { + if let Some(p) = rfd::FileDialog::new() + .add_filter("FASTA", &["fa", "fasta", "fna", "gz"]) + .pick_file() + { + row.local_path = p.display().to_string(); + } + } + }); + ui.checkbox(&mut row.auto_download, ""); + ui.horizontal(|ui| { + if ui.small_button(self.tr("settings.verify")).clicked() { + verify_build = Some(row.build.clone()); + } + if !row.verify.is_empty() { + ui.label(egui::RichText::new(&row.verify).small().weak()); + } + }); + ui.end_row(); + } + }); + if form.references.is_empty() { + ui.label(egui::RichText::new(self.tr("settings.loadingRefs")).weak()); } } - }); - ui.horizontal(|ui| { - ui.label(self.tr("liftvcf.target")); - egui::ComboBox::from_id_salt("liftvcf_target") - .selected_text(&form.lift_target) - .show_ui(ui, |ui| { - for b in ["chm13v2.0", "GRCh38", "GRCh37"] { - ui.selectable_value(&mut form.lift_target, b.to_string(), b); + + SettingsTab::Tools => { + // --- Tools: VCF liftover --- + ui.label(egui::RichText::new(self.tr("liftvcf.title")).strong()); + ui.label(egui::RichText::new(self.tr("liftvcf.hint")).weak().small()); + ui.horizontal(|ui| { + ui.label(self.tr("liftvcf.input")); + ui.add( + egui::TextEdit::singleline(&mut form.lift_in) + .hint_text("input.vcf[.gz]") + .desired_width(260.0), + ); + if ui.button("📂").clicked() { + if let Some(p) = rfd::FileDialog::new().add_filter("VCF", &["vcf", "gz"]).pick_file() { + form.lift_in = p.display().to_string(); + } } }); - ui.checkbox(&mut form.lift_filter_par, self.tr("liftvcf.filterPar")); - }); - ui.horizontal(|ui| { - ui.label(self.tr("liftvcf.output")); - ui.add( - egui::TextEdit::singleline(&mut form.lift_out) - .hint_text("lifted.vcf[.gz]") - .desired_width(260.0), - ); - if ui.button("📂").clicked() { - if let Some(p) = rfd::FileDialog::new() - .add_filter("VCF", &["vcf", "gz"]) - .set_file_name("lifted.vcf") - .save_file() + ui.horizontal(|ui| { + ui.label(self.tr("liftvcf.target")); + egui::ComboBox::from_id_salt("liftvcf_target") + .selected_text(&form.lift_target) + .show_ui(ui, |ui| { + for b in ["chm13v2.0", "GRCh38", "GRCh37"] { + ui.selectable_value(&mut form.lift_target, b.to_string(), b); + } + }); + ui.checkbox(&mut form.lift_filter_par, self.tr("liftvcf.filterPar")); + }); + ui.horizontal(|ui| { + ui.label(self.tr("liftvcf.output")); + ui.add( + egui::TextEdit::singleline(&mut form.lift_out) + .hint_text("lifted.vcf[.gz]") + .desired_width(260.0), + ); + if ui.button("📂").clicked() { + if let Some(p) = rfd::FileDialog::new() + .add_filter("VCF", &["vcf", "gz"]) + .set_file_name("lifted.vcf") + .save_file() + { + form.lift_out = p.display().to_string(); + } + } + }); + let lift_ready = !form.lift_in.trim().is_empty() && !form.lift_out.trim().is_empty(); + if ui + .add_enabled(lift_ready, egui::Button::new(self.tr("liftvcf.run"))) + .clicked() { - form.lift_out = p.display().to_string(); + lift_request = true; } } - }); - let lift_ready = !form.lift_in.trim().is_empty() && !form.lift_out.trim().is_empty(); - if ui - .add_enabled(lift_ready, egui::Button::new(self.tr("liftvcf.run"))) - .clicked() - { - lift_request = true; - } - } - SettingsTab::Advanced => { - ui.checkbox(&mut form.prefer_external_calls, self.tr("settings.preferExternalCalls")); - ui.label( - egui::RichText::new(self.tr("settings.preferExternalCallsHint")) - .weak() - .small(), - ); - ui.label( - egui::RichText::new(format!( - "{}: {}", - self.tr("settings.cacheDir"), - AppSettings::cache_base_dir().display() - )) - .weak(), - ); - ui.label(egui::RichText::new(self.tr("settings.advancedEnv")).weak()); - } + SettingsTab::Advanced => { + ui.checkbox(&mut form.prefer_external_calls, self.tr("settings.preferExternalCalls")); + ui.label( + egui::RichText::new(self.tr("settings.preferExternalCallsHint")) + .weak() + .small(), + ); + ui.label( + egui::RichText::new(format!( + "{}: {}", + self.tr("settings.cacheDir"), + AppSettings::cache_base_dir().display() + )) + .weak(), + ); + ui.label(egui::RichText::new(self.tr("settings.advancedEnv")).weak()); + } } }); ui.separator(); @@ -1042,7 +1078,10 @@ impl NavigatorApp { ui.add_space(12.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui - .add(egui::Button::new(egui::RichText::new(self.tr("update.download")).color(egui::Color32::WHITE)).fill(ACCENT)) + .add( + egui::Button::new(egui::RichText::new(self.tr("update.download")).color(egui::Color32::WHITE)) + .fill(ACCENT), + ) .clicked() { let url = info.download_url.clone().unwrap_or_else(|| info.release_url.clone()); @@ -1816,23 +1855,36 @@ impl NavigatorApp { /// the encrypted channel. Neither is undoable. The three headings below are the whole point of /// the dialog — what we send, what they learn, and what never leaves the device. pub(crate) fn consent_modal(&mut self, ctx: &egui::Context) { - let Some(entry) = self.consent_prompt.clone() else { return }; + let Some(entry) = self.consent_prompt.clone() else { + return; + }; let mut decision: Option = None; let mut close = false; modal_frame(ctx, "matching_consent_modal", 480.0, |ui| { - ui.label(egui::RichText::new(self.tr("matching.consent.title")).strong().size(16.0)); + ui.label( + egui::RichText::new(self.tr("matching.consent.title")) + .strong() + .size(16.0), + ); ui.separator(); ui.add_space(8.0); ui.label(self.tr("matching.consent.body")); ui.add_space(8.0); - egui::Grid::new("consent_facts").num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| { - ui.strong(self.tr("matching.col.purpose")); - ui.label(if entry.purpose.is_empty() { "—" } else { &entry.purpose }); - ui.end_row(); - ui.strong(self.tr("matching.consent.request")); - ui.label(egui::RichText::new(&entry.request_uri).small()); - ui.end_row(); - }); + egui::Grid::new("consent_facts") + .num_columns(2) + .spacing([12.0, 4.0]) + .show(ui, |ui| { + ui.strong(self.tr("matching.col.purpose")); + ui.label(if entry.purpose.is_empty() { + "—" + } else { + &entry.purpose + }); + ui.end_row(); + ui.strong(self.tr("matching.consent.request")); + ui.label(egui::RichText::new(&entry.request_uri).small()); + ui.end_row(); + }); ui.add_space(10.0); for (title, body) in [ ("matching.consent.sendTitle", "matching.consent.sendBody"), diff --git a/crates/navigator-ui/src/ui/rowcache.rs b/crates/navigator-ui/src/ui/rowcache.rs index 4674d817..4aca3e8d 100644 --- a/crates/navigator-ui/src/ui/rowcache.rs +++ b/crates/navigator-ui/src/ui/rowcache.rs @@ -318,7 +318,10 @@ mod tests { let before = seen.get(); cache.get(2, Some(YVariantStatus::Conflict), "rs1", &data, |v| v % 2 == 0); - assert!(seen.get() == before, "the status filter is part of the key, not the predicate here"); + assert!( + seen.get() == before, + "the status filter is part of the key, not the predicate here" + ); } /// Indices are only ever handed back for a collection of the length they were derived from — diff --git a/crates/navigator-ui/src/worker.rs b/crates/navigator-ui/src/worker.rs index 6996900e..1fa4b8aa 100644 --- a/crates/navigator-ui/src/worker.rs +++ b/crates/navigator-ui/src/worker.rs @@ -14,18 +14,14 @@ use std::sync::{Arc, Mutex}; use navigator_app::CancelToken; use navigator_app::{ - AnalysisStep, - AlignmentProbe, AncestryResult, App, AppError, AuditEntry, BatchImportSummary, BuildNeed, - ChatTurn, Consensus, Coverage, DenovoCall, DescentReport, DmConversationSummary, DmMessage, DnaType, - ExchangeSessionInfo, - FtdnaGenealogy, FtdnaImportOptions, FtdnaImportPlan, FtdnaImportSummary, FtdnaResolution, HaploAssignment, - HeteroplasmySite, IbdComparison, IbdDetectorConfig, IbdSuggestion, IdentityVerification, IncomingRequest, - MatchingEntry, - NarratedBrief, PaintingResult, PrivateBucket, ProjectImportSummary, ProjectOverview, ProjectSampleReport, - ArchaicMarkerResult, ArchaicSegmentResult, ProjectStrChart, ReadMetrics, RecruitmentInvitation, RefBuildStatus, RohResult, SexInferenceResult, - SignalKind, - SourceType, StoredIbdExchange, StrConcordanceRow, SubjectAnalysisStatus, SubjectBrief, SvAnalysisResult, YMatch, - YstrClustering, + AlignmentProbe, AnalysisStep, AncestryResult, App, AppError, ArchaicMarkerResult, ArchaicSegmentResult, AuditEntry, + BatchImportSummary, BuildNeed, ChatTurn, Consensus, Coverage, DenovoCall, DescentReport, DmConversationSummary, + DmMessage, DnaType, ExchangeSessionInfo, FtdnaGenealogy, FtdnaImportOptions, FtdnaImportPlan, FtdnaImportSummary, + FtdnaResolution, HaploAssignment, HeteroplasmySite, IbdComparison, IbdDetectorConfig, IbdSuggestion, + IdentityVerification, IncomingRequest, MatchingEntry, NarratedBrief, PaintingResult, PrivateBucket, + ProjectImportSummary, ProjectOverview, ProjectSampleReport, ProjectStrChart, ReadMetrics, RecruitmentInvitation, + RefBuildStatus, RohResult, SexInferenceResult, SignalKind, SourceType, StoredIbdExchange, StrConcordanceRow, + SubjectAnalysisStatus, SubjectBrief, SvAnalysisResult, YMatch, YstrClustering, }; use navigator_domain::chipprofile::ChipProfile; use navigator_domain::du_domain::ids::SampleGuid; @@ -76,7 +72,10 @@ pub enum Command { /// Build (off the UI thread) the plain-language Subject Brief for a subject (Simple mode). LoadSubjectBrief(SampleGuid), /// Build (off the UI thread) a YFull-style Y/mtDNA descent report for a subject. - LoadDescentReport { guid: SampleGuid, dna: DnaType }, + LoadDescentReport { + guid: SampleGuid, + dna: DnaType, + }, /// Build (off the UI thread) a per-marker branch report over `node`'s subtree for a subject. LoadBranchReport { guid: SampleGuid, @@ -93,7 +92,10 @@ pub enum Command { question: String, }, /// Explain a single result signal in plain language (per-tab "Explain this", M5). - NarrateSignal { guid: SampleGuid, kind: SignalKind }, + NarrateSignal { + guid: SampleGuid, + kind: SignalKind, + }, /// Deep-analyze every sample in a project as a cancellable background job, streaming /// per-sample `DeepAnalyzeProgress` and yielding between samples so the UI stays responsive. /// Skips what the fast path already filled; cancelled via [`Command::CancelAnalysis`]. @@ -1230,7 +1232,10 @@ pub enum Event { /// Separate from [`Event::Error`] so the UI can offer the report without having to guess, from /// a string, whether an error has one. Only emitted when the preflight actually failed a /// check — a tree-download or network error must not raise a file report. - Diagnosed { message: String, report: String }, + Diagnosed { + message: String, + report: String, + }, /// A run stopped because the user cancelled it. /// /// Distinct from both `Error` (this is not a failure) and `Noop` (which would leave the @@ -1325,7 +1330,10 @@ async fn settle_alignment_command(app: &App, alignment_id: i64, event: Event) -> /// [`Event::Genealogy`] — the refresh emitted after any genealogy mutation so the detail card /// reflects the new state without a separate "changed" round-trip. async fn reload_genealogy(app: &App, guid: SampleGuid) -> Event { - ev(app.subject_genealogy(guid).await, |data| Event::Genealogy { guid, data }) + ev(app.subject_genealogy(guid).await, |data| Event::Genealogy { + guid, + data, + }) } /// Map a fallible app call to an [`Event`]: `ok` names the success event, and **any** error becomes @@ -1358,9 +1366,7 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Command::RefreshTrees => ev(app.refresh_trees().await, Event::TreesRefreshed), Command::CreateProject(new) => ev(app.create_project(new).await, Event::ProjectCreated), // ImportProjectDir streams ImportProgress from the spawn loop; reaching here is a bug. - Command::ImportProjectDir { .. } => { - Event::Error("internal: unrouted ImportProjectDir".into()) - } + Command::ImportProjectDir { .. } => Event::Error("internal: unrouted ImportProjectDir".into()), Command::PlanFtdnaImport { project_id, project_name, @@ -1384,30 +1390,30 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Command::CommitFtdnaImport { plan, resolutions } => { ev(app.commit_ftdna_import(&plan, &resolutions).await, Event::FtdnaImported) } - Command::LoadGenealogy(guid) => ev(app.subject_genealogy(guid).await, |data| Event::Genealogy { guid, data }), - Command::ClusterProject(project_id) => ev( - app.cluster_project_ystr(project_id).await, - |clustering| Event::ProjectClustering { project_id, clustering }, - ), + Command::LoadGenealogy(guid) => ev(app.subject_genealogy(guid).await, |data| Event::Genealogy { + guid, + data, + }), + Command::ClusterProject(project_id) => ev(app.cluster_project_ystr(project_id).await, |clustering| { + Event::ProjectClustering { project_id, clustering } + }), // ResolveReference is handled in the spawn loop (it streams progress events); reaching // here would mean a routing bug. Command::ResolveReference { build } => Event::Error(format!("internal: unrouted ResolveReference {build}")), - Command::LoadSamples(project_id) => { - ev(app.list_biosamples(project_id).await, |samples| Event::Samples { project_id, samples }) - } - Command::LoadProjectReport(project_id) => { - ev(app.project_report(project_id).await, |rows| Event::ProjectReport { project_id, rows }) - } - Command::LoadProjectStrChart(project_id) => { - ev(app.project_str_chart(project_id).await, |chart| Event::ProjectStrChart { project_id, chart }) - } - Command::LoadSubjectBrief(guid) => ev( - app.subject_brief(guid).await, - |brief| Event::SubjectBrief { - guid, - brief: Box::new(brief), - }, - ), + Command::LoadSamples(project_id) => ev(app.list_biosamples(project_id).await, |samples| Event::Samples { + project_id, + samples, + }), + Command::LoadProjectReport(project_id) => ev(app.project_report(project_id).await, |rows| { + Event::ProjectReport { project_id, rows } + }), + Command::LoadProjectStrChart(project_id) => ev(app.project_str_chart(project_id).await, |chart| { + Event::ProjectStrChart { project_id, chart } + }), + Command::LoadSubjectBrief(guid) => ev(app.subject_brief(guid).await, |brief| Event::SubjectBrief { + guid, + brief: Box::new(brief), + }), Command::LoadDescentReport { guid, dna } => Event::DescentReportLoaded { guid, dna, @@ -1432,13 +1438,11 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Command::LoadSubjectStatus => ev(app.subject_analysis_status().await, Event::SubjectStatus), Command::LoadHaploSummary => ev(app.haplogroup_terminals().await, Event::HaploSummary), Command::LoadAllBiosamples => ev(app.list_all_biosamples().await, Event::AllBiosamples), - Command::AddBiosample(b) => { - ev( - app.add_biosample(b.project_id, b.donor_identifier, b.sample_accession, b.sex) - .await, - |_| Event::BiosamplesChanged, - ) - } + Command::AddBiosample(b) => ev( + app.add_biosample(b.project_id, b.donor_identifier, b.sample_accession, b.sex) + .await, + |_| Event::BiosamplesChanged, + ), Command::UpdateBiosample { guid, donor_identifier, @@ -1446,19 +1450,19 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { description, center_name, sex, - } => { - ev( - app.update_biosample(guid, donor_identifier, sample_accession, description, center_name, sex) - .await, - |_| Event::BiosamplesChanged, - ) - } - Command::AddExternalId { guid, source, external_id } => { - match app.add_external_id(guid, &source, &external_id).await { - Ok(_) => reload_genealogy(app, guid).await, - Err(e) => Event::Error(e.to_string()), - } - } + } => ev( + app.update_biosample(guid, donor_identifier, sample_accession, description, center_name, sex) + .await, + |_| Event::BiosamplesChanged, + ), + Command::AddExternalId { + guid, + source, + external_id, + } => match app.add_external_id(guid, &source, &external_id).await { + Ok(_) => reload_genealogy(app, guid).await, + Err(e) => Event::Error(e.to_string()), + }, Command::DeleteExternalId { guid, id } => match app.delete_external_id(id).await { Ok(()) => reload_genealogy(app, guid).await, Err(e) => Event::Error(e.to_string()), @@ -1472,15 +1476,15 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Err(e) => Event::Error(e.to_string()), }, Command::DeleteBiosample(guid) => ev(app.delete_biosample(guid).await, |_| Event::BiosamplesChanged), - Command::ClearBiosampleData(guid) => { - ev(app.clear_biosample_data(guid).await, |_| Event::BiosampleDataCleared(guid)) - } - Command::ClearHaplogroupData(guid) => { - ev(app.clear_haplogroup_data(guid).await, |_| Event::HaplogroupDataReset(guid)) - } - Command::DeleteSequenceRun { id, biosample_guid } => { - ev(app.delete_sequence_run(id).await, |_| Event::RunsChanged(biosample_guid)) - } + Command::ClearBiosampleData(guid) => ev(app.clear_biosample_data(guid).await, |_| { + Event::BiosampleDataCleared(guid) + }), + Command::ClearHaplogroupData(guid) => ev(app.clear_haplogroup_data(guid).await, |_| { + Event::HaplogroupDataReset(guid) + }), + Command::DeleteSequenceRun { id, biosample_guid } => ev(app.delete_sequence_run(id).await, |_| { + Event::RunsChanged(biosample_guid) + }), Command::MergeSequenceRuns { biosample_guid, primary, @@ -1489,12 +1493,12 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { app.merge_sequence_runs(biosample_guid, primary, secondary).await, |_| Event::RunsChanged(biosample_guid), ), - Command::DeleteAlignment { id, sequence_run_id } => { - ev(app.delete_alignment(id).await, |_| Event::AlignmentsChanged(sequence_run_id)) - } - Command::DeleteStrProfile { id, biosample_guid } => { - ev(app.delete_str_profile(id).await, |_| Event::StrProfilesChanged(biosample_guid)) - } + Command::DeleteAlignment { id, sequence_run_id } => ev(app.delete_alignment(id).await, |_| { + Event::AlignmentsChanged(sequence_run_id) + }), + Command::DeleteStrProfile { id, biosample_guid } => ev(app.delete_str_profile(id).await, |_| { + Event::StrProfilesChanged(biosample_guid) + }), Command::LoadReferenceSettings => Event::ReferenceSettings(app.reference_settings()), Command::TestLlmConnection { base_url } => { Event::LlmConnection(app.llm_models_at(&base_url).await.map_err(|e| e.to_string())) @@ -1502,18 +1506,15 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Command::SetReferenceOverrides(rows) => { ev(app.set_reference_overrides(&rows), |_| Event::ReferenceSettingsChanged) } - Command::VerifyReference { build } => ev( - app.verify_reference(&build).await, - |outcome| { - let status = match outcome { - navigator_app::VerifyOutcome::Verified => "✓ verified".to_string(), - navigator_app::VerifyOutcome::Mismatch { .. } => "✗ mismatch (corrupted?)".to_string(), - navigator_app::VerifyOutcome::NoSidecar => "• no checksum on record".to_string(), - navigator_app::VerifyOutcome::NotCached => "not cached".to_string(), - }; - Event::ReferenceVerified { build, status } - }, - ), + Command::VerifyReference { build } => ev(app.verify_reference(&build).await, |outcome| { + let status = match outcome { + navigator_app::VerifyOutcome::Verified => "✓ verified".to_string(), + navigator_app::VerifyOutcome::Mismatch { .. } => "✗ mismatch (corrupted?)".to_string(), + navigator_app::VerifyOutcome::NoSidecar => "• no checksum on record".to_string(), + navigator_app::VerifyOutcome::NotCached => "not cached".to_string(), + }; + Event::ReferenceVerified { build, status } + }), Command::LiftVcf { source, target, @@ -1546,24 +1547,28 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { } } } - Command::DeleteVariantSet { id, biosample_guid } => { - ev(app.delete_variant_set(id).await, |_| Event::VariantSetsChanged(biosample_guid)) - } - Command::DeleteChipProfile { id, biosample_guid } => { - ev(app.delete_chip_profile(id).await, |_| Event::ChipProfilesChanged(biosample_guid)) - } - Command::DeleteMtdnaSequence { id, biosample_guid } => { - ev(app.delete_mtdna_sequence(id).await, |_| Event::MtdnaChanged(biosample_guid)) - } + Command::DeleteVariantSet { id, biosample_guid } => ev(app.delete_variant_set(id).await, |_| { + Event::VariantSetsChanged(biosample_guid) + }), + Command::DeleteChipProfile { id, biosample_guid } => ev(app.delete_chip_profile(id).await, |_| { + Event::ChipProfilesChanged(biosample_guid) + }), + Command::DeleteMtdnaSequence { id, biosample_guid } => ev(app.delete_mtdna_sequence(id).await, |_| { + Event::MtdnaChanged(biosample_guid) + }), Command::AssignBiosampleProject { guid, project_id } => { - ev(app.add_biosample_to_project(guid, project_id).await, |_| Event::BiosamplesChanged) + ev(app.add_biosample_to_project(guid, project_id).await, |_| { + Event::BiosamplesChanged + }) } Command::UpdateProject { id, name, description, administrator, - } => ev(app.update_project(id, name, description, administrator).await, |_| Event::ProjectsChanged), + } => ev(app.update_project(id, name, description, administrator).await, |_| { + Event::ProjectsChanged + }), Command::DeleteProject(id) => ev(app.delete_project(id).await, |_| Event::ProjectsChanged), Command::UpdateSequenceRun { id, @@ -1573,20 +1578,18 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { test_type, library_layout, sequencing_facility, - } => { - ev( - app.update_sequence_run( - id, - platform_name, - instrument_model, - test_type, - library_layout, - sequencing_facility, - ) - .await, - |_| Event::RunsChanged(biosample_guid), + } => ev( + app.update_sequence_run( + id, + platform_name, + instrument_model, + test_type, + library_layout, + sequencing_facility, ) - } + .await, + |_| Event::RunsChanged(biosample_guid), + ), Command::UpdateAlignment { id, sequence_run_id, @@ -1597,10 +1600,13 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { app.update_alignment(id, reference_build, aligner, variant_caller).await, |_| Event::AlignmentsChanged(sequence_run_id), ), - Command::LoadRuns(biosample_guid) => { - ev(app.list_sequence_runs(biosample_guid).await, |runs| Event::Runs { biosample_guid, runs }) - } - Command::AddRun(new) => ev(app.record_sequence_run(new).await, |run| Event::RunsChanged(run.biosample_guid)), + Command::LoadRuns(biosample_guid) => ev(app.list_sequence_runs(biosample_guid).await, |runs| Event::Runs { + biosample_guid, + runs, + }), + Command::AddRun(new) => ev(app.record_sequence_run(new).await, |run| { + Event::RunsChanged(run.biosample_guid) + }), Command::LoadConsensus(guid) => { let y = app.haplogroup_consensus(guid, DnaType::Y).await.unwrap_or(None); let mt = app.haplogroup_consensus(guid, DnaType::Mt).await.unwrap_or(None); @@ -1621,40 +1627,31 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Command::YMatches { biosample_guid, project_id, - } => ev( - app.y_matches(biosample_guid, project_id).await, - |matches| Event::YMatches { + } => ev(app.y_matches(biosample_guid, project_id).await, |matches| { + Event::YMatches { biosample_guid, matches, - }, - ), - Command::LoadStrProfiles(guid) => ev( - app.list_str_profiles(guid).await, - |profiles| Event::StrProfiles { - biosample_guid: guid, - profiles, - }, - ), + } + }), + Command::LoadStrProfiles(guid) => ev(app.list_str_profiles(guid).await, |profiles| Event::StrProfiles { + biosample_guid: guid, + profiles, + }), Command::ImportStrProfile { biosample_guid, panel_name, provider, source, path, - } => { - ev( - app.import_str_profile_from_csv(biosample_guid, &panel_name, provider, source, &path) - .await, - |_| Event::StrProfilesChanged(biosample_guid), - ) - } - Command::LoadVariantSets(guid) => ev( - app.list_variant_sets(guid).await, - |sets| Event::VariantSets { - biosample_guid: guid, - sets, - }, + } => ev( + app.import_str_profile_from_csv(biosample_guid, &panel_name, provider, source, &path) + .await, + |_| Event::StrProfilesChanged(biosample_guid), ), + Command::LoadVariantSets(guid) => ev(app.list_variant_sets(guid).await, |sets| Event::VariantSets { + biosample_guid: guid, + sets, + }), Command::ImportVariants { biosample_guid, path, @@ -1668,98 +1665,89 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { source_label, source_type, text, - } => { - ev( - app.add_variants(biosample_guid, &source_label, source_type, &text) - .await, - |_| Event::VariantSetsChanged(biosample_guid), - ) - } - Command::LoadChipProfiles(guid) => ev( - app.list_chip_profiles(guid).await, - |profiles| Event::ChipProfiles { - biosample_guid: guid, - profiles, - }, + } => ev( + app.add_variants(biosample_guid, &source_label, source_type, &text) + .await, + |_| Event::VariantSetsChanged(biosample_guid), ), + Command::LoadChipProfiles(guid) => ev(app.list_chip_profiles(guid).await, |profiles| Event::ChipProfiles { + biosample_guid: guid, + profiles, + }), Command::ImportChipProfile { biosample_guid, provider, path, - } => { - ev( - app.import_chip_profile_from_csv(biosample_guid, provider, None, &path) - .await, - |_| Event::ChipProfilesChanged(biosample_guid), - ) - } - Command::LoadMtdna(guid) => ev( - app.list_mtdna_sequences(guid).await, - |sequences| Event::MtdnaSequences { + } => ev( + app.import_chip_profile_from_csv(biosample_guid, provider, None, &path) + .await, + |_| Event::ChipProfilesChanged(biosample_guid), + ), + Command::LoadMtdna(guid) => ev(app.list_mtdna_sequences(guid).await, |sequences| { + Event::MtdnaSequences { biosample_guid: guid, sequences, - }, - ), + } + }), Command::ImportMtdna { biosample_guid, path } => { - ev(app.import_mtdna_from_fasta(biosample_guid, &path).await, |_| Event::MtdnaChanged(biosample_guid)) + ev(app.import_mtdna_from_fasta(biosample_guid, &path).await, |_| { + Event::MtdnaChanged(biosample_guid) + }) } - Command::LoadMtdnaVariants { mtdna_id } => { - ev(app.mtdna_variants(mtdna_id).await, |variants| Event::MtdnaVariants { mtdna_id, variants }) + Command::LoadMtdnaVariants { mtdna_id } => ev(app.mtdna_variants(mtdna_id).await, |variants| { + Event::MtdnaVariants { mtdna_id, variants } + }), + Command::AssignMtdnaHaplogroup { mtdna_id } => ev(app.assign_mtdna_haplogroup(mtdna_id).await, |assignment| { + Event::Haplogroup { mtdna_id, assignment } + }), + Command::AssignYBisdna { biosample_guid } => { + ev(app.assign_y_bisdna(biosample_guid, None).await, |assignment| { + Event::YBisdnaHaplogroup { + biosample_guid, + assignment, + } + }) } - Command::AssignMtdnaHaplogroup { mtdna_id } => { - ev(app.assign_mtdna_haplogroup(mtdna_id).await, |assignment| Event::Haplogroup { mtdna_id, assignment }) + Command::YHaploReport { alignment_id } => { + ev(app.y_haplogroup_report(alignment_id).await, |(assignment, lineage)| { + Event::YHaploReport { + alignment_id, + assignment, + lineage, + } + }) } - Command::AssignYBisdna { biosample_guid } => ev( - app.assign_y_bisdna(biosample_guid, None).await, - |assignment| Event::YBisdnaHaplogroup { - biosample_guid, - assignment, - }, - ), - Command::YHaploReport { alignment_id } => ev( - app.y_haplogroup_report(alignment_id).await, - |(assignment, lineage)| Event::YHaploReport { + Command::AssignYHaplogroup { alignment_id } => ev(app.assign_y_haplogroup(alignment_id).await, |assignment| { + Event::YHaplogroup { alignment_id, assignment, - lineage, - }, - ), - Command::AssignYHaplogroup { alignment_id } => ev( - app.assign_y_haplogroup(alignment_id).await, - |assignment| Event::YHaplogroup { + } + }), + Command::AssignMtdnaHaplogroupFromAlignment { alignment_id } => ev( + app.assign_mtdna_haplogroup_from_alignment(alignment_id).await, + |assignment| Event::MtHaplogroup { alignment_id, assignment, }, ), - Command::AssignMtdnaHaplogroupFromAlignment { alignment_id } => { - ev( - app.assign_mtdna_haplogroup_from_alignment(alignment_id).await, - |assignment| Event::MtHaplogroup { - alignment_id, - assignment, - }, - ) - } Command::EstimateAncestryFromConsensus { biosample_guid } => { // Estimate from the pooled consensus, then surface it as the donor-level result. - ev( - app.estimate_ancestry_from_consensus(biosample_guid).await, - |result| Event::DonorAncestry { + ev(app.estimate_ancestry_from_consensus(biosample_guid).await, |result| { + Event::DonorAncestry { alignment_id: navigator_app::CONSENSUS_SOURCE_ID, result, - }, - ) + } + }) } Command::EstimateDeepAncestry { biosample_guid } => { // Heavy: genotypes the best CHM13 alignment at ~1.15M sites, then fits qpAdm f4. Persists // the ANCIENT_ADMIXTURE result (or nothing, when the model doesn't apply). - ev( - app.estimate_deep_ancestry(biosample_guid).await, - |result| Event::DeepAncestryEstimated { + ev(app.estimate_deep_ancestry(biosample_guid).await, |result| { + Event::DeepAncestryEstimated { biosample_guid, result: result.map(Box::new), - }, - ) + } + }) } Command::PaintAncestryFromConsensus { biosample_guid } => { // Painting from the consensus needs no genotyping pass — fast, no progress stream. @@ -1771,61 +1759,58 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { }, ) } - Command::LoadPainting { biosample_guid } => ev( - app.cached_painting(biosample_guid).await, - |result| Event::AncestryPainting { + Command::LoadPainting { biosample_guid } => ev(app.cached_painting(biosample_guid).await, |result| { + Event::AncestryPainting { alignment_id: navigator_app::CONSENSUS_SOURCE_ID, result: result.unwrap_or_default(), - }, - ), + } + }), Command::ComputeRohFromConsensus { biosample_guid } => { // ROH from the consensus needs no genotyping pass — fast, no progress stream. - ev( - app.compute_roh_from_consensus(biosample_guid).await, - |result| Event::RohResultReady { + ev(app.compute_roh_from_consensus(biosample_guid).await, |result| { + Event::RohResultReady { biosample_guid, result: Some(Box::new(result)), - }, - ) + } + }) } - Command::LoadRoh { biosample_guid } => ev( - app.cached_roh(biosample_guid).await, - |result| Event::RohResultReady { + Command::LoadRoh { biosample_guid } => { + ev(app.cached_roh(biosample_guid).await, |result| Event::RohResultReady { biosample_guid, result: result.map(Box::new), - }, - ), + }) + } Command::ComputeArchaicFromConsensus { biosample_guid } => { // A pure read over the cached consensus + the marker panel — no genotyping pass. - ev( - app.estimate_archaic_from_consensus(biosample_guid).await, - |result| Event::ArchaicResultReady { + ev(app.estimate_archaic_from_consensus(biosample_guid).await, |result| { + Event::ArchaicResultReady { biosample_guid, result: Some(Box::new(result)), - }, - ) + } + }) } - Command::LoadArchaic { biosample_guid } => ev( - app.cached_archaic(biosample_guid).await, - |result| Event::ArchaicResultReady { + Command::LoadArchaic { biosample_guid } => ev(app.cached_archaic(biosample_guid).await, |result| { + Event::ArchaicResultReady { biosample_guid, result: result.map(Box::new), - }, - ), - Command::CallArchaicSegments { biosample_guid } => ev( - app.call_archaic_segments_for_subject(biosample_guid).await, - |result| Event::ArchaicSegmentsReady { - biosample_guid, - result: Some(Box::new(result)), - }, - ), - Command::LoadArchaicSegments { biosample_guid } => ev( - app.cached_archaic_segments(biosample_guid).await, - |result| Event::ArchaicSegmentsReady { - biosample_guid, - result: result.map(Box::new), - }, - ), + } + }), + Command::CallArchaicSegments { biosample_guid } => { + ev(app.call_archaic_segments_for_subject(biosample_guid).await, |result| { + Event::ArchaicSegmentsReady { + biosample_guid, + result: Some(Box::new(result)), + } + }) + } + Command::LoadArchaicSegments { biosample_guid } => { + ev(app.cached_archaic_segments(biosample_guid).await, |result| { + Event::ArchaicSegmentsReady { + biosample_guid, + result: result.map(Box::new), + } + }) + } Command::LoadConsensusAncestryDetail { biosample_guid } => { let fine = app .consensus_ancestry(biosample_guid, "FINE_ADMIXTURE") @@ -1871,38 +1856,35 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Err(e) => Event::Error(e.to_string()), }, Command::AddDataBatch { biosample_guid, paths } => { - ev( - app.add_data_batch(biosample_guid, paths, |_, _| {}).await, - |summary| Event::DataBatchImported { + ev(app.add_data_batch(biosample_guid, paths, |_, _| {}).await, |summary| { + Event::DataBatchImported { biosample_guid, summary, - }, - ) + } + }) } Command::CreateSubjectAndImport { donor_identifier, sex, paths, } => match app.add_biosample(None, donor_identifier, None, sex).await { - Ok(bio) => ev( - app.add_data_batch(bio.guid, paths, |_, _| {}).await, - |summary| Event::SubjectCreatedAndImported { + Ok(bio) => ev(app.add_data_batch(bio.guid, paths, |_, _| {}).await, |summary| { + Event::SubjectCreatedAndImported { biosample_guid: bio.guid, summary, - }, - ), + } + }), Err(e) => Event::Error(e.to_string()), }, - Command::LoadAlignments(sequence_run_id) => ev( - app.list_alignments(sequence_run_id).await, - |alignments| Event::Alignments { + Command::LoadAlignments(sequence_run_id) => ev(app.list_alignments(sequence_run_id).await, |alignments| { + Event::Alignments { sequence_run_id, alignments, - }, - ), - Command::AddAlignment(new) => { - ev(app.record_alignment(new).await, |a| Event::AlignmentsChanged(a.sequence_run_id)) - } + } + }), + Command::AddAlignment(new) => ev(app.record_alignment(new).await, |a| { + Event::AlignmentsChanged(a.sequence_run_id) + }), Command::ProbeAlignment { path } => ev(app.probe_alignment(path).await, Event::AlignmentProbe), Command::DefaultAlignment { biosample_guid } => match app.default_alignment_for_subject(biosample_guid).await { Ok(Some((run_id, alignment_id))) => Event::DefaultAlignment { run_id, alignment_id }, @@ -1919,125 +1901,118 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Ok(None) => Event::Noop, Err(e) => Event::Error(e.to_string()), }, - Command::LoadYProfile { biosample_guid } => ev( - app.cached_y_profile(biosample_guid).await, - |profile| Event::YProfile { + Command::LoadYProfile { biosample_guid } => { + ev(app.cached_y_profile(biosample_guid).await, |profile| Event::YProfile { biosample_guid, profile, - }, - ), - Command::BuildYProfile { biosample_guid } => ev( - app.build_y_profile(biosample_guid).await, - |profile| Event::YProfile { + }) + } + Command::BuildYProfile { biosample_guid } => { + ev(app.build_y_profile(biosample_guid).await, |profile| Event::YProfile { biosample_guid, profile: Some(profile), - }, - ), + }) + } Command::LoadYSnpNames { biosample_guid, positions, - } => ev(app.y_snp_names_at(biosample_guid, &positions).await, |names| Event::YSnpNames { names }), - Command::LoadMtProfile { biosample_guid } => ev( - app.cached_mt_profile(biosample_guid).await, - |profile| Event::MtProfile { - biosample_guid, - profile, - }, - ), - Command::BuildMtProfile { biosample_guid } => ev( - app.build_mt_profile(biosample_guid).await, - |profile| Event::MtProfile { - biosample_guid, - profile: Some(profile), - }, - ), - Command::LoadAutosomalProfile { biosample_guid } => ev( - app.cached_autosomal_profile(biosample_guid).await, - |profile| Event::AutosomalProfile { + } => ev(app.y_snp_names_at(biosample_guid, &positions).await, |names| { + Event::YSnpNames { names } + }), + Command::LoadMtProfile { biosample_guid } => ev(app.cached_mt_profile(biosample_guid).await, |profile| { + Event::MtProfile { biosample_guid, profile, - }, - ), - Command::BuildAutosomalProfile { biosample_guid } => ev( - app.build_autosomal_profile(biosample_guid).await, - |profile| Event::AutosomalProfile { + } + }), + Command::BuildMtProfile { biosample_guid } => { + ev(app.build_mt_profile(biosample_guid).await, |profile| Event::MtProfile { biosample_guid, profile: Some(profile), - }, - ), - Command::LoadCoverage(alignment_id) => { - ev(app.cached_coverage(alignment_id).await, |result| Event::Coverage { alignment_id, result }) + }) } + Command::LoadAutosomalProfile { biosample_guid } => { + ev(app.cached_autosomal_profile(biosample_guid).await, |profile| { + Event::AutosomalProfile { + biosample_guid, + profile, + } + }) + } + Command::BuildAutosomalProfile { biosample_guid } => { + ev(app.build_autosomal_profile(biosample_guid).await, |profile| { + Event::AutosomalProfile { + biosample_guid, + profile: Some(profile), + } + }) + } + Command::LoadCoverage(alignment_id) => ev(app.cached_coverage(alignment_id).await, |result| Event::Coverage { + alignment_id, + result, + }), Command::LoadCoverageBulk(ids) => ev(app.cached_coverage_bulk(&ids).await, Event::CoverageBulk), - Command::LoadGenomeRegions { alignment_id, build } => ev( - app.genome_regions(&build).await, - |regions| Event::GenomeRegions { + Command::LoadGenomeRegions { alignment_id, build } => { + ev(app.genome_regions(&build).await, |regions| Event::GenomeRegions { alignment_id, regions: Some(regions), - }, - ), - Command::RunCoverage(alignment_id) => ev( - app.run_coverage_for_alignment(alignment_id).await, - |result| Event::Coverage { - alignment_id, - result: Some(result), - }, - ), - Command::LoadSex(alignment_id) => { - ev(app.cached_sex(alignment_id).await, |result| Event::Sex { alignment_id, result }) + }) } - Command::RunSex(alignment_id) => ev( - app.run_sex(alignment_id).await, - |result| Event::Sex { + Command::RunCoverage(alignment_id) => ev(app.run_coverage_for_alignment(alignment_id).await, |result| { + Event::Coverage { alignment_id, result: Some(result), - }, - ), - Command::LoadReadMetrics(alignment_id) => { - ev(app.cached_read_metrics(alignment_id).await, |result| Event::ReadMetrics { alignment_id, result }) - } - Command::RunReadMetrics(alignment_id) => ev( - app.run_read_metrics(alignment_id).await, - |result| Event::ReadMetrics { + } + }), + Command::LoadSex(alignment_id) => ev(app.cached_sex(alignment_id).await, |result| Event::Sex { + alignment_id, + result, + }), + Command::RunSex(alignment_id) => ev(app.run_sex(alignment_id).await, |result| Event::Sex { + alignment_id, + result: Some(result), + }), + Command::LoadReadMetrics(alignment_id) => ev(app.cached_read_metrics(alignment_id).await, |result| { + Event::ReadMetrics { alignment_id, result } + }), + Command::RunReadMetrics(alignment_id) => { + ev(app.run_read_metrics(alignment_id).await, |result| Event::ReadMetrics { alignment_id, result: Some(result), - }, - ), - Command::LoadSv(alignment_id) => { - ev(app.cached_sv(alignment_id).await, |result| Event::Sv { alignment_id, result }) + }) } - Command::RunSv(alignment_id) => ev( - app.run_sv(alignment_id, cancel.clone()).await, - |result| Event::Sv { + Command::LoadSv(alignment_id) => ev(app.cached_sv(alignment_id).await, |result| Event::Sv { + alignment_id, + result, + }), + Command::RunSv(alignment_id) => ev(app.run_sv(alignment_id, cancel.clone()).await, |result| Event::Sv { + alignment_id, + result: Some(result), + }), + Command::LoadDenovo { alignment_id, contig } => { + ev(app.cached_denovo(alignment_id, &contig).await, |result| Event::Denovo { alignment_id, - result: Some(result), - }, - ), - Command::LoadDenovo { alignment_id, contig } => ev( - app.cached_denovo(alignment_id, &contig).await, + contig, + result, + }) + } + Command::RunDenovo { alignment_id, contig } => ev( + app.run_denovo_for_alignment(alignment_id, contig.clone()).await, |result| Event::Denovo { alignment_id, contig, - result, + result: Some(result), }, ), - Command::RunDenovo { alignment_id, contig } => { - ev( - app.run_denovo_for_alignment(alignment_id, contig.clone()).await, - |result| Event::Denovo { - alignment_id, - contig, - result: Some(result), - }, - ) - } Command::LoadAllAlignments => ev(app.list_all_alignments().await, Event::AllAlignments), - Command::CompareIbdConsensus { a, b } => { - ev(app.compare_ibd_consensus(a, b, IbdDetectorConfig::default()).await, Event::Ibd) - } - Command::CompareIbdSources { a, b } => { - ev(app.compare_ibd_sources(a, b, IbdDetectorConfig::default()).await, Event::Ibd) - } + Command::CompareIbdConsensus { a, b } => ev( + app.compare_ibd_consensus(a, b, IbdDetectorConfig::default()).await, + Event::Ibd, + ), + Command::CompareIbdSources { a, b } => ev( + app.compare_ibd_sources(a, b, IbdDetectorConfig::default()).await, + Event::Ibd, + ), Command::VerifyIdentityConsensus { a, b } => ev(app.verify_identity_consensus(a, b).await, Event::Identity), Command::LoadIbdSuggestions => ev(app.ibd_suggestions().await, Event::IbdSuggestions), Command::RequestIntroduction { @@ -2047,10 +2022,11 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Ok(_) => ev(app.matching_entries().await, Event::Matching), Err(e) => Event::Error(e.to_string()), }, - Command::DismissCandidate { suggested_sample_guid } => ev( - app.ibd_dismiss(&suggested_sample_guid).await, - |_| Event::CandidateDismissed { suggested_sample_guid }, - ), + Command::DismissCandidate { suggested_sample_guid } => { + ev(app.ibd_dismiss(&suggested_sample_guid).await, |_| { + Event::CandidateDismissed { suggested_sample_guid } + }) + } Command::UseLocalIdentity => ev(app.use_local_identity(), |did| Event::Authenticated(Some(did))), Command::RefreshMatching => ev(app.refresh_matching().await, Event::Matching), Command::MatchingConsent { @@ -2092,10 +2068,9 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { } } Command::LoadIbdExchanges { biosample_guid } => { - ev( - app.list_ibd_exchanges_for_subject(biosample_guid).await, - |rows| Event::IbdExchanges { biosample_guid, rows }, - ) + ev(app.list_ibd_exchanges_for_subject(biosample_guid).await, |rows| { + Event::IbdExchanges { biosample_guid, rows } + }) } Command::DmInitiate { partner_did } => ev(app.dm_initiate(&partner_did).await, |_| Event::DmInitiated), Command::LoadDmInbox => match (app.dm_incoming().await, app.dm_ready().await) { @@ -2107,35 +2082,36 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { } Command::DmConnect { info } => ev(app.dm_connect(&info).await, |_| Event::DmConnected), Command::LoadDmConversations => ev(app.dm_conversations().await, Event::DmConversations), - Command::LoadDmMessages { session_id } => { - ev(app.dm_messages(&session_id).await, |rows| Event::DmMessages { session_id, rows }) - } + Command::LoadDmMessages { session_id } => ev(app.dm_messages(&session_id).await, |rows| Event::DmMessages { + session_id, + rows, + }), Command::DmSend { session_id, text } => { ev(app.dm_send(&session_id, &text).await, |_| Event::DmSent { session_id }) } - Command::DmSync { session_id } => { - ev(app.dm_sync(&session_id).await, |new_count| Event::DmSynced { session_id, new_count }) - } + Command::DmSync { session_id } => ev(app.dm_sync(&session_id).await, |new_count| Event::DmSynced { + session_id, + new_count, + }), Command::LoadRecruitmentInvitations => ev(app.recruitment_invitations().await, Event::RecruitmentInvitations), Command::RespondRecruitment { campaign_id, accept } => { - ev(app.recruitment_respond(campaign_id, accept).await, |_| Event::RecruitmentResponded) + ev(app.recruitment_respond(campaign_id, accept).await, |_| { + Event::RecruitmentResponded + }) } Command::BackfillLabs => ev(app.backfill_run_labs().await, Event::LabsResolved), Command::AuthStatus => Event::Authenticated(app.current_account()), Command::SyncStatus => Event::SyncOnline(app.is_online()), - Command::PullSync => ev( - app.pull_sync().await, - |o| Event::PullDone { - in_sync: o.in_sync, - applied: o.applied, - adopted: o.adopted, - repushed: o.repushed, - conflicts: o.conflicts, - }, - ), - Command::VerifySourceFiles => { - ev(app.verify_source_files().await, |missing| Event::SourceFilesVerified { missing }) - } + Command::PullSync => ev(app.pull_sync().await, |o| Event::PullDone { + in_sync: o.in_sync, + applied: o.applied, + adopted: o.adopted, + repushed: o.repushed, + conflicts: o.conflicts, + }), + Command::VerifySourceFiles => ev(app.verify_source_files().await, |missing| Event::SourceFilesVerified { + missing, + }), Command::Login { handle } => ev(app.login(&handle).await, |did| Event::Authenticated(Some(did))), Command::Logout => ev(app.logout().await, |_| Event::Authenticated(None)), // Publishes enqueue to the durable outbox then drain — handled in the spawn loop (they emit @@ -2170,74 +2146,65 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Err(e) => Event::Error(format!("write {}: {e}", path.display())), } } - Command::LoadPcaReference => ev( - app.ancestry_pca_reference().await, - |points| Event::PcaReference { - alignment_id: navigator_app::CONSENSUS_SOURCE_ID, - points, - }, - ), + Command::LoadPcaReference => ev(app.ancestry_pca_reference().await, |points| Event::PcaReference { + alignment_id: navigator_app::CONSENSUS_SOURCE_ID, + points, + }), Command::SetHaploOverride { biosample_guid, dna_type, haplogroup, reason, - } => { - ev( - app.set_manual_override(biosample_guid, dna_type, &haplogroup, reason.as_deref()) - .await, - |_| Event::ReconciliationChanged { - biosample_guid, - dna_type, - }, - ) - } - Command::ClearHaploOverride { - biosample_guid, - dna_type, } => ev( - app.clear_manual_override(biosample_guid, dna_type).await, + app.set_manual_override(biosample_guid, dna_type, &haplogroup, reason.as_deref()) + .await, |_| Event::ReconciliationChanged { biosample_guid, dna_type, }, ), + Command::ClearHaploOverride { + biosample_guid, + dna_type, + } => ev(app.clear_manual_override(biosample_guid, dna_type).await, |_| { + Event::ReconciliationChanged { + biosample_guid, + dna_type, + } + }), Command::LoadAudit { biosample_guid, dna_type, - } => ev( - app.reconciliation_audit(biosample_guid, dna_type).await, - |entries| Event::Audit { + } => ev(app.reconciliation_audit(biosample_guid, dna_type).await, |entries| { + Event::Audit { biosample_guid, dna_type, entries, - }, - ), - Command::LoadHeteroplasmy { alignment_id } => { - ev(app.mtdna_heteroplasmy(alignment_id).await, |sites| Event::Heteroplasmy { alignment_id, sites }) - } + } + }), + Command::LoadHeteroplasmy { alignment_id } => ev(app.mtdna_heteroplasmy(alignment_id).await, |sites| { + Event::Heteroplasmy { alignment_id, sites } + }), Command::PublishReconciliation { biosample_guid, .. } => { Event::Error(format!("internal: unrouted PublishReconciliation {biosample_guid:?}")) } // ---- social (Community tab) ---------------------------------------- Command::LoadSupportThreads => ev(app.support_threads().await, Event::SupportThreads), - Command::LoadSupportThread { conversation_id } => ev( - app.support_thread(&conversation_id).await, - |messages| Event::SupportThread { + Command::LoadSupportThread { conversation_id } => ev(app.support_thread(&conversation_id).await, |messages| { + Event::SupportThread { conversation_id, messages, - }, - ), - Command::OpenSupportThread { subject, body } => ev( - app.open_support_thread(&subject, &body).await, + } + }), + Command::OpenSupportThread { subject, body } => { + ev(app.open_support_thread(&subject, &body).await, |conversation_id| { + Event::SupportThreadPosted { conversation_id } + }) + } + Command::ReplySupportThread { conversation_id, body } => ev( + app.reply_support_thread(&conversation_id, &body).await, |conversation_id| Event::SupportThreadPosted { conversation_id }, ), - Command::ReplySupportThread { conversation_id, body } => { - ev( - app.reply_support_thread(&conversation_id, &body).await, - |conversation_id| Event::SupportThreadPosted { conversation_id }, - ) - } Command::LoadCommunityFeed => ev(app.community_feed().await, Event::CommunityFeed), Command::PostCommunity { content, @@ -2248,23 +2215,22 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { // `feed.post` record; a publish failure is surfaced but the post itself is not lost. Ok(_) => { if publish_pds { - ev(app.publish_feed_post(&content, topic.as_deref()).await, |_| Event::CommunityPosted) + ev(app.publish_feed_post(&content, topic.as_deref()).await, |_| { + Event::CommunityPosted + }) } else { Event::CommunityPosted } } Err(e) => Event::Error(e.to_string()), }, - Command::LoadNotifications => ev( - app.notifications().await, - |n| Event::Notifications { - items: n.items, - unread: n.unread, - }, - ), - Command::MarkNotificationRead { id } => { - ev(app.mark_notification_read(id.as_deref()).await, |_| Event::NotificationsMarked) - } + Command::LoadNotifications => ev(app.notifications().await, |n| Event::Notifications { + items: n.items, + unread: n.unread, + }), + Command::MarkNotificationRead { id } => ev(app.mark_notification_read(id.as_deref()).await, |_| { + Event::NotificationsMarked + }), } } @@ -2293,7 +2259,9 @@ async fn resolve_reference_streaming( wake(); } }; - let event = ev(app.resolve_reference(&build, &mut progress).await, |path| Event::ReferenceReady { build, path }); + let event = ev(app.resolve_reference(&build, &mut progress).await, |path| { + Event::ReferenceReady { build, path } + }); let _ = evt_tx.send(event); wake(); } @@ -2323,12 +2291,7 @@ async fn ensure_references_streaming( /// (else they error or degrade to a whole-file scan); building it eagerly — with a visible bar — /// keeps a freshly imported file from looking stuck on its first analysis. A file that already has /// an index returns instantly with `built: None` (no progress noise). -async fn ensure_index_streaming( - app: &App, - alignment_id: i64, - evt_tx: &Sender, - wake: &(dyn Fn() + Send + Sync), -) { +async fn ensure_index_streaming(app: &App, alignment_id: i64, evt_tx: &Sender, wake: &(dyn Fn() + Send + Sync)) { // Progress runs on a blocking thread, so the callback must be Send — capture owned clones, not // borrows. Throttling already happens in the analysis layer (per ~32 MB); forward each tick. let tx = evt_tx.clone(); @@ -2428,19 +2391,23 @@ async fn run_full_analysis_streaming( // be Fn + Sync; the event Sender is !Sync, so guard it with a Mutex. let evt = Arc::new(Mutex::new(evt_tx.clone())); let wk = wake.clone(); - app.run_unified_metrics_with_progress(alignment_id, move |done, tot| { - let within = if tot > 0 { done as f32 / tot as f32 } else { 0.0 }; - if let Ok(tx) = evt.lock() { - let _ = tx.send(Event::AnalysisProgress { - step: 1, - total, - label: "Quality metrics".into(), - detail: format!("scanning genome — {:.0}%", within * 100.0), - fraction: within / total as f32, - }); - } - wk(); - }, cancel.clone()) + app.run_unified_metrics_with_progress( + alignment_id, + move |done, tot| { + let within = if tot > 0 { done as f32 / tot as f32 } else { 0.0 }; + if let Ok(tx) = evt.lock() { + let _ = tx.send(Event::AnalysisProgress { + step: 1, + total, + label: "Quality metrics".into(), + detail: format!("scanning genome — {:.0}%", within * 100.0), + fraction: within / total as f32, + }); + } + wk(); + }, + cancel.clone(), + ) .await .map(|r| (r.coverage, r.read_metrics, r.sex)) } @@ -2831,7 +2798,12 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo // visible progress bar — a first CRAM/BAM that needs a multi-GB reference // download otherwise looks like it didn't register (§ ensure_references_streaming). Command::AddDataBatch { biosample_guid, paths } => { - let event = handle(&app, Command::AddDataBatch { biosample_guid, paths }, &CancelToken::none()).await; + let event = handle( + &app, + Command::AddDataBatch { biosample_guid, paths }, + &CancelToken::none(), + ) + .await; let imported = matches!(event, Event::DataBatchImported { .. }); let _ = evt_tx.send(event); wake(); @@ -2878,7 +2850,12 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo ensure_references_streaming(&app, &builds, &evt_tx, &*wake).await; } ensure_indexes_for_subject_streaming(&app, biosample_guid, &evt_tx, &*wake).await; - let event = handle(&app, Command::BuildAutosomalProfile { biosample_guid }, &CancelToken::none()).await; + let event = handle( + &app, + Command::BuildAutosomalProfile { biosample_guid }, + &CancelToken::none(), + ) + .await; let _ = evt_tx.send(event); wake(); } @@ -2887,73 +2864,125 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo ensure_references_streaming(&app, &builds, &evt_tx, &*wake).await; } ensure_indexes_for_subject_streaming(&app, biosample_guid, &evt_tx, &*wake).await; - let event = handle(&app, Command::StrConcordance { biosample_guid }, &CancelToken::none()).await; + let event = + handle(&app, Command::StrConcordance { biosample_guid }, &CancelToken::none()) + .await; let _ = evt_tx.send(event); wake(); } Command::RunSv(alignment_id) => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; let (gen, token) = cancels.begin(); - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::RunSv(alignment_id), &token).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle(&app, Command::RunSv(alignment_id), &token).await, + ) + .await; cancels.end(gen); let _ = evt_tx.send(event); wake(); } Command::LoadHeteroplasmy { alignment_id } => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::LoadHeteroplasmy { alignment_id }, &CancelToken::none()).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle(&app, Command::LoadHeteroplasmy { alignment_id }, &CancelToken::none()) + .await, + ) + .await; let _ = evt_tx.send(event); wake(); } Command::AssignYHaplogroup { alignment_id } => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::AssignYHaplogroup { alignment_id }, &CancelToken::none()).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle(&app, Command::AssignYHaplogroup { alignment_id }, &CancelToken::none()) + .await, + ) + .await; let _ = evt_tx.send(event); wake(); } Command::YHaploReport { alignment_id } => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::YHaploReport { alignment_id }, &CancelToken::none()).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle(&app, Command::YHaploReport { alignment_id }, &CancelToken::none()).await, + ) + .await; let _ = evt_tx.send(event); wake(); } Command::AssignMtdnaHaplogroupFromAlignment { alignment_id } => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::AssignMtdnaHaplogroupFromAlignment { alignment_id }, &CancelToken::none()).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle( + &app, + Command::AssignMtdnaHaplogroupFromAlignment { alignment_id }, + &CancelToken::none(), + ) + .await, + ) + .await; let _ = evt_tx.send(event); wake(); } Command::FindPrivateY { alignment_id, mask } => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::FindPrivateY { alignment_id, mask }, &CancelToken::none()).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle(&app, Command::FindPrivateY { alignment_id, mask }, &CancelToken::none()) + .await, + ) + .await; let _ = evt_tx.send(event); wake(); } Command::RunDenovo { alignment_id, contig } => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; let (gen, token) = cancels.begin(); - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::RunDenovo { alignment_id, contig }, &token).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle(&app, Command::RunDenovo { alignment_id, contig }, &token).await, + ) + .await; cancels.end(gen); let _ = evt_tx.send(event); wake(); @@ -2962,12 +2991,19 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo for src in [a, b] { if let navigator_app::IbdSource::Alignment(aln) = src { if let Ok(Some(build)) = app.reference_build_of_alignment(aln).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming( + &app, + std::slice::from_ref(&build), + &evt_tx, + &*wake, + ) + .await; } ensure_index_streaming(&app, aln, &evt_tx, &*wake).await; } } - let event = handle(&app, Command::CompareIbdSources { a, b }, &CancelToken::none()).await; + let event = + handle(&app, Command::CompareIbdSources { a, b }, &CancelToken::none()).await; let _ = evt_tx.send(event); wake(); } @@ -2976,7 +3012,8 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo ensure_references_streaming(&app, &builds, &evt_tx, &*wake).await; } ensure_indexes_for_subject_streaming(&app, biosample_guid, &evt_tx, &*wake).await; - let event = handle(&app, Command::BuildYProfile { biosample_guid }, &CancelToken::none()).await; + let event = + handle(&app, Command::BuildYProfile { biosample_guid }, &CancelToken::none()).await; let _ = evt_tx.send(event); wake(); } @@ -2985,7 +3022,9 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo ensure_references_streaming(&app, &builds, &evt_tx, &*wake).await; } ensure_indexes_for_subject_streaming(&app, biosample_guid, &evt_tx, &*wake).await; - let event = handle(&app, Command::BuildMtProfile { biosample_guid }, &CancelToken::none()).await; + let event = + handle(&app, Command::BuildMtProfile { biosample_guid }, &CancelToken::none()) + .await; let _ = evt_tx.send(event); wake(); } @@ -3007,8 +3046,15 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; // Simple one-click: include the autosomal ancestry step. let (gen, cancel) = cancels.begin(); - run_full_analysis_streaming(&app, alignment_id, true, cancel, &evt_tx, wake.clone()) - .await; + run_full_analysis_streaming( + &app, + alignment_id, + true, + cancel, + &evt_tx, + wake.clone(), + ) + .await; cancels.end(gen); } Ok(None) => { @@ -3177,8 +3223,21 @@ mod tests { } // Delete both → empty genealogy. - let _ = handle(&app, Command::DeleteMdka { guid, lineage: "Y".into() }, &CancelToken::none()).await; - let ev = handle(&app, Command::DeleteExternalId { guid, id: kit_id }, &CancelToken::none()).await; + let _ = handle( + &app, + Command::DeleteMdka { + guid, + lineage: "Y".into(), + }, + &CancelToken::none(), + ) + .await; + let ev = handle( + &app, + Command::DeleteExternalId { guid, id: kit_id }, + &CancelToken::none(), + ) + .await; match ev { Event::Genealogy { data, .. } => assert!(data.is_empty(), "all genealogy removed"), other => panic!("expected Genealogy, got {other:?}"), @@ -3211,7 +3270,9 @@ mod tests { name: "Trio".into(), description: None, administrator: "jk".into(), - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await; let pid = match created { Event::ProjectCreated(p) => p.id, @@ -3359,7 +3420,9 @@ mod tests { name: "P".into(), description: None, administrator: "jk".into(), - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::ProjectCreated(p) => p.id, @@ -3374,7 +3437,9 @@ mod tests { donor_identifier: "HG002".into(), sample_accession: None, sex: Some("male".into()), - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3398,7 +3463,9 @@ mod tests { donor_identifier: "NA12878".into(), sample_accession: None, sex: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3422,7 +3489,9 @@ mod tests { pf_reads_aligned: None, mean_read_length: None, mean_insert_size: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::RunsChanged(g) => assert_eq!(g, guid), @@ -3444,7 +3513,9 @@ mod tests { bam_path: None, reference_path: None, content_sha256: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::AlignmentsChanged(r) => assert_eq!(r, run_id), @@ -3466,7 +3537,9 @@ mod tests { donor_identifier: "draft".into(), sample_accession: None, sex: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3520,7 +3593,9 @@ mod tests { pf_reads_aligned: None, mean_read_length: None, mean_insert_size: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::RunsChanged(_) => {} @@ -3572,7 +3647,9 @@ mod tests { donor_identifier: "spare".into(), sample_accession: None, sex: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3601,7 +3678,9 @@ mod tests { name: "P".into(), description: None, administrator: "jk".into(), - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::ProjectCreated(p) => p.id, @@ -3614,7 +3693,9 @@ mod tests { donor_identifier: "loose".into(), sample_accession: None, sex: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3660,7 +3741,13 @@ mod tests { } // clearing the project (None) removes it from the project list - match handle(&app, Command::AssignBiosampleProject { guid, project_id: None }, &CancelToken::none()).await { + match handle( + &app, + Command::AssignBiosampleProject { guid, project_id: None }, + &CancelToken::none(), + ) + .await + { Event::BiosamplesChanged => {} other => panic!("got {other:?}"), } @@ -3679,7 +3766,9 @@ mod tests { name: "Old".into(), description: None, administrator: "jk".into(), - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::ProjectCreated(p) => p.id, @@ -3721,7 +3810,9 @@ mod tests { donor_identifier: "member".into(), sample_accession: None, sex: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3759,7 +3850,9 @@ mod tests { donor_identifier: "subj".into(), sample_accession: None, sex: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3781,7 +3874,9 @@ mod tests { pf_reads_aligned: None, mean_read_length: None, mean_insert_size: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::RunsChanged(_) => {} @@ -3833,7 +3928,9 @@ mod tests { bam_path: None, reference_path: None, content_sha256: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::AlignmentsChanged(_) => {} From 2534c37df1a14e9b21dc863bbf23dc4591d09223 Mon Sep 17 00:00:00 2001 From: James Kane Date: Sun, 2 Aug 2026 13:40:10 -0500 Subject: [PATCH 2/2] Enforce the format gate with a pre-commit hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo fmt` clean was already a per-commit gate for this repo, but nothing enforced it, and the drift that accumulated once reached 116 files — at which point every feature branch either carried a pile of unrelated reformatting into review or had to be de-noised by hand before it was readable. The preceding commit clears the drift; this stops it coming back. Local rather than CI, deliberately: the useful moment to catch this is before it is committed, not after a build fails. `cargo fmt --all --check` costs ~0.6s and compiles nothing, so it is cheap enough that nobody has a reason to disable it. The hook only ever *checks*. A hook that reformats your files mid-commit produces a commit whose contents you did not read, which is a worse failure than the one it prevents. It reports the offending files and the command to fix them. It stays out of the way where it has no business: no cargo on PATH (docs-only checkout) exits 0, and so does a commit with no staged `.rs` files — which also covers merges and reverts, where the tree is whatever the other side wrote and blocking helps nobody. `--no-verify` remains the escape hatch. Hooks live in per-clone git config, so this is not automatic on checkout; CLAUDE.md documents the one-time `git config core.hooksPath .githooks`. Verified by exercising both paths: a clean tree passes, staged drift is rejected with the file list, and the rejected commit is genuinely not created. Co-Authored-By: Claude Opus 5 (1M context) --- .githooks/pre-commit | 39 +++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 15 +++++++++++++++ 2 files changed, 54 insertions(+) create mode 100755 .githooks/pre-commit diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..341586da --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,39 @@ +#!/bin/sh +# Reject a commit that would (re)introduce rustfmt drift. +# +# `cargo fmt` clean is a per-commit gate for this repo, but nothing enforced it, and the drift that +# accumulated once reached 116 files — at which point every feature branch either carried a pile of +# unrelated reformatting into review or had to be de-noised by hand. This is the enforcement. +# +# It runs `--check` only: it never rewrites your working tree mid-commit, because a hook that edits +# files behind you produces a commit whose contents you did not read. It tells you what is wrong and +# lets you fix it. +# +# Deliberately local rather than CI: the point is to catch drift before it is committed, not to fail +# a build after the fact. +# +# Enable (once per clone): git config core.hooksPath .githooks +# Bypass for one commit: git commit --no-verify + +# No toolchain (docs-only checkout, CI image without rust) — do not block the commit. +command -v cargo >/dev/null 2>&1 || exit 0 + +# Nothing Rust-shaped staged → nothing this hook has an opinion about. Also covers the merge/revert +# case, where the tree is whatever the other side committed and blocking is unhelpful. +staged_rs=$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs') +[ -n "$staged_rs" ] || exit 0 + +out=$(cargo fmt --all --check 2>&1) +[ $? -eq 0 ] && exit 0 + +# `--check` reports "Diff in ::" per hunk; collapse to the file list. +files=$(printf '%s\n' "$out" | sed -n 's|^Diff in \(.*\):[0-9]*:$|\1|p' | sort -u) + +echo "rustfmt: the working tree is not formatted." >&2 +echo >&2 +printf '%s\n' "$files" | sed 's|^| |' >&2 +echo >&2 +echo " Fix: cargo fmt --all" >&2 +echo " Review: cargo fmt --all --check" >&2 +echo " Skip: git commit --no-verify (leaves the drift for someone else)" >&2 +exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index f688f8b2..bb17447f 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,10 +20,25 @@ cargo test --workspace # Lint gate (must be clean per commit) cargo clippy --all-targets -- -D warnings +# Format gate (must be clean per commit; enforced by the pre-commit hook below) +cargo fmt --all + # Run a single test cargo test -p navigator-analysis some_test_name ``` +**Enable the pre-commit hook once per clone** — it runs `cargo fmt --all --check` (~0.6s, no +compilation) and rejects a commit that would reintroduce formatting drift: + +```bash +git config core.hooksPath .githooks +``` + +Hooks are per-clone git config, so this is not automatic on checkout. Without it the gate is +advisory: the drift once reached 116 files, at which point every feature branch either carried a +pile of unrelated reformatting into review or had to be de-noised by hand. `git commit --no-verify` +bypasses it when you genuinely need to. + The built binary is named `navigator` (`target/debug/navigator` or `target/release/navigator`). Run with no subcommand to launch the GUI; run with `ingest` / `subjects` / `show` / `projects` for headless mode. ## Architecture Overview