Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -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 <abs path>:<line>:" 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
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion crates/navigator-analysis/examples/archaic_callable_dump.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ use navigator_analysis::archaic::ArchaicCallable;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut a = std::env::args().skip(1);
let path = a.next().expect("usage: archaic_callable_dump <callable.bin> [min_frac] [contig ...]");
let path = a
.next()
.expect("usage: archaic_callable_dump <callable.bin> [min_frac] [contig ...]");
let min_frac: f64 = a.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
let want: Vec<String> = a.collect();

Expand Down
4 changes: 3 additions & 1 deletion crates/navigator-analysis/examples/archaic_classify_dump.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ use navigator_analysis::archaic::ArchaicClassify;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut a = std::env::args().skip(1);
let path = a.next().expect("usage: archaic_classify_dump <classify.bin> [contig ...]");
let path = a
.next()
.expect("usage: archaic_classify_dump <classify.bin> [contig ...]");
let want: Vec<String> = a.collect();

let cls = ArchaicClassify::from_bytes(&std::fs::read(&path)?).map_err(|e| e.to_string())?;
Expand Down
12 changes: 10 additions & 2 deletions crates/navigator-analysis/examples/archaic_match_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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,
);
let carried = obs.iter().filter(|o| o.carries).count();
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ use navigator_analysis::archaic::ArchaicOutgroup;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut a = std::env::args().skip(1);
let path = a.next().expect("usage: archaic_outgroup_density <outgroup.bin> [window_bp] [contig ...]");
let path = a
.next()
.expect("usage: archaic_outgroup_density <outgroup.bin> [window_bp] [contig ...]");
let window: i64 = a.next().and_then(|s| s.parse().ok()).unwrap_or(1000);
let want: Vec<String> = a.collect();

Expand Down
4 changes: 3 additions & 1 deletion crates/navigator-analysis/examples/archaic_panel_dump.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ use navigator_analysis::archaic::{ArchaicMarkerPanel, ARCHAIC_GENOMES};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut a = std::env::args().skip(1);
let path = a.next().expect("usage: archaic_panel_dump <archaic_markers.bin> [contig ...]");
let path = a
.next()
.expect("usage: archaic_panel_dump <archaic_markers.bin> [contig ...]");
let want: Vec<String> = a.collect();

let panel = ArchaicMarkerPanel::from_bytes(&std::fs::read(&path)?).map_err(|e| e.to_string())?;
Expand Down
8 changes: 6 additions & 2 deletions crates/navigator-analysis/examples/archaic_private_dump.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ use navigator_analysis::caller::SiteGenotype;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut a = std::env::args().skip(1);
let calls_path = a.next().expect("usage: archaic_private_dump <calls.json> <outgroup.bin>");
let og_path = a.next().expect("usage: archaic_private_dump <calls.json> <outgroup.bin>");
let calls_path = a
.next()
.expect("usage: archaic_private_dump <calls.json> <outgroup.bin>");
let og_path = a
.next()
.expect("usage: archaic_private_dump <calls.json> <outgroup.bin>");

let calls: Vec<SiteGenotype> = 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())?;
Expand Down
36 changes: 29 additions & 7 deletions crates/navigator-analysis/examples/archaic_segments_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,37 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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(())
}
26 changes: 21 additions & 5 deletions crates/navigator-analysis/examples/cram_query_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -70,7 +79,11 @@ fn main() {
r.sequence().as_ref().to_vec(),
)
};
let mine: Vec<_> = reader.query(&header, &region).expect("mine").map(|r| key(&r.expect("rec"))).collect();
let mine: Vec<_> = reader
.query(&header, &region)
.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()
Expand All @@ -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");
Expand Down
3 changes: 2 additions & 1 deletion crates/navigator-analysis/examples/denovo_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ fn main() {
&contig,
&params,
&navigator_analysis::CancelToken::none(),
).expect("call_denovo");
)
.expect("call_denovo");
eprintln!(
"call_denovo({contig}): {} variants in {:.1}s (realign={})",
calls.len(),
Expand Down
20 changes: 15 additions & 5 deletions crates/navigator-analysis/examples/profile_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,23 +36,33 @@ fn main() {
coverage::estimate_molecule_lengths(bam, Some(reference)).ok()
});
timed("coverage SEQUENTIAL whole-genome", || {
coverage::collect_coverage_callable(bam, reference, &params, None).map(|_| ()).err()
coverage::collect_coverage_callable(bam, reference, &params, None)
.map(|_| ())
.err()
});
timed("coverage SEQUENTIAL scoped chrY+chrM", || {
coverage::collect_coverage_callable(bam, reference, &params, Some(&ym)).map(|_| ()).err()
coverage::collect_coverage_callable(bam, reference, &params, Some(&ym))
.map(|_| ())
.err()
});
timed("coverage PARALLEL whole-genome", || {
unified::collect_unified_metrics_parallel(bam, reference, &params, None).map(|_| ()).err()
unified::collect_unified_metrics_parallel(bam, reference, &params, None)
.map(|_| ())
.err()
});
timed("coverage PARALLEL scoped chrY+chrM", || {
unified::collect_unified_metrics_parallel(bam, reference, &params, Some(&ym)).map(|_| ()).err()
unified::collect_unified_metrics_parallel(bam, reference, &params, Some(&ym))
.map(|_| ())
.err()
});

// chrY haplogroup genotyping pass: a region query over chrY tallying ~200k target sites
// (representative of the Y tree's chrY loci) — the deep-analyze Y step's read pattern.
let hp = HaploidCallerParams::default();
let targets: HashSet<i64> = (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()
});
}
40 changes: 28 additions & 12 deletions crates/navigator-analysis/examples/reassembly_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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<u8> = refseq[(lo - 1) as usize..(hi as usize).min(refseq.len())].to_vec();
Expand Down
5 changes: 4 additions & 1 deletion crates/navigator-analysis/examples/reassembly_validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ fn main() {
let hi = (pos + 5) as usize;

let called = |reassembly: bool| -> Option<(char, char, u32, u32, Option<f64>)> {
let params = HaploidCallerParams { reassembly, ..HaploidCallerParams::default() };
let params = HaploidCallerParams {
reassembly,
..HaploidCallerParams::default()
};
let calls = call_denovo_region(bam, refp, contig, lo, hi, &params).expect("call_denovo_region");
calls
.into_iter()
Expand Down
Loading
Loading