diff --git a/crates/navigator-align/examples/map_profile.rs b/crates/navigator-align/examples/map_profile.rs index 58210997..b1df1d9a 100644 --- a/crates/navigator-align/examples/map_profile.rs +++ b/crates/navigator-align/examples/map_profile.rs @@ -1,9 +1,9 @@ -//! Profiling harness for the mapping stage alone — stage B with nothing else in the sample. +//! A harness that profiles the mapping stage alone: stage B with nothing else in the sample. //! -//! The mapping stage runs read → map → write per batch, and the CPU-load graph shows a valley -//! between the peaks: the rayon pool idles while one thread inflates gzip and parses the next -//! batch of reads. This runs `map_pairs` and nothing else, so a profile attributes that valley to -//! a function rather than to a stage. +//! The mapping stage does read → map → write for each batch. The CPU-load graph shows a valley +//! between the peaks. The rayon pool is idle there, because one thread inflates gzip and parses +//! the next batch of reads. This example runs `map_pairs` and nothing else, so a profile points +//! at a function and not at a stage. //! //! ```sh //! cargo build --profile profiling -p navigator-align --example map_profile @@ -14,8 +14,8 @@ //! samply record target/profiling/examples/map_profile //! ``` //! -//! `LIMIT_SECONDS` stops after a fixed wall-clock budget, so a profile can be taken over a couple -//! of minutes of steady state instead of the three quarters of an hour a whole sample takes. +//! `LIMIT_SECONDS` stops the run after a fixed wall-clock budget. A profile then covers about two +//! minutes of steady state, and not the three quarters of an hour that a whole sample needs. use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; @@ -67,7 +67,7 @@ fn main() -> Result<(), Box> { let stats = match navigator_align::map_pairs(&index, &r1, &r2, &out, &scratch, ¶ms, &cancelled, &mut progress) { Ok(stats) => stats, - // Hitting the time budget is the normal way this ends. + // The time budget is the normal reason for this loop to stop. Err(navigator_align::AlignError::Cancelled) => { eprintln!("stopped at the {limit}s budget"); return Ok(()); diff --git a/crates/navigator-align/src/batch.rs b/crates/navigator-align/src/batch.rs index 6e5568f0..f79093f1 100644 --- a/crates/navigator-align/src/batch.rs +++ b/crates/navigator-align/src/batch.rs @@ -1,4 +1,4 @@ -//! Index batch size — the memory control for the whole module. +//! Index batch size: the memory control for the whole module. //! //! minimap2 splits a reference into index *parts* of at most `batch_size` bases (the CLI's `-I`). //! One part is resident at a time, so this single number decides peak RAM. Measured on CHM13v2 @@ -11,31 +11,33 @@ //! | 400 Mbase | 8.7 GiB | — | //! | 200 Mbase | 7.5 GiB | — | //! -//! Wall time was flat across all of them, so bounding memory here is close to free. Building one -//! monolithic index is the failure mode to avoid — it is what made an early estimate conclude the -//! module needed ~19 GB and could not run on a normal desktop. +//! Wall time was flat across all of them, so a limit on memory here is almost free. One +//! monolithic index is the failure mode to avoid. It is what made an early estimate say the module +//! needed ~19 GB, and could not run on a normal desktop. //! -//! **Bigger is better, within budget.** A split index costs a little MAPQ fidelity: a read's -//! second-best hit can fall in another part and go uncounted, so MAPQ comes out slightly *too -//! high* at multi-mapping loci (measured at 7 of 5,045 records against a ~5-part split, with every -//! locus identical). So this picks the largest batch that fits, never the smallest that works. +//! **Bigger is better, inside the budget.** A split index costs a little MAPQ fidelity. A read's +//! second-best hit can fall in another part, where the count misses it. So MAPQ comes out a little +//! *too high* at a locus with more than one hit. The measurement was 7 of 5,045 records against a +//! ~5-part split, and every locus was identical. So this code chooses the largest batch that fits, +//! and never the smallest one that works. //! -//! ## Sizing itself +//! ## How the code chooses the size //! -//! [`BatchSize::for_this_machine`] reads the machine's physical memory and picks from the table -//! above. This is the path the app should use: the target user clicks "Realign" and gets a job -//! sized to their hardware, rather than being asked for a number in bases that nothing in their -//! experience equips them to choose. A wrong answer here is not a preference, it is either an -//! out-of-memory failure or a needlessly split index. +//! [`BatchSize::for_this_machine`] reads the machine's physical memory and chooses from the table +//! above. This is the path the app must use. The target user clicks "Realign" and gets a job that +//! fits their hardware. Nobody asks them for a number in bases, because nothing in their +//! experience prepares them to choose one. A wrong answer here is not a preference. It is an +//! out-of-memory failure, or an index with more parts than it needs. //! -//! It sizes from **total** memory, not currently-available memory, which is deliberate. The `.mmi` -//! is cached and reused for every later job against that build, so a machine that happens to be -//! busy at the moment of the first click would otherwise bake a more-split index — and its -//! permanent MAPQ cost — into the cache. Available memory is still reported by -//! [`detect_memory`], because deciding whether to start *right now* is a different question from -//! how to build the artifact, and belongs to the preflight. - -/// Bases per index part. +//! It reads **total** memory, and not the memory that is free at that moment. That is deliberate. +//! The cache keeps the `.mmi`, and every later job against that build uses it again. A machine +//! that is busy at the moment of the first click would otherwise put an index with more parts into +//! the cache. That index carries a permanent MAPQ cost. +//! +//! [`detect_memory`] still reports the free memory. The question "can the job start *right now*" +//! belongs to the preflight. That is a different question from "how do we build the artifact". + +/// Bases in each index part. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct BatchSize(u64); @@ -67,9 +69,9 @@ impl MachineMemory { /// Read the machine's memory. /// -/// `None` if the platform will not say — sysinfo supports every desktop target Navigator ships to, -/// but reporting nothing is preferable to reporting a fabricated number that would then silently -/// size a multi-hour job. +/// `None` if the platform will not say. sysinfo supports every desktop target that Navigator +/// ships to. But it is better to report nothing than to report an invented number. Such a number +/// would then set the size of a multi-hour job, with no warning. pub fn detect_memory() -> Option { let mut system = sysinfo::System::new(); system.refresh_memory(); @@ -87,13 +89,13 @@ pub fn detect_memory() -> Option { /// fit a 16 GB machine with room for the OS and the rest of the app. const DEFAULT_BASES: u64 = GBASE; -/// "Do not split" — 8 Gbase, which is also minimap2's own default `batch_size`. Any human -/// reference fits in one part at this size, so it expresses the intent without a magic sentinel, -/// and it still renders as a real number wherever the choice is reported. +/// "Do not split": 8 Gbase, which is also minimap2's own default `batch_size`. Any human +/// reference fits in one part at this size. So the value shows the intent, and it is not a special +/// sentinel. It also renders as a real number everywhere the code reports the choice. const UNSPLIT: u64 = 8 * GBASE; -/// `NAVIGATOR_ALIGN_BATCH_MBASE`, in megabases — the escape hatch for unusual hardware, and how -/// tests pin the value without depending on the machine they run on. +/// `NAVIGATOR_ALIGN_BATCH_MBASE`, in megabases. This is the override for unusual hardware. It is +/// also how tests pin the value, so that they do not depend on the machine they run on. fn env_override() -> Option { std::env::var("NAVIGATOR_ALIGN_BATCH_MBASE") .ok() @@ -101,8 +103,8 @@ fn env_override() -> Option { .map(|mbase| mbase.saturating_mul(1_000_000).max(1_000_000)) } -/// The conservative default: honours the override, otherwise assumes a 16 GB desktop. Prefer -/// [`BatchSize::for_this_machine`], which actually looks. +/// The conservative default: it obeys the override, and if there is none it assumes a 16 GB +/// desktop. Prefer [`BatchSize::for_this_machine`], which reads the machine. impl Default for BatchSize { fn default() -> Self { Self(env_override().unwrap_or(DEFAULT_BASES).max(1_000_000)) @@ -115,11 +117,11 @@ impl BatchSize { Self(bases.max(1_000_000)) } - /// The batch size for the machine this is running on — the button-click path. + /// The batch size for the machine this code runs on. This is the button-click path. /// - /// Precedence, highest first: the `NAVIGATOR_ALIGN_BATCH_MBASE` override, then detected - /// physical memory, then the 16 GB-desktop default. The override comes first so a user on - /// unusual hardware, or a test, can pin the value without having to defeat the detector. + /// Precedence, highest first: the `NAVIGATOR_ALIGN_BATCH_MBASE` override, then the physical + /// memory that the code found, then the 16 GB-desktop default. The override comes first, so + /// that a user on unusual hardware, or a test, can pin the value and not defeat the detector. pub fn for_this_machine() -> Self { if let Some(bases) = env_override() { return Self(bases); @@ -132,8 +134,8 @@ impl BatchSize { /// Why [`BatchSize::for_this_machine`] chose what it did, for a log line or a UI tooltip. /// - /// A realignment is a multi-hour job whose memory profile the user can not see; when it is - /// sized automatically, the sizing has to be inspectable rather than a mystery. + /// A realignment is a multi-hour job, and the user can not see its memory profile. The code + /// chooses the size without help, so the user must be able to see how it chose. pub fn explain() -> String { if let Some(bases) = env_override() { return format!( @@ -174,37 +176,40 @@ impl BatchSize { /// The largest batch that fits a machine with `ram_gib` of physical memory, from the measured /// table in the module docs. /// - /// The thresholds leave headroom deliberately: the numbers in that table are the mapper's peak - /// alone, and a realignment job is also holding a sort buffer, the revert's scratch, and a - /// desktop application. Below 8 GiB nothing here is comfortable, so the smallest step is - /// offered rather than refusing outright — the preflight decides whether to proceed, not this. + /// The thresholds leave headroom on purpose. The numbers in that table are the peak of the + /// mapper alone. A realignment job also holds a sort buffer, the scratch of the revert, and a + /// desktop application. Below 8 GiB no value here is comfortable, so this code gives the + /// smallest step and does not refuse. The preflight decides whether to go on, and this does + /// not. pub fn for_ram_gib(ram_gib: u64) -> Self { let bases = match ram_gib { 0..=7 => 200_000_000, 8..=15 => 400_000_000, 16..=31 => GBASE, - // Above 32 GiB a single part is affordable, and a whole index costs no MAPQ fidelity - // at all — the one thing splitting gives up. `UNSPLIT` rather than a saturating - // sentinel: this number reaches a log line and a UI tooltip, and "9223372036854 - // Mbase" is not something to show a user who was promised a button. + // Above 32 GiB the machine can hold one part, and a whole index costs no MAPQ + // fidelity at all. MAPQ fidelity is the one thing a split index gives up. Use + // `UNSPLIT`, and not a sentinel at the top of the number range. This number reaches a + // log line and a UI tooltip. "9223372036854 Mbase" is not a thing to show a user who + // clicked one button. _ => UNSPLIT, }; Self(bases) } - /// Whether a reference of `total_bases` will be split into more than one part — i.e. whether - /// the cross-part merge and its MAPQ caveat come into play at all. + /// True when a reference of `total_bases` needs more than one part. That is also when the + /// cross-part merge and its MAPQ caveat apply at all. pub fn splits(self, total_bases: u64) -> bool { total_bases > self.0 } - /// An **upper bound** on how many parts `total_bases` will produce, for sizing a progress bar. + /// An **upper bound** on how many parts `total_bases` makes. Use it to set the length of a + /// progress bar. /// - /// Deliberately not exact. A part accumulates whole sequences until the running total - /// *exceeds* the batch, so parts overshoot by up to one sequence and the real count comes in - /// at or below this — measured, a 3 Mbase reference at a 1 Mbase batch yields 2 parts where - /// this returns 3. A progress bar that finishes early is fine; one that runs past its own - /// maximum is not. + /// It is not exact, on purpose. A part collects whole sequences until the total *goes above* + /// the batch. So a part overshoots by as much as one sequence, and the real count is this + /// number or less. The measurement: a 3 Mbase reference at a 1 Mbase batch makes 2 parts, + /// where this returns 3. A progress bar that ends early is acceptable. One that goes past its + /// own maximum is not. pub fn part_estimate(self, total_bases: u64) -> usize { if self.0 == 0 { return 1; @@ -217,8 +222,8 @@ impl BatchSize { mod tests { use super::*; - /// The sizing table's whole purpose: a 16 GB desktop must land on a batch that fits it, and a - /// small machine must land on a smaller one. + /// The whole purpose of the table: a 16 GB desktop must land on a batch that fits it. A small + /// machine must land on a smaller batch. #[test] fn ram_maps_to_the_largest_batch_that_fits() { assert_eq!(BatchSize::for_ram_gib(8).bases(), 400_000_000); @@ -244,8 +249,8 @@ mod tests { /// CHM13 is 3.1 Gbase; the default must split it (that is the point) into a handful of parts. #[test] fn the_default_splits_a_human_genome_into_a_few_parts() { - // Reads the environment, so it takes ENV_LOCK too — the guard is only worth anything if - // the readers hold it as well as the writers. + // This reads the environment, so it takes ENV_LOCK too. The guard has no value unless + // the readers hold it and the writers hold it. let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let chm13 = 3_100_000_000u64; let default = BatchSize::default(); @@ -260,16 +265,16 @@ mod tests { assert_eq!(b.part_estimate(50_000_000), 1); } - /// A zero or absurdly small batch would produce an unbounded part count and thrash; the floor - /// keeps a bad caller from turning the mapper into a no-op. + /// A batch of zero, or a very small batch, would make a part count with no limit, and the + /// machine would thrash. The floor stops a bad caller who would make the mapper do nothing. #[test] fn the_batch_size_has_a_floor() { assert!(BatchSize::new(0).bases() >= 1_000_000); } - /// Detection has to work on whatever machine this runs on — that is the entire point of taking - /// the dependency. The assertions are about plausibility rather than a specific number, since - /// the test can not know the host. + /// Detection must work on any machine this runs on. That is the whole reason for the + /// dependency. The assertions are about a plausible range, and not about a specific number, + /// because the test can not know the host. #[test] fn the_machine_reports_its_own_memory() { let memory = detect_memory().expect("every desktop target sysinfo supports reports memory"); @@ -286,8 +291,8 @@ mod tests { ); } - /// The button-click path must always yield a usable batch, whatever the host, and must land on - /// a value the sizing table actually produces rather than something improvised. + /// The button-click path must always give a batch that works, on any host. It must also land + /// on a value that the table makes, and not on an improvised one. #[test] fn sizing_for_this_machine_lands_on_a_table_value() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -300,11 +305,11 @@ mod tests { assert_eq!(chosen, from_table, "detection and the table must agree"); } - /// The override is the escape hatch for hardware the table does not suit, so it has to beat - /// detection rather than merely fill in for it. + /// The override is for hardware that the table does not suit, so it must win over detection. + /// It must not only fill in when detection gives nothing. /// - /// Serialized with the other env-reading test: `set_var` is process-global, and Rust runs tests - /// in threads by default. + /// This test runs in sequence with the other test that reads the environment: `set_var` is + /// process-global, and Rust runs tests in threads by default. #[test] fn the_env_override_beats_detection() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -331,8 +336,8 @@ mod tests { assert!(detected.contains("RAM") || detected.contains("default"), "{detected}"); } - /// This string reaches a log line and a UI tooltip, so no branch of the sizing table may render - /// as a raw sentinel — the large-RAM case used to come out as "9223372036854 Mbase". + /// This string reaches a log line and a UI tooltip. So no branch of the table may render as a + /// raw sentinel. The large-RAM case used to come out as "9223372036854 Mbase". #[test] fn every_table_choice_describes_itself_readably() { for gib in [4u64, 8, 16, 24, 32, 64, 128, 512] { diff --git a/crates/navigator-align/src/error.rs b/crates/navigator-align/src/error.rs index a7572009..817def6d 100644 --- a/crates/navigator-align/src/error.rs +++ b/crates/navigator-align/src/error.rs @@ -1,4 +1,4 @@ -//! Error type for the mapping layer (one `thiserror` enum per layer, as elsewhere). +//! Error type for the mapping layer (one `thiserror` enum for each layer, as elsewhere). use std::path::PathBuf; @@ -11,17 +11,17 @@ pub enum AlignError { source: std::io::Error, }, - /// The read technology could not be resolved to a mapper preset. Deliberately an error and not - /// a guess: mapping long reads with a short-read preset (or the reverse) silently produces bad - /// alignments rather than failing, so an unknown technology has to stop the job and ask. + /// The read technology does not resolve to a mapper preset. This is an error and not a guess. + /// A short-read preset that maps long reads makes bad alignments, and so does the reverse. The + /// mapper gives no warning when it does this. An unknown technology must stop the job and ask. #[error("cannot choose a mapper preset for {what} — pass one explicitly")] UnknownTechnology { what: String }, #[error("{0}")] Message(String), - /// The job stopped because cancellation was requested. A distinct variant so callers can tell - /// a user-requested stop from a failure — same contract as `AnalysisError::Cancelled`. + /// The user cancelled the job. This is a different variant, so that a caller can tell a stop + /// that the user asked for from a failure. The contract is the same as `AnalysisError::Cancelled`. #[error("cancelled")] Cancelled, } diff --git a/crates/navigator-align/src/index.rs b/crates/navigator-align/src/index.rs index 1614a759..e1caf3b9 100644 --- a/crates/navigator-align/src/index.rs +++ b/crates/navigator-align/src/index.rs @@ -1,9 +1,9 @@ -//! The minimap2 index (`.mmi`) cache, and building one part by part. +//! The minimap2 index (`.mmi`) cache, and how to build one part by part. //! -//! An index is specific to both the reference build *and* the preset — `sr`, `map-hifi`, and -//! `map-ont` disagree on k-mer and window size, so an index built for one is wrong for another. -//! The cache key is therefore `(build, preset)`, laid out alongside the reference cache the -//! refgenome crate already owns: +//! An index is specific to the reference build *and* to the preset. `sr`, `map-hifi` and +//! `map-ont` disagree on k-mer size and window size, so an index for one preset is wrong for +//! another. So the cache key is `(build, preset)`. The layout puts it next to the reference cache +//! that the refgenome crate already owns: //! //! ```text //! /minimap2_index//.mmi @@ -11,14 +11,15 @@ //! //! ## Part by part //! -//! [`build_index`] streams: read one index part from the FASTA, write it, drop it, repeat. That is -//! what keeps peak memory at the [`BatchSize`] rather than at the whole genome — building a single -//! resident index for CHM13 costs ~19 GiB, and building it in 1 Gbase parts costs 11.7 GiB for the -//! same output in the same wall time. +//! [`build_index`] streams: read one index part from the FASTA, write it, drop it, and repeat. +//! That line keeps peak memory at the [`BatchSize`], and not at the whole genome. One index for +//! CHM13 that stays in memory costs about 19 GiB. The same index in parts of 1 Gbase costs +//! 11.7 GiB, and gives the same output in the same wall time. //! -//! The `.mmi` is written to a temporary path and renamed on success, so an interrupted build can -//! never leave a half-written index that a later run would load as if it were whole. The file is -//! 8.93 GB for CHM13 — large enough that "just rebuild it if it looks wrong" is not a strategy. +//! The code writes the `.mmi` to a temporary path, and renames it on success. So a build that +//! stops early can never leave a half-written index that a later run would load as a whole one. +//! The file is 8.93 GB for CHM13. That is too large for "build it again if it looks wrong" to be +//! a strategy. use std::path::{Path, PathBuf}; @@ -31,18 +32,20 @@ use crate::batch::BatchSize; use crate::error::AlignError; use crate::preset::Preset; -/// Progress during a long index build: `(parts_done, bases_done)`. Parts arrive as they are -/// written, so a caller can report "part 2 of ~4" against [`BatchSize::part_estimate`]. +/// Progress during a long index build: `(parts_done, bases_done)`. Each part arrives when the +/// code writes it, so a caller can report "part 2 of ~4" against [`BatchSize::part_estimate`]. pub type ProgressFn<'a> = &'a mut dyn FnMut(usize, u64); /// The cache root the aligner index lives under: `$NAVIGATOR_REFGENOME_DIR`, else `~/.decodingus`. /// -/// Deliberately the same answer `navigator-refgenome::cache::base_dir` gives, reached the same way -/// — through `navigator_domain::paths::decodingus_dir`, the one definition of the cache root — so -/// `minimap2_index/` lands beside `references/` and `liftover/` rather than in a second location -/// that only this crate knows about. This crate is a leaf and can not depend on `navigator-refgenome` -/// (that would invert the layering), which is why the resolution is repeated rather than imported; -/// the shared *definition* is what keeps the two the same. +/// This gives the same answer as `navigator-refgenome::cache::base_dir`, and it reaches that +/// answer the same way. Both go through `navigator_domain::paths::decodingus_dir`, which is the +/// one definition of the cache root. So `minimap2_index/` lands beside `references/` and +/// `liftover/`, and not in a second location that only this crate knows about. +/// +/// This crate is a leaf, so it can not depend on `navigator-refgenome`. That dependency would +/// invert the layers. So the code repeats the resolution, and does not import it. The shared +/// *definition* is what keeps the two the same. pub fn cache_root() -> PathBuf { if let Some(dir) = std::env::var_os("NAVIGATOR_REFGENOME_DIR") { return PathBuf::from(dir); @@ -52,8 +55,8 @@ pub fn cache_root() -> PathBuf { /// Where the cached index for `(build, preset)` lives under `base`. /// -/// `base` is the refgenome cache root — [`cache_root`] resolves it, or a caller that already has -/// one (the app, which resolves it once) passes it in. Tests point it anywhere. +/// `base` is the refgenome cache root. [`cache_root`] resolves it. A caller that already has one +/// (the app, which resolves it once) can pass it in. Tests point it anywhere. pub fn index_path(base: &Path, build: &str, preset: Preset) -> PathBuf { base.join("minimap2_index") .join(build) @@ -62,8 +65,8 @@ pub fn index_path(base: &Path, build: &str, preset: Preset) -> PathBuf { /// Build the index for `reference` into the cache, unless it is already there. /// -/// Returns the cached path. Idempotent: an existing index is returned untouched, which is what -/// makes this safe to call at the top of every realignment job. +/// Returns the cached path. This function is idempotent: it returns an index that already exists +/// and does not touch it. That is what makes it safe to call at the top of every realignment job. pub fn ensure_index( base: &Path, build: &str, @@ -80,11 +83,12 @@ pub fn ensure_index( Ok(path) } -/// [`ensure_index`] against the real cache root, sizing the index for this machine. +/// [`ensure_index`] against the real cache root, with an index size for this machine. /// -/// This is the call a job should make: it resolves where the cache lives, picks a batch size from -/// the machine's RAM, and returns a ready index — none of which the caller should have to know how -/// to do. See [`BatchSize::for_this_machine`] for why the sizing is detected rather than asked. +/// This is the call a job must make. It resolves where the cache lives, chooses a batch size from +/// the machine's RAM, and returns an index that is ready. A caller does not have to know how to do +/// any of that. See [`BatchSize::for_this_machine`] for why the code finds the size and does not +/// ask for it. pub fn ensure_cached_index( build: &str, reference: &Path, @@ -103,8 +107,8 @@ pub fn ensure_cached_index( /// Build a `.mmi` for `reference` at `out`, one part at a time. /// -/// Exposed separately from [`ensure_index`] so a caller can build to an arbitrary location — the -/// tests do, and so would a "rebuild this index" maintenance action. +/// This is public and separate from [`ensure_index`], so that a caller can build to any location. +/// The tests do that, and so would a "build this index again" maintenance action. pub fn build_index( reference: &Path, out: &Path, @@ -131,14 +135,14 @@ pub fn build_index( ) .map_err(|e| AlignError::io(reference, e))?; - // Write to a sibling temp path and rename at the end: a torn 8.93 GB index that looks complete - // is a far worse outcome than a build that has to be repeated. + // Write to a sibling temp path, and rename at the end. A torn 8.93 GB index that looks + // complete is a much worse result than a build that must run again. let tmp = out.with_extension("mmi.partial"); let file = std::fs::File::create(&tmp).map_err(|e| AlignError::io(&tmp, e))?; - // Paced, like every other multi-GB write in the pipeline: an index build is a one-off, but it - // is nine gigabytes in one uninterrupted push, and it happens on the machine of a user who is - // still using it. It also puts those bytes in the counter the resource watch reports, so the - // stage stops looking idle in the log. + // The pipeline paces this write, like every other multi-GB write in it. An index build + // happens one time only. But it is nine gigabytes in one continuous push, and it happens on + // the machine of a user who still works on it. The pacing also puts those bytes in the counter + // that the resource watch reports, so the stage no longer looks idle in the log. let mut writer = std::io::BufWriter::with_capacity(1 << 20, navigator_resource::PacedFile::new(file)); let mut parts = 0usize; @@ -150,15 +154,15 @@ pub fn build_index( bases += part_bases(&part); parts += 1; write_part(&mut writer, &part, &tmp)?; - // `part` is dropped here — this is the line that bounds peak memory to one part. + // The code drops `part` here. This line is what limits peak memory to one part. progress(parts, bases); } use std::io::Write as _; writer.flush().map_err(|e| AlignError::io(&tmp, e))?; - // Sync before the rename. The rename is what publishes this as a complete index, and a cache - // entry whose contents are still only a page-cache promise is the torn-index case the temp path - // exists to prevent. + // Sync before the rename. The rename is what makes this a complete index in the cache. A + // cache entry whose contents are still only a page-cache promise is the torn-index case, and + // the temp path exists to stop it. writer.get_ref().sync().map_err(|e| AlignError::io(&tmp, e))?; drop(writer); @@ -190,9 +194,9 @@ fn idx_opts(preset: Preset) -> Result { Ok(io) } -/// minimap2's API takes paths as `&str`. A non-UTF-8 path is a real possibility on both Unix and -/// Windows, so it is refused with a clear message rather than lossily converted into a path that -/// does not exist. +/// minimap2's API takes paths as `&str`. A non-UTF-8 path is possible on both Unix and Windows. +/// So this function refuses it with a clear message. It does not do a lossy conversion, which +/// would make a path that does not exist. pub(crate) fn path_str(path: &Path) -> Result { path.to_str() .map(str::to_string) @@ -217,7 +221,7 @@ mod tests { let mut text = String::new(); for c in 0..contigs { text.push_str(&format!(">contig{c}\n")); - // Non-repetitive enough to produce minimizers rather than one degenerate bucket. + // Non-repetitive enough to make minimizers, and not one degenerate bucket. let seq: String = (0..len) .map(|i| match (i * 7 + c * 13) % 4 { 0 => 'A', @@ -272,14 +276,15 @@ mod tests { ); } - /// The memory bound in action: a reference larger than the batch must yield several parts, and - /// the resulting `.mmi` must still be one loadable file with every base accounted for. + /// The memory limit in action: a reference larger than the batch must make more than one + /// part. The `.mmi` that comes out must still be one file that loads, with every base in it. /// - /// Sizing this fixture is fussier than it looks, and the shape is worth recording. A part - /// accumulates whole sequences until the total *exceeds* the batch, so parts overshoot by up - /// to one sequence and a reference only a little larger than the batch still comes out as one - /// part. The fixture must also clear [`BatchSize`]'s 1 Mbase floor, which exists because - /// smaller parts are pathological in production. 3 Mbase against a 1 Mbase batch clears both. + /// The size of this fixture is more difficult than it looks, and the shape is worth a record + /// here. A part collects whole sequences until the total *goes above* the batch. So a part + /// overshoots by as much as one sequence. A reference only a little larger than the batch + /// still comes out as one part. The fixture must also clear [`BatchSize`]'s 1 Mbase floor, + /// which exists because a smaller part is pathological in production. A fixture of 3 Mbase + /// against a 1 Mbase batch clears both limits. #[test] fn a_reference_larger_than_the_batch_splits_into_several_parts() { let dir = scratch("split"); @@ -305,8 +310,8 @@ mod tests { assert!(out.is_file()); } - /// `ensure_index` is called at the top of every job, so a second call must not rebuild an - /// 8.93 GB artifact. + /// Every job calls `ensure_index` at its start, so a second call must not build an 8.93 GB + /// artifact again. #[test] fn ensure_index_is_idempotent() { let dir = scratch("ensure"); @@ -340,9 +345,10 @@ mod tests { assert_eq!(stamp, std::fs::metadata(&second).unwrap().modified().unwrap()); } - /// The aligner index has to land beside the reference cache, not in a second place only this - /// crate knows about. `navigator-refgenome` resolves its root the same way — env override - /// first, then the shared `decodingus_dir` — and this pins that agreement. + /// The aligner index must land beside the reference cache, and not in a second place that + /// only this crate knows about. `navigator-refgenome` resolves its root the same way: the + /// environment override first, then the shared `decodingus_dir`. This test pins that + /// agreement. #[test] fn the_cache_root_follows_the_refgenome_override() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -367,8 +373,9 @@ mod tests { /// `set_var` mutates process-global state, so the tests that touch it must not overlap. static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - /// Feeding something that is not a reference must fail loudly rather than caching an empty - /// index that every later job would load and quietly map nothing against. + /// A file that is not a reference must fail with a loud message. It must not cache an empty + /// index. Every later job would load such an index, map nothing against it, and give no + /// warning. #[test] fn a_reference_with_no_sequences_is_an_error() { let dir = scratch("empty"); diff --git a/crates/navigator-align/src/lib.rs b/crates/navigator-align/src/lib.rs index 297587b9..015ea4ed 100644 --- a/crates/navigator-align/src/lib.rs +++ b/crates/navigator-align/src/lib.rs @@ -1,50 +1,54 @@ -//! Read mapping for the realignment module — stage B of +//! Read mapping for the realignment module. This is stage B of //! `documents/design/realignment-module.md`. //! -//! Takes the reads [`navigator-analysis`'s revert stage](../navigator_analysis/revert/index.html) -//! recovered and maps them to a new reference. Everything here is about doing that within a -//! desktop's memory, because that — not accuracy, and no longer platform support — is the module's -//! binding constraint. +//! This module takes the reads that +//! [`navigator-analysis`'s revert stage](../navigator_analysis/revert/index.html) recovered, and +//! maps them to a new reference. It does that work inside a desktop's memory. Memory is the +//! constraint that limits the module. Accuracy is not, and platform support is no longer. //! //! ## The backend //! -//! The default mapper is [`minimap2-pure-rs`], a pure-Rust translation of minimap2 v2.31. It was -//! chosen over linking the C library through FFI because it needs no C toolchain, which means -//! Windows and every other Rust target build unchanged, and because a parity test measured it -//! 99.74% byte-identical to the C implementation with **zero disagreements at MAPQ > 0**. +//! The default mapper is [`minimap2-pure-rs`], a pure-Rust translation of minimap2 v2.31. The +//! module uses it, and does not link the C library through FFI. It needs no C toolchain, so +//! Windows and every other Rust target build unchanged. A parity test also measured it 99.74% +//! byte-identical to the C implementation, with **zero disagreements at MAPQ > 0**. //! -//! That crate describes itself as an "LLM-mediated faithful translation" and asks users to stay -//! alert to bugs, so its output has to be checked against the original. That check is done -//! **out-of-band**, by running upstream minimap2 over the same reads and diffing — not by -//! carrying a second backend in here. Linking the C library would put a C toolchain, an `unsafe` -//! surface, and a Windows-unproven dependency into the shipped artifact to serve a development -//! activity, which is a poor trade for something no user ever runs. +//! That crate describes itself as an "LLM-mediated faithful translation", and it asks users to +//! look for bugs. So its output needs a check against the original. Do that check **outside the +//! application**: run upstream minimap2 over the same reads and compare the two outputs. Do not +//! keep a second backend here. +//! +//! A link to the C library would put a C toolchain, an `unsafe` surface, and a Windows-unproven +//! dependency into the artifact that users install. That is a poor trade for a development +//! activity that no user ever runs. //! //! ## Memory //! -//! A reference is indexed in *parts* of at most [`BatchSize`] bases, and exactly one part is -//! resident at a time. Building CHM13's index as a single part costs ~19 GiB; building it in -//! 1 Gbase parts costs 11.7 GiB, produces the same output, and takes the same wall time. See -//! [`batch`] for the measured table and for why bigger parts are preferred within the budget. +//! This module indexes a reference in *parts* of not more than [`BatchSize`] bases. Exactly one +//! part stays in memory at a time. An index of CHM13 that is one part costs about 19 GiB. An index +//! in parts of 1 Gbase costs 11.7 GiB, gives the same output, and takes the same wall time. See +//! [`batch`] for the measured table, and for why a larger part is better inside the budget. //! -//! [`BatchSize::for_this_machine`] reads the machine's RAM and picks for itself. That is the -//! intended entry point: this module's users click a button, and "bases per index part" is not a -//! question they can be asked — a wrong answer is an out-of-memory failure, not a preference. +//! [`BatchSize::for_this_machine`] reads the machine's RAM and chooses for itself. That is the +//! intended entry point. Users of this module click a button. Nobody can ask them how many bases +//! an index part holds, because a wrong answer is an out-of-memory failure and not a preference. //! //! ## What is here so far //! -//! [`preset`] (which mapper preset a run's reads need), [`batch`] (the memory control), [`index`] -//! (the `.mmi` cache and the part-by-part build), [`map`] (single-end mapping, including the -//! cross-part merge that makes a split index produce the same alignments as a whole one), [`pe`] -//! (paired-end, which is what `sr` and most vendor WGS need), and [`output`] (SAM/BAM/CRAM through -//! noodles). +//! - [`preset`] finds which mapper preset a run's reads need. +//! - [`batch`] is the memory control. +//! - [`index`] holds the `.mmi` cache and the part-by-part build. +//! - [`map`] does single-end mapping. It also does the cross-part merge, which makes a split index +//! give the same alignments as a whole one. +//! - [`pe`] does paired-end mapping, which is what `sr` and most vendor WGS need. +//! - [`output`] writes SAM, BAM and CRAM through noodles. //! -//! The entry point a job wants is [`index::ensure_cached_index`] followed by [`map::map_reads`] or -//! [`pe::map_pairs`]: the first resolves the cache location and sizes the index for the machine, -//! and the second two write BAM by default. +//! A job starts at [`index::ensure_cached_index`], then calls [`map::map_reads`] or +//! [`pe::map_pairs`]. The first resolves the cache location and sets the index size for the +//! machine. The other two write BAM by default. //! -//! What stage B does not do, by design, is sort or mark duplicates — those are stage C, and CRAM -//! belongs after the sort rather than here. +//! Stage B does not sort, and it does not mark duplicates. That is deliberate: those two steps are +//! stage C. CRAM belongs after the sort, and not here. pub mod batch; pub mod error; diff --git a/crates/navigator-align/src/map.rs b/crates/navigator-align/src/map.rs index aadf6e04..cf74ccc6 100644 --- a/crates/navigator-align/src/map.rs +++ b/crates/navigator-align/src/map.rs @@ -1,14 +1,15 @@ -//! Map reads against a cached index — the pass that turns reverted reads back into an alignment. +//! Map reads against a cached index. This is the pass that makes an alignment from reverted reads. //! //! ## The part-by-part problem //! -//! [`crate::index`] deliberately builds the index in parts so no single one has to fit in memory. -//! That buys the memory bound but creates an obligation here: a read must be mapped against -//! *every* part, and the per-part results merged, or it will be placed against whichever fraction -//! of the genome happened to be resident. Worse, MAPQ is a statement about how much better the -//! best hit is than the second best — a claim that is only meaningful genome-wide. Merging is -//! therefore not an optimization, it is what makes a split index produce the same answer as a -//! whole one. +//! [`crate::index`] builds the index in parts on purpose, so that no one part has to fit in +//! memory. That gives the memory limit, but it also makes an obligation here. The mapper must map +//! a read against *every* part, and the merge must join the results from each part. If it does +//! not, the read lands against whatever fraction of the genome was in memory. +//! +//! MAPQ is worse. It says how much better the best hit is than the second best. That claim +//! is true only over the whole genome. So the merge is not an optimization. It is what makes a +//! split index give the same answer as a whole one. //! //! So: //! @@ -18,26 +19,28 @@ //! part 2 ──map all reads──> part-2 hits ─┘ //! ``` //! -//! Each pass holds one part; the per-part hits go to scratch rather than memory. The reads are -//! streamed once per part, which is the same trade minimap2's own `--split-prefix` makes. +//! Each pass holds one part, and the hits for that part go to scratch and not to memory. The code +//! reads the reads one time for each part. That is the same trade that minimap2's own +//! `--split-prefix` makes. //! -//! The merge itself — re-ranking across parts and recomputing MAPQ — is -//! `minimap2::index::split::merge_split_query_records`, reused rather than reimplemented. That is -//! the subtlest arithmetic in the pipeline and the place an independent implementation would most -//! likely be quietly wrong. +//! The merge itself is `minimap2::index::split::merge_split_query_records`. It ranks the hits +//! again over all parts, and calculates MAPQ again. This module uses that function, and does not +//! write its own. That code is the most delicate arithmetic in the pipeline. A separate +//! implementation would most probably be wrong there, and give no sign of it. //! //! ## Why not the upstream file-level entry points //! -//! `minimap2-pure-rs` ships `map_file_sam_split` and friends, which look like exactly this. They -//! can not be used: they write to **stdout** (unusable from a desktop app) and they take -//! `parts: &[MmIdx]`, holding every part resident — giving up the entire memory bound this design -//! exists to buy. What is reused is the per-part record format and the merge; the loop is ours. +//! `minimap2-pure-rs` ships `map_file_sam_split` and the functions like it, which look exactly +//! like this. This module can not use them. They write to **stdout**, which a desktop app can not +//! use, and they take `parts: &[MmIdx]`, which keeps every part in memory. That gives up the whole +//! memory limit this design exists for. This module uses the record format for each part, and the +//! merge. The loop is its own. //! //! ## Scope //! -//! Single-end, which is what the long-read presets need. Paired-end — `sr`, and so most vendor -//! WGS — lives in [`crate::pe`], which reuses the part-by-part machinery here and adds fragment -//! mapping, pairing, and the mate-facing SAM fields. +//! Single-end, which is what the long-read presets need. Paired-end lives in [`crate::pe`], which +//! is `sr` and so most vendor WGS. That module uses the part-by-part code here, and adds fragment +//! mapping, pairing, and the SAM fields for the mate. use std::io::{BufReader, BufWriter, Write}; use std::path::{Path, PathBuf}; @@ -55,11 +58,12 @@ use crate::index::path_str; use crate::output::{AlignmentWriter, OutputFormat}; use crate::preset::Preset; -/// How often the read loop asks whether it has been cancelled — same reasoning as the analysis -/// walkers: often enough that a click feels immediate, rarely enough to stay off the profile. +/// How often the read loop asks whether the user cancelled. The reason is the same as for the +/// analysis walkers: often enough that a click feels immediate, and rarely enough to stay off the +/// profile. const CANCEL_CHECK_INTERVAL: u64 = 4096; -/// Tuning for [`map_reads`]. +/// Options for [`map_reads`]. #[derive(Debug, Clone)] pub struct MapParams { pub preset: Preset, @@ -67,7 +71,7 @@ pub struct MapParams { pub threads: usize, /// An `@RG` line to stamp into the header and onto each record, if the source had one. pub read_group: Option, - /// Output container. BAM by default — see [`crate::output`] for why not CRAM here. + /// Output container. BAM by default. See [`crate::output`] for why CRAM is not the default. pub format: OutputFormat, /// The reference FASTA, required only for CRAM output. pub reference: Option, @@ -105,27 +109,27 @@ pub struct MapStats { pub queries: u64, /// Reads with at least one alignment. pub mapped: u64, - /// Reads with none — written as unmapped SAM records, never dropped. + /// Reads with none. The writer gives them an unmapped SAM record, and never drops them. pub unmapped: u64, /// Index parts the reference was split into. More than one means the merge path ran. pub parts: usize, } -/// Cancellation, as a callback rather than a shared token type. +/// Cancellation, as a callback and not as a shared token type. /// -/// This crate is a leaf — it deliberately does not depend on `navigator-analysis`, so it can not -/// take that crate's `CancelToken` without inverting the layering. A closure lets the caller wire -/// whatever cancellation it already has, and costs this crate no dependency. +/// This crate is a leaf. It does not depend on `navigator-analysis`, and that is deliberate, so it +/// can not take that crate's `CancelToken`. That would invert the layers. A closure lets the +/// caller connect whatever cancellation it already has, and it costs this crate no dependency. pub type CancelFn<'a> = &'a dyn Fn() -> bool; -/// Progress: `(reads_done, parts_done, parts_total)`. `parts_total` is only known once the index -/// has been walked, so it is zero during the first pass. +/// Progress: `(reads_done, parts_done, parts_total)`. The code knows `parts_total` only after it +/// walks the index, so the value is zero during the first pass. pub type ProgressFn<'a> = &'a mut dyn FnMut(u64, usize, usize); /// Map `reads` against the index at `index_path`, writing SAM to `out`. /// -/// `scratch` holds the per-part intermediates when the index is split; it is cleaned up before -/// returning, on success or failure. +/// `scratch` holds the intermediates for each part when the index is split. This function removes +/// it before it returns, on success and on failure. pub fn map_reads( index_path: &Path, reads: &Path, @@ -143,20 +147,21 @@ pub fn map_reads( let (_idx_opt, mut map_opt) = minimap2::prelude::preset(params.preset.as_str()) .map_err(|e| AlignError::Message(format!("preset {}: {e}", params.preset.as_str())))?; - // What `-ax ` sets, and both flags are load-bearing rather than cosmetic. + // What `-ax ` sets. Both flags do real work, and neither is cosmetic. // - // `CIGAR` is what runs base-level alignment. Without it a mapping stops at chaining, so records - // carry coordinates but no CIGAR — and, less obviously, `map_query` skips the block that - // assigns primary/secondary status, leaving every region with `sam_pri` unset so that *every* - // record is emitted flagged supplementary (0x800). The split path hid this, because the merge - // re-runs that ranking unconditionally; only the whole-index fast path was affected. + // `CIGAR` is what runs base-level alignment. Without it a mapping stops at chaining, so a + // record carries coordinates but no CIGAR. Less obviously, `map_query` then skips the block + // that assigns primary status and secondary status. Every region keeps `sam_pri` unset, so + // *every* record comes out with the supplementary flag (0x800). The split path hid this, + // because the merge always ranks the hits again. Only the whole-index fast path had the + // fault. map_opt.flag |= MapFlags::OUT_SAM | MapFlags::CIGAR; let mut reader = open_index(index_path, params.preset)?; - // Read the first part, then ask whether that was all of it. Knowing this up front matters: on - // a machine large enough to hold a whole index the split machinery is pure overhead, and the - // reads would be streamed twice for nothing. + // Read the first part, then ask whether that was all of it. The answer matters at this + // point. On a machine large enough to hold a whole index, the split code is only overhead. + // The code would then read the reads two times for nothing. let Some(first) = reader.read_next().map_err(|e| AlignError::io(index_path, e))? else { return Err(AlignError::Message(format!( "{} contains no index parts", @@ -182,8 +187,8 @@ pub fn map_reads( ) } -/// Open the cached `.mmi`. `is_idx = true` — this is a prebuilt index, not a FASTA to sketch, so -/// the sketching parameters are read back from the file rather than supplied. +/// Open the cached `.mmi`. `is_idx = true`, because this is an index that already exists, and not +/// a FASTA to sketch. So the sketch parameters come back from the file, and no caller gives them. pub(crate) fn open_index(index_path: &Path, preset: Preset) -> Result { let (idx_opt, _) = minimap2::prelude::preset(preset.as_str()) .map_err(|e| AlignError::Message(format!("preset {}: {e}", preset.as_str())))?; @@ -254,12 +259,12 @@ fn map_single_part( Ok(stats) } -/// Map a batch across the pool, **preserving input order**. +/// Map a batch across the pool, and **keep the input order**. /// -/// Order is not cosmetic. The split path joins each part's hits to a read by position in the file, -/// so a reordered batch would silently attach one read's hits to another — the kind of corruption -/// that produces plausible alignments at wrong loci. `par_iter().collect()` preserves order, which -/// is why results are collected rather than written as they finish. +/// Order is not cosmetic. The split path joins the hits of each part to a read by position in the +/// file. A batch in a different order would attach the hits of one read to another read, with no +/// warning. That corruption gives plausible alignments at wrong loci. `par_iter().collect()` keeps +/// the order, and that is why this collects the results instead of a write as each one ends. fn map_batch( pool: &rayon::ThreadPool, index: &MmIdx, @@ -277,8 +282,8 @@ fn map_batch( /// The mapping options a preset implies, with the flags SAM output requires. /// -/// `CIGAR` is load-bearing: without it `map_query` stops at chaining, emitting records with no -/// CIGAR *and* skipping the step that assigns primary/secondary status. +/// `CIGAR` does real work here. Without it `map_query` stops at chaining. It then emits records +/// with no CIGAR, *and* it skips the step that assigns primary status and secondary status. pub(crate) fn prepared_map_opt(preset: Preset) -> Result { let (_idx, mut opt) = minimap2::prelude::preset(preset.as_str()) .map_err(|e| AlignError::Message(format!("preset {}: {e}", preset.as_str())))?; @@ -291,8 +296,8 @@ pub(crate) fn path_str_of(path: &Path) -> Result { crate::index::path_str(path) } -/// Mapping is the pipeline's dominant cost and is per-read independent, so it gets a pool sized to -/// the machine (or to `NAVIGATOR_ALIGN_THREADS`). +/// Mapping is the largest cost in the pipeline, and the work for each read is independent. So it +/// gets a pool that fits the machine, or that `NAVIGATOR_ALIGN_THREADS` sets. pub(crate) fn thread_pool(params: &MapParams) -> Result { rayon::ThreadPoolBuilder::new() .num_threads(params.thread_count()) @@ -302,7 +307,7 @@ pub(crate) fn thread_pool(params: &MapParams) -> Result, ) -> Result { let prefix = path_str(&scratch.join("part"))?; - // Every intermediate is removed before returning, whatever happens — these are per-read hit - // blocks for a whole WGS and would otherwise be left behind at genome scale. + // This removes every intermediate before it returns, whatever happens. These are hit blocks + // for each read of a whole WGS, and they would otherwise stay on disk at genome scale. let cleanup = ScratchGuard { prefix: prefix.clone(), parts: 0, }; let mut cleanup = cleanup; - // Header-only accumulation of every part's sequences. This is the one thing that must span all - // parts, and it is safe to: names and lengths for a few hundred contigs, not index data. + // A header-only collection of the sequences of every part. This is the one thing that must + // cover all parts, and that is safe. It holds names and lengths for a few hundred contigs, + // and no index data. let mut merged_header = header_only(&first); let mut part_opts = vec![part_opt(map_opt, &first)]; let mut rid_shifts = vec![0u32]; @@ -335,15 +341,15 @@ fn map_split( let mut part = first; let mut parts = 0usize; loop { - // Every part sees the same reads, so this is the same number each pass — reported for - // progress, not accumulated. + // Every part sees the same reads, so this is the same number in each pass. It goes to + // progress, and nothing adds it up. let queries_seen = write_part_hits(&prefix, parts, &part, &part_opts[parts], reads, &pool, cancel)?; parts += 1; cleanup.parts = parts; progress(queries_seen, parts, 0); - // Drop this part before pulling the next: this is the line that keeps peak memory at one - // part rather than the whole index. + // Drop this part before the next one arrives. This is the line that keeps peak memory at + // one part, and not at the whole index. drop(part); let Some(next) = reader.read_next().map_err(|e| AlignError::io(index_path, e))? else { @@ -370,9 +376,9 @@ fn map_split( Ok(stats) } -/// One pass over the reads against one part, appending each read's hits to that part's scratch -/// file. Read order is the join key for the merge, so the file is positional: record *n* here is -/// read *n* of the input, in every part. +/// One pass over the reads against one part. It adds the hits of each read to the scratch file of +/// that part. Read order is the join key for the merge, so the file is positional. Record *n* +/// here holds the hits of read *n* of the input, in every part. fn write_part_hits( prefix: &str, part_index: usize, @@ -400,7 +406,7 @@ fn write_part_hits( return Err(AlignError::Cancelled); } - // Order-preserving, and it must be: this file is joined to the reads positionally. + // This keeps the order, and it must: the merge joins this file to the reads by position. for result in map_batch(pool, part, opt, &batch) { let block = split::SplitQueryRecord { n_reg: result.regs.len() as i32, @@ -416,7 +422,7 @@ fn write_part_hits( Ok(seen) } -/// Read one hit block per part per read, merge them, and emit SAM. +/// Read one hit block for each part and each read, merge them, and emit SAM. #[allow(clippy::too_many_arguments)] fn merge_parts( prefix: &str, @@ -439,7 +445,7 @@ fn merge_parts( let path = split::split_tmp_path(prefix, part); let file = std::fs::File::open(&path).map_err(|e| AlignError::io(&path, e))?; let mut r = BufReader::with_capacity(1 << 20, file); - // Step past the header this part's writer stamped, leaving the reader on record 0. + // Step past the header that the writer of this part wrote. The reader is then on record 0. split::read_split_header(&mut r).map_err(|e| AlignError::io(&path, e))?; part_readers.push((path, r)); } @@ -488,8 +494,8 @@ fn merge_parts( /// Write one read's SAM records: the primary (or an unmapped record) plus any supplementaries. /// -/// An unmapped read gets a record rather than silence. Realignment exists partly to find reads the -/// old reference could not place, so which reads failed *here* is information, not noise. +/// An unmapped read gets a record, and not silence. Realignment exists in part to find reads that +/// the old reference could not place. So which reads failed *here* is information, and not noise. #[allow(clippy::too_many_arguments)] fn emit( writer: &mut AlignmentWriter, @@ -545,8 +551,9 @@ pub(crate) fn open_output(out: &Path, index: &MmIdx, params: &MapParams) -> Resu } pub(crate) fn part_opt(base: &MapOpt, part: &MmIdx) -> MapOpt { - // Per-part thresholds: `mapopt_update` derives occurrence cutoffs from the index's own - // statistics, so a part must be scored against its own, not the whole reference's. + // Thresholds for each part: `mapopt_update` derives occurrence cutoffs from the statistics of + // the index itself. So a part must score against its own statistics, and not against those of + // the whole reference. let mut opt = base.clone(); mapopt_update(&mut opt, part); opt @@ -554,9 +561,9 @@ pub(crate) fn part_opt(base: &MapOpt, part: &MmIdx) -> MapOpt { /// A metadata-only copy of an index part: sequence names and lengths, no minimizers. /// -/// SAM needs `RNAME` and `@SQ` for every contig across every part, and after the merge a region's -/// `rid` indexes that concatenation. Carrying names and lengths for a few hundred contigs costs -/// nothing; carrying the parts themselves would undo the whole design. +/// SAM needs `RNAME` and `@SQ` for every contig in every part, and after the merge the `rid` of a +/// region indexes that concatenation. Names and lengths for a few hundred contigs cost nothing to +/// keep. To keep the parts themselves would undo the whole design. pub(crate) fn header_only(part: &MmIdx) -> MmIdx { let mut header = MmIdx::new(part.w, part.k, part.bucket_bits, IdxFlags::empty()); append_header(&mut header, part); @@ -583,7 +590,7 @@ fn check_cancel(seen: u64, cancel: CancelFn<'_>) -> Result<(), AlignError> { Ok(()) } -/// Removes the per-part scratch on the way out, including on an error or a cancel. +/// Removes the scratch of each part on the way out, and also on an error or a cancel. pub(crate) struct ScratchGuard { prefix: String, pub(crate) parts: usize, diff --git a/crates/navigator-align/src/map/tests.rs b/crates/navigator-align/src/map/tests.rs index 385a0f18..cf58105f 100644 --- a/crates/navigator-align/src/map/tests.rs +++ b/crates/navigator-align/src/map/tests.rs @@ -1,8 +1,8 @@ //! Tests for the mapping pass. //! //! The one that matters is [`a_split_index_places_reads_exactly_where_a_whole_index_does`]. Every -//! other property here is ordinary; that one is the design's central claim, and if it fails the -//! memory bound that justifies this whole module is not free after all. +//! other property here is ordinary. That one is the design's central claim. If it fails, the +//! memory limit that gives this whole module its reason is not free after all. use std::path::PathBuf; @@ -17,9 +17,9 @@ fn scratch(tag: &str) -> PathBuf { dir } -/// A deterministic pseudo-random reference. Real-ish base composition matters: a low-complexity -/// sequence collapses into a few minimizer buckets and maps ambiguously everywhere, which would -/// make these tests measure the fixture rather than the code. +/// A deterministic pseudo-random reference. The base composition must be near to real. A +/// low-complexity sequence collapses into a few minimizer buckets and maps ambiguously +/// everywhere. These tests would then measure the fixture, and not the code. fn reference_bases(contig: usize, len: usize) -> Vec { let mut state = 0x9E3779B97F4A7C15u64 ^ (contig as u64).wrapping_mul(0xD1B54A32D192ED03); (0..len) @@ -46,7 +46,8 @@ fn write_reference(dir: &Path, contigs: usize, len: usize) -> PathBuf { path } -/// Reads lifted straight out of the reference, so the true origin of each is known from its name. +/// These reads come straight out of the reference, so the name of each read gives its true +/// origin. fn write_reads(dir: &Path, contigs: usize, len: usize, per_contig: usize, read_len: usize) -> PathBuf { let path = dir.join("reads.fq"); let mut text = Vec::new(); @@ -118,13 +119,13 @@ fn mapq(sam: &Path) -> Vec<(String, String)> { // ---- the claim ------------------------------------------------------------ -/// **The central property.** Splitting the index is a memory optimization; it must not be an -/// accuracy decision. A read mapped against a 3-part index has to land exactly where it lands -/// against a whole one — same contig, same position, same flags — because the per-part hits are -/// merged and re-ranked before anything is emitted. +/// **The central property.** A split index is a memory optimization. It must not be an accuracy +/// decision. A read that maps against a 3-part index must land exactly where it lands against a +/// whole index. The contig, the position and the flags must all be the same. The merge collects +/// the hits from each part and ranks them again before the writer emits anything. /// -/// If this ever fails, the memory table in `crate::batch` stops being free and the design has to -/// be re-argued. +/// If this ever fails, the memory table in `crate::batch` is no longer free, and the design needs +/// a new argument. #[test] fn a_split_index_places_reads_exactly_where_a_whole_index_does() { let dir = scratch("equivalence"); @@ -145,7 +146,7 @@ fn a_split_index_places_reads_exactly_where_a_whole_index_does() { let whole_sam = dir.join("whole.sam"); let whole_stats = run(&whole, &reads, &whole_sam, &dir, Preset::ShortRead); - // Split index: several parts, exercising the write-merge path. + // Split index: more than one part, so this uses the write-merge path. let split_idx = dir.join("split.mmi"); build_index( &reference, @@ -172,10 +173,10 @@ fn a_split_index_places_reads_exactly_where_a_whole_index_does() { ); } -/// MAPQ is the part of the answer a split index is *allowed* to differ on — a read's second-best -/// hit can fall in another part — but the merge re-runs the MAPQ calculation across all parts -/// precisely so it does not. On a reference with no duplicated sequence there is nothing to be -/// ambiguous about, so it must agree exactly. +/// A split index may differ on MAPQ, and only on MAPQ, because a read's second-best hit can fall +/// in another part. But the merge does the MAPQ calculation again over all parts, and that is why +/// it does not differ. This reference has no duplicated sequence, so nothing is ambiguous, and +/// MAPQ must agree exactly. #[test] fn merging_restores_mapq_across_parts() { let dir = scratch("mapq"); @@ -212,8 +213,8 @@ fn merging_restores_mapq_across_parts() { // ---- ordinary properties -------------------------------------------------- -/// Reads were lifted out of the reference, so they must come back to the contig they came from. -/// Without this the equivalence test above could pass with both sides equally wrong. +/// These reads come out of the reference, so they must come back to the contig they came from. +/// Without this check, the equivalence test above could pass with both sides equally wrong. #[test] fn reads_map_back_to_where_they_came_from() { let dir = scratch("placement"); @@ -252,13 +253,14 @@ fn reads_map_back_to_where_they_came_from() { } } -/// A SAM record without a CIGAR is not an alignment, it is a coordinate guess — and nothing -/// downstream (coverage, callable, SV, the variant caller) can consume it. +/// A SAM record without a CIGAR is not an alignment. It is a coordinate guess, and nothing +/// downstream can read it: not coverage, not callable, not SV, and not the variant caller. /// -/// This exists because its absence was invisible: the placement and equivalence tests all passed -/// while every record carried `*`, since chaining alone yields coordinates. It also pins the -/// primary/supplementary flag, which failed the same way and for the same underlying reason — -/// without base-level alignment, `map_query` never ranks the regions it returns. +/// This test exists because the absence of the CIGAR was invisible. The placement tests and the +/// equivalence tests all passed while every record carried `*`, because chaining alone gives +/// coordinates. The test also pins the primary flag and the supplementary flag. Those failed the +/// same way and for the same reason: without base-level alignment, `map_query` never ranks the +/// regions it returns. #[test] fn records_carry_a_real_cigar_and_a_primary_flag() { let dir = scratch("cigar"); @@ -300,8 +302,9 @@ fn records_carry_a_real_cigar_and_a_primary_flag() { assert_eq!(primaries, 20, "every read should have exactly one primary record"); } -/// The SAM has to be readable by everything downstream, which starts with a header naming every -/// contig — including, in the split case, contigs from parts that were never resident together. +/// Everything downstream must be able to read the SAM. That starts with a header that names +/// every contig. In the split case, that includes contigs from parts that were never in memory +/// together. #[test] fn the_header_names_every_contig_across_every_part() { let dir = scratch("header"); @@ -333,8 +336,9 @@ fn the_header_names_every_contig_across_every_part() { assert!(text.contains("@PG"), "a realigned header must carry a @PG record"); } -/// An unmappable read gets a record rather than vanishing. Which reads failed is information — -/// realignment exists partly to recover reads a previous reference could not place. +/// An unmappable read still gets a record, and does not disappear. Which reads failed is +/// information: realignment exists in part to recover reads that a previous reference could not +/// place. #[test] fn unmappable_reads_are_written_as_unmapped_records() { let dir = scratch("unmapped"); @@ -368,8 +372,8 @@ fn unmappable_reads_are_written_as_unmapped_records() { assert_eq!(records[0].3, "4", "SAM flag 4 == unmapped"); } -/// Per-part hit blocks are one file per part for the whole read set — at genome scale that is -/// large, so it must not survive the call. +/// The hit blocks are one file for each part, and they hold the whole read set. At genome scale +/// that is large, so it must not survive the call. #[test] fn split_scratch_is_cleaned_up() { let dir = scratch("cleanup"); @@ -400,8 +404,8 @@ fn split_scratch_is_cleaned_up() { assert!(leftovers.is_empty(), "scratch left behind: {leftovers:?}"); } -/// A multi-hour job has to stop when asked, and report the stop as itself rather than as a -/// failure. +/// A multi-hour job must stop when the user asks. It must report the stop as a stop, and not as +/// a failure. #[test] fn cancellation_stops_the_mapping_pass() { let dir = scratch("cancel"); @@ -439,8 +443,8 @@ fn cancellation_stops_the_mapping_pass() { assert!(matches!(err, AlignError::Cancelled), "got {err:?}"); } -/// An index file with nothing in it must fail loudly rather than produce an empty, valid-looking -/// SAM that a later stage would treat as "this sample simply has no reads". +/// An index file with nothing in it must fail with a loud message. It must not make an empty SAM +/// that looks correct, which a later stage would read as "this sample has no reads". #[test] fn an_empty_index_is_an_error() { let dir = scratch("emptyidx"); @@ -462,9 +466,9 @@ fn an_empty_index_is_an_error() { // ---- output containers ---------------------------------------------------- -/// BAM is the default container, and it has to hold exactly what SAM did. Read back through -/// noodles as *typed* records, so this asserts on fields rather than on column positions — the -/// same reason the writer exists. +/// BAM is the default container, and it must hold exactly what SAM held. This test reads the BAM +/// back through noodles as *typed* records. So it asserts on fields, and not on column positions. +/// That is also the reason the writer exists. #[test] fn bam_output_round_trips_the_same_records_as_sam() { let dir = scratch("bam"); @@ -534,8 +538,8 @@ fn bam_output_round_trips_the_same_records_as_sam() { } } -/// A BAM whose BGZF end-of-file block is missing reads as truncated. Writing it is the writer's -/// job on `finish`, and nothing else in the test suite would notice if it stopped happening. +/// A BAM whose BGZF end-of-file block is missing reads as truncated. The writer writes that +/// block on `finish`, and nothing else in the test suite would see it if the writer stopped. #[test] fn bam_output_is_a_complete_bgzf_stream() { let dir = scratch("bgzf"); @@ -590,8 +594,8 @@ fn the_output_format_can_be_read_off_the_path() { assert_eq!(F::from_path(Path::new("x")), F::Bam, "BAM is the default"); } -/// CRAM can not be written without the reference it is compressed against, and saying so up front -/// beats failing partway through a multi-hour job. +/// A CRAM writer needs the reference that the compression uses. It must say so at the start, and +/// must not fail part of the way through a multi-hour job. #[test] fn cram_without_a_reference_is_refused_before_any_work() { let dir = scratch("cramref"); diff --git a/crates/navigator-align/src/output.rs b/crates/navigator-align/src/output.rs index 0d8f18da..273d0a2a 100644 --- a/crates/navigator-align/src/output.rs +++ b/crates/navigator-align/src/output.rs @@ -1,30 +1,30 @@ -//! Alignment output — SAM, BAM, or CRAM, through noodles. +//! Alignment output: SAM, BAM, or CRAM, through noodles. //! //! ## Why this sits between the mapper and the file //! -//! `minimap2-pure-rs` formats records as SAM *text* and nothing else. Two problems follow. Every -//! downstream stage in Navigator reads BAM/CRAM through noodles, so SAM text would have to be -//! converted somewhere anyway; and the paired fields the mapper does not fill in were being -//! patched into that text by column position, which is fragile in a way that fails silently if the -//! formatter's layout ever shifts. +//! `minimap2-pure-rs` writes records as SAM *text* and nothing else. Two problems come from that. +//! First, every downstream stage in Navigator reads BAM or CRAM through noodles, so something must +//! convert the SAM text anyway. Second, the code patched the paired fields that the mapper does +//! not fill into that text by column position. That is fragile, and it fails with no warning if +//! the layout of the formatter ever moves. //! -//! So each record makes one hop through a type: the mapper's line is parsed into a -//! [`RecordBuf`], the paired fields are set on the *typed* record, and noodles writes it. The -//! parse costs something, but the mapper's own formatting already allocates a string per record, -//! and both are noise next to alignment itself — mapping a WGS is hours, serializing it is -//! minutes. What it buys is that `RNEXT` can no longer be written into the `TLEN` column. +//! So each record goes through a type one time. The code parses the mapper's line into a +//! [`RecordBuf`], sets the paired fields on the *typed* record, and lets noodles write it. The +//! parse has a cost. But the mapper already allocates a string for each record when it makes the +//! SAM text. Both costs are small next to alignment itself: a WGS takes hours to map, and minutes +//! to write. The gain is that `RNEXT` can no longer go into the `TLEN` column. //! -//! The alternative — building records from `AlignReg` directly and skipping SAM text — was -//! rejected on purpose. It would mean reimplementing CIGAR emission, clipping, `SEQ` orientation, -//! and every tag, which is the delicate part of the mapper's output and exactly the code most -//! worth *not* rewriting. +//! There is an alternative: build records from `AlignReg` directly, and do not make SAM text at +//! all. This module refuses that on purpose. It would need a new implementation of CIGAR emission, +//! clipping, `SEQ` orientation, and every tag. That is the delicate part of the mapper's output, +//! and it is the code this module most wants to leave alone. //! //! ## Which format //! -//! **BAM is the right choice for this stage.** The mapper emits reads in input order, and CRAM's -//! compression assumes coordinate-sorted, reference-adjacent records — writing it here would be -//! both slow and large. CRAM belongs after the sort in stage C. It is supported anyway because -//! the cost is one enum arm and callers past the sort will want it. +//! **BAM is the right choice for this stage.** The mapper emits reads in input order. CRAM +//! compression expects records in coordinate order, and near to the reference. CRAM here would be +//! both slow and large. CRAM belongs after the sort, in stage C. This module still has a CRAM arm, +//! because the cost is one enum arm, and a caller after the sort will want it. use std::io::{BufWriter, Write}; use std::path::Path; @@ -38,26 +38,26 @@ use crate::error::AlignError; /// Write buffer under the container encoders. /// -/// BGZF hands down ~64 KB blocks, so `BufWriter`'s 8 KB default coalesced nothing at all: this -/// stage's output is the largest file the pipeline produces and it was reaching the disk in -/// block-sized dribs. Matches the post-processing writers. +/// BGZF hands down ~64 KB blocks, so the 8 KB default of `BufWriter` combined nothing at all. +/// This stage writes the largest file in the pipeline, and it went to the disk in pieces the size +/// of one block. This matches the writers in post-processing. const WRITE_BUFFER: usize = 1 << 20; /// On-disk container for the mapper's output. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum OutputFormat { - /// Uncompressed SAM text. Useful for tests and eyeballing; wasteful for a real run. + /// Uncompressed SAM text. Useful for tests and for a look by a person. Wasteful for a real run. Sam, /// The default, and what stage C expects to sort. #[default] Bam, - /// Reference-compressed. Needs `reference`, and really wants coordinate-sorted input, so it - /// belongs after the sort rather than here. + /// Reference-compressed. Needs `reference`, and needs input in coordinate order, so it + /// belongs after the sort and not here. Cram, } impl OutputFormat { - /// Guess from the file extension, defaulting to BAM. + /// Guess from the file extension. The default is BAM. pub fn from_path(path: &Path) -> Self { match path.extension().and_then(|e| e.to_str()) { Some(e) if e.eq_ignore_ascii_case("sam") => OutputFormat::Sam, @@ -73,16 +73,18 @@ pub struct AlignmentWriter { inner: Inner, } -/// Every arm writes through a [`PacedFile`], and that is not incidental. +/// Every arm writes through a [`PacedFile`], and that is not an accident. /// -/// This stage produces the pipeline's largest file — ~60 GB of `mapped.bam` for a 30x WGS — as fast -/// as sixteen cores can compress it, and left to itself that goes into the page cache and becomes -/// the operating system's problem to write back. On macOS it became everyone's problem: a -/// realignment dirtied 549 GB of file-backed memory, exceeded the sustained write-back limit by -/// 1.4x, and WindowServer's watchdog took the login session down with the job. Pacing caps what can -/// be outstanding; the accounting is what makes the stage visible to -/// [`navigator_resource::ResourceWatch`] at all, which until now reported `0 MB/s` through the -/// longest stage in the job because the only writer it has was unwrapped. +/// This stage makes the largest file in the pipeline: about 60 GB of `mapped.bam` for a 30x WGS. +/// It writes as fast as sixteen cores can compress it. With no control, all of that goes into the +/// page cache, and the write-back becomes the problem of the operating system. On macOS it became +/// the problem of everybody. One realignment made 549 GB of file-backed memory dirty. It went 1.4x +/// above the sustained write-back limit, and the watchdog of WindowServer took the login session +/// down with the job. +/// +/// The pacing puts a limit on how many bytes can wait. The count is what makes the stage visible +/// to [`navigator_resource::ResourceWatch`] at all. Until now that watch reported `0 MB/s` through +/// the longest stage in the job, because nothing wrapped the one writer it has. enum Inner { Sam(sam::io::Writer>), Bam(bam::io::Writer>>), @@ -92,9 +94,9 @@ enum Inner { impl AlignmentWriter { /// Open `path` and write the header. /// - /// `header_text` is the mapper's `@HD`/`@SQ`/`@RG`/`@PG` block, parsed here so the typed - /// header can be handed to the encoders — BAM stores reference names as indices into it, so - /// this is not merely decorative. + /// `header_text` is the mapper's `@HD`/`@SQ`/`@RG`/`@PG` block. This function parses it, so + /// that the encoders get a typed header. BAM stores reference names as indices into that + /// header, so the parse is not only decoration. pub fn create( path: &Path, format: OutputFormat, @@ -110,10 +112,11 @@ impl AlignmentWriter { Inner::Sam(w) } OutputFormat::Bam => { - // Threaded BGZF, because this is where the mapping stage's wall clock went. A - // profile of the stage attributes ~60% of the serial phase to zlib deflate — - // `longest_match` alone is a third of it — while sixteen cores wait for the next - // batch. Block compression parallelizes; the byte stream is unchanged. + // Threaded BGZF, because this is where the wall clock of the mapping stage went. + // A profile of the stage gives ~60% of the serial phase to zlib deflate, and + // `longest_match` alone is a third of that. Sixteen cores wait for the next batch + // while this happens. Block compression runs in parallel, and the byte stream does + // not change. let inner = bgzf::io::MultithreadedWriter::with_worker_count(bgzf_worker_count(), paced(path)?); let mut w = bam::io::Writer::from(inner); w.write_header(&header).map_err(|e| AlignError::io(path, e))?; @@ -124,8 +127,9 @@ impl AlignmentWriter { AlignError::Message("CRAM output needs the reference FASTA it will be compressed against".into()) })?; let repository = fasta_repository(reference)?; - // `build_from_writer`, not `build_from_path`: the latter opens the file itself, and - // an encoder holding its own raw `File` is exactly the writer that goes uncounted. + // `build_from_writer`, not `build_from_path`. The second one opens the file + // itself, and an encoder that holds its own raw `File` is exactly the writer that + // no counter sees. let mut w = cram::io::writer::Builder::default() .set_reference_sequence_repository(repository) .build_from_writer(paced(path)?); @@ -143,8 +147,8 @@ impl AlignmentWriter { /// Parse one SAM line from the mapper and hand it to `edit` before writing. /// - /// `edit` is where paired fields get set. It sees a typed record, so it can not write a value - /// into the wrong column — which was the entire failure mode this module removes. + /// `edit` is where the code sets the paired fields. It sees a typed record, so it can not + /// write a value into the wrong column. That was the whole failure mode this module removes. pub fn write_line_with( &mut self, line: &str, @@ -166,21 +170,21 @@ impl AlignmentWriter { .map_err(|e| AlignError::io(path, e)) } - /// Flush and close. CRAM in particular must be finished explicitly — its final container is - /// only written on shutdown, so a dropped writer yields a truncated file. + /// Flush and close. CRAM above all needs an explicit finish. It writes its final container + /// only on shutdown, so a writer that drops leaves a truncated file. /// - /// Each arm then syncs, which matters more here than it looks. A resumed realignment decides - /// whether it can pick this file up by checking for the BGZF end-of-file block on the end of it - /// (`navigator_analysis::postprocess::bamio::is_complete_bam`), and a marker still sitting in - /// the page cache is a promise the disk has not made. Getting that wrong once already cost a - /// 59 GB intermediate: a truncated file that looked complete was resumed past and the real one - /// deleted. + /// Each arm then syncs, which matters more here than it looks. A realignment that resumes + /// looks for the BGZF end-of-file block at the end of this file + /// (`navigator_analysis::postprocess::bamio::is_complete_bam`). That is how it decides whether + /// it can use the file. A marker that is still in the page cache is a promise the disk has not + /// made. This was wrong one time, and it cost a 59 GB intermediate. A truncated file looked + /// complete, the resume went past it, and the code deleted the real one. pub fn finish(self, path: &Path) -> Result<(), AlignError> { match self.inner { Inner::Sam(mut w) => sync(w.get_mut(), path), - // BAM is BGZF, which ends with a specific empty block. Flushing alone leaves the file - // without it, and readers treat that as truncated. On the threaded writer that means - // draining the workers, which is what `finish` does. + // BAM is BGZF, which ends with a specific empty block. A flush alone leaves the file + // without it, and readers treat that as truncated. On the threaded writer the code + // must also empty the workers, which is what `finish` does. Inner::Bam(mut w) => { let mut buffered = w.get_mut().finish().map_err(|e| AlignError::io(path, e))?; sync(&mut buffered, path) @@ -201,9 +205,9 @@ fn sync(buffered: &mut BufWriter, path: &Path) -> Result<(), AlignErr /// Worker threads for BGZF block compression. /// -/// Compression is the mapping stage's serial bottleneck, so this wants more workers than the -/// read-side default: the consumer here is the mapper's pool, not a single parsing thread. Shares -/// `NAVIGATOR_ALIGN_THREADS` with the mapper so one knob still controls the stage. +/// Compression is what makes the mapping stage serial, so this wants more workers than the +/// read-side default. The consumer here is the pool of the mapper, and not one thread that parses. +/// This shares `NAVIGATOR_ALIGN_THREADS` with the mapper, so one control still sets the stage. fn bgzf_worker_count() -> std::num::NonZeroUsize { let n = std::env::var("NAVIGATOR_ALIGN_THREADS") .ok() @@ -213,7 +217,7 @@ fn bgzf_worker_count() -> std::num::NonZeroUsize { std::num::NonZeroUsize::new(n.clamp(1, 8)).expect("clamped above zero") } -/// Create `path` — parents included — behind a buffer and the write pacer. +/// Create `path`, and its parent directories, behind a buffer and the write pacer. fn paced(path: &Path) -> Result, AlignError> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| AlignError::io(parent, e))?; @@ -232,8 +236,8 @@ fn fasta_repository(reference: &Path) -> Result { } fn parse_header(text: &str) -> Result { - // The mapper hands back the block without a trailing newline; the parser wants line-terminated - // input, and an unterminated last line is silently dropped. + // The mapper hands back the block with no newline at the end. The parser needs each line to + // end with a newline, and it drops a last line that does not, with no warning. let mut owned = text.to_string(); if !owned.ends_with('\n') { owned.push('\n'); @@ -246,17 +250,18 @@ fn parse_header(text: &str) -> Result { /// Parse one of the mapper's SAM lines into a typed record. /// -/// Public to the crate so records can be built *off* the writing thread. Formatting an alignment -/// to SAM text and parsing it back is per-record independent work, and at WGS scale it dominates -/// the mapping stage — see the pipeline note on `pe::map_pairs_single_part`. +/// This is public to the crate, so that the code can make records *off* the write thread. To make +/// SAM text from an alignment, and to parse it back, is independent work for each record. At WGS +/// scale it is the largest part of the mapping stage. See the pipeline note on +/// `pe::map_pairs_single_part`. pub(crate) fn parse_record(line: &str, header: &sam::Header, path: &Path) -> Result { let raw = sam::Record::try_from(line.as_bytes()) .map_err(|e| AlignError::Message(format!("could not parse a SAM record: {e}")))?; RecordBuf::try_from_alignment_record(header, &raw).map_err(|e| AlignError::io(path, e)) } -/// Read every record from a SAM file. Test-facing: it lets a test assert on typed fields rather -/// than on column positions, which is the same reason the writer exists. +/// Read every record from a SAM file. This is for tests: it lets a test assert on typed fields, +/// and not on column positions. That is the same reason the writer exists. pub fn read_all(path: &Path) -> Result<(sam::Header, Vec), AlignError> { let file = std::fs::File::open(path).map_err(|e| AlignError::io(path, e))?; let mut reader = sam::io::Reader::new(std::io::BufReader::new(file)); @@ -297,11 +302,12 @@ mod tests { /// The regression this crate's dependency on `navigator-resource` exists for. /// - /// The mapping stage writes the largest file in the pipeline, and for the whole of its first - /// WGS run it wrote that file through a bare `File` — so the resource watch, which reports what - /// the pipeline is doing to the machine, logged `0 MB/s` for hours while ~60 GB went to disk. - /// The counter is process-global precisely so that a writer in *this* crate lands in the same - /// total as the sort's, and the only way to keep that true is to assert it from here. + /// The mapping stage writes the largest file in the pipeline. For the whole of its first WGS + /// run it wrote that file through a bare `File`. So the resource watch, which reports what the + /// pipeline does to the machine, logged `0 MB/s` for hours while ~60 GB went to disk. + /// + /// The counter is process-global, so that a writer in *this* crate lands in the same total as + /// the writer of the sort. The only way to keep that true is to assert it from here. #[test] fn the_mappers_output_reaches_the_shared_byte_counter() { let dir = scratch("counted"); @@ -312,8 +318,8 @@ mod tests { writer.write_line_with(RECORD, &path, |_, _| {}).unwrap(); writer.finish(&path).unwrap(); - // Strictly greater, not an exact figure: the counter is shared with anything else running - // in this binary, so the claim under test is that these bytes were counted at all. + // Strictly greater, and not an exact number. Anything else in this binary shares the + // counter, so the claim under test is only that a counter saw these bytes. assert!( navigator_resource::bytes_written() > before, "the mapper's BAM output was not accounted for" diff --git a/crates/navigator-align/src/pe.rs b/crates/navigator-align/src/pe.rs index a73fb61a..396d9822 100644 --- a/crates/navigator-align/src/pe.rs +++ b/crates/navigator-align/src/pe.rs @@ -1,29 +1,31 @@ -//! Paired-end mapping — the `sr` path, and so most vendor WGS. +//! Paired-end mapping: the `sr` path, and so most vendor WGS. //! -//! A pair is not two independent reads. Mapping them together lets a confidently-placed mate -//! rescue an ambiguous one, and the fragment's expected span is evidence about where the second -//! end belongs; both feed MAPQ. So the two ends are mapped as one *fragment* -//! ([`minimap2::map::map_frag_queries`]) and then paired ([`minimap2::pe::pair`]), which is what -//! sets `proper_frag`, adjusts MAPQ, and decides which region of each end is the primary. +//! A pair is not two independent reads. Mapping them together lets a mate with a confident +//! position rescue a mate that is ambiguous. The expected span of the fragment is also evidence +//! about where the second end belongs. Both of those feed MAPQ. +//! +//! So the mapper maps the two ends as one *fragment* ([`minimap2::map::map_frag_queries`]), and +//! then pairs them ([`minimap2::pe::pair`]). That step sets `proper_frag`, adjusts MAPQ, and +//! decides which region of each end is the primary. //! //! ## What this module has to build itself //! -//! The pieces above are public in `minimap2-pure-rs`. Its **PE SAM formatting is not** — that -//! lives in private `pipeline.rs` helpers, so the mate-facing half of each record is assembled -//! here: the paired flags, `RNEXT`/`PNEXT`, and `TLEN`. +//! The pieces above are public in `minimap2-pure-rs`. **The code that makes its PE SAM text is +//! not.** That code lives in private `pipeline.rs` helpers. So this module builds the mate half of +//! each record itself: the paired flags, `RNEXT`/`PNEXT`, and `TLEN`. //! -//! The approach mirrors upstream's: format the single-end line with the public writer (which -//! already knows CIGAR, clipping, and tags), then fill in the paired fields. That is string -//! surgery on a formatted SAM line, which is worth naming rather than hiding — but the -//! alternative is reimplementing CIGAR and tag emission, which is far more of the delicate work, -//! not less. [`set_pair_fields`] is deliberately small and heavily tested for that reason. +//! The method is the same as upstream. Make the single-end line with the public writer, which +//! already knows CIGAR, clipping, and tags. Then fill in the paired fields. That is string surgery +//! on a SAM line, and this module says so instead of a quiet name for it. But the alternative is a +//! new implementation of CIGAR emission and tag emission, which is more of the delicate work, and +//! not less. [`set_pair_fields`] is small on purpose, and it has many tests for that reason. //! //! ## Split indexes //! -//! Same shape as the single-end path: map the fragment against each part, spill per-segment hit -//! blocks, then merge each end across parts and re-pair. The re-pair after the merge is essential -//! — pairing decided per part would be based on a fraction of the genome, exactly the error the -//! merge exists to prevent. +//! Same shape as the single-end path. Map the fragment against each part, and spill the hit +//! blocks for each segment. Then merge each end over all parts, and pair them again. The pair step +//! after the merge is necessary. Pairing that the code decides for each part would use only a +//! fraction of the genome. That is exactly the error the merge exists to stop. use std::io::{BufReader, BufWriter, Write}; use std::path::Path; @@ -47,8 +49,8 @@ use crate::map::{ }; use crate::output::AlignmentWriter; -/// SAM flag bits this module sets. Named because a bare `0x20` in flag arithmetic is unreadable -/// and the difference between `0x20` and `0x10` is a silently wrong strand. +/// SAM flag bits this module sets. They have names because a bare `0x20` in flag arithmetic is +/// hard to read. The difference between `0x20` and `0x10` is a wrong strand with no warning. mod flag { pub const PAIRED: u16 = 0x1; pub const PROPER_PAIR: u16 = 0x2; @@ -60,8 +62,8 @@ mod flag { /// Map `reads1`/`reads2` as pairs against the index, writing SAM to `out`. /// -/// The two files must be in lockstep — record *n* of each is one template — which is what -/// `navigator-analysis`'s revert stage guarantees for the FASTQ it produces. +/// The two files must be in lockstep: record *n* of each is one template. The revert stage of +/// `navigator-analysis` guarantees that for the FASTQ it makes. #[allow(clippy::too_many_arguments)] pub fn map_pairs( index_path: &Path, @@ -123,17 +125,19 @@ fn map_pairs_single_part( let header = writer.header().clone(); let chunk = opt.mini_batch_size; - // Read, map and write run as three overlapping stages rather than in strict alternation. + // Read, map and write run as three stages that overlap, and not one after the other. + // + // One stage after the other left the pool idle through two phases of every cycle. A profile of + // the stage put ~60% of the serial phase in BGZF compression, and ~15% in BAM record + // encoding. Another ~9% was gzip inflate. All of it ran while sixteen cores waited. // - // Alternating meant the pool idled through two phases of every cycle: a profile of the stage - // put ~60% of the serial phase in BGZF compression, ~15% in BAM record encoding and ~9% in - // gzip inflate, all of it while sixteen cores waited. Threading the compression removed the - // largest piece; overlapping the stages is what removes the *waiting*, because the reader can - // be inflating batch n+1 and the writer encoding batch n-1 while the pool maps batch n. + // Threads on the compression removed the largest piece. Stages that overlap remove the *idle + // time*. The reader can inflate batch n+1, and the writer can encode batch n-1, while the pool + // maps batch n. // - // Order is preserved by construction: one reader, one writer, and batches crossing each - // channel in sequence. Depth is small on purpose — a batch is tens of MB, and a deep queue - // would just let a fast mapper build a backlog in memory ahead of a slow disk. + // The design keeps the order: one reader, one writer, and batches that cross each channel in + // sequence. The depth is small on purpose. A batch is tens of MB. A deep queue would only + // let a fast mapper build a backlog in memory ahead of a slow disk. let (read_tx, read_rx) = std::sync::mpsc::sync_channel::>(PIPELINE_DEPTH); let (write_tx, write_rx) = std::sync::mpsc::sync_channel::>>(PIPELINE_DEPTH); @@ -141,8 +145,9 @@ fn map_pairs_single_part( let reader = scope.spawn(move || -> Result<(), AlignError> { let mut pairs = PairReader::open(reads1, reads2)?; while let Some(batch) = pairs.next_batch(chunk)? { - // A closed channel means the mapper stopped — cancelled, or a stage failed. Its - // error is the one worth reporting, so this exits quietly. + // A closed channel means the mapper stopped: the user cancelled it, or a stage + // failed. The error of the mapper is the one to report, so this exits with no + // message. if read_tx.send(batch).is_err() { break; } @@ -159,8 +164,9 @@ fn map_pairs_single_part( } } } - // Finishing here, on the thread that owns the writer, is what writes BGZF's - // end-of-file block. A writer dropped mid-stream leaves a file readers call truncated. + // The finish here, on the thread that owns the writer, is what writes the BGZF + // end-of-file block. A writer that drops in mid-stream leaves a file that readers call + // truncated. writer.finish(out) }); @@ -199,8 +205,8 @@ fn map_pairs_single_part( .join() .map_err(|_| AlignError::Message("reader thread panicked".into()))?; - // A downstream failure usually shows up first as a send error upstream, so the real cause - // is reported ahead of the symptom. + // A downstream failure usually shows first as a send error upstream, so this reports the + // real cause before the symptom. scribe_result?; mapped_result?; reader_result?; @@ -211,10 +217,11 @@ fn map_pairs_single_part( Ok(outcome) } -/// Batches in flight per pipeline stage. +/// Batches in progress in each pipeline stage. /// -/// One in hand and one queued is enough to keep a stage from waiting on its neighbour; more only -/// buys memory. A batch is `mini_batch_size` bases of reads plus the records built from them. +/// One in hand and one in the queue is enough to keep a stage off its neighbour. More than that +/// only costs memory. A batch is `mini_batch_size` bases of reads, plus the records the code makes +/// from them. const PIPELINE_DEPTH: usize = 2; // ---- split path ----------------------------------------------------------- @@ -243,8 +250,8 @@ fn map_pairs_split( } let pool = thread_pool(params)?; - // One scratch file per part; each holds two blocks per template, R1 then R2, so the file is - // positional in exactly the way the single-end path's is. + // One scratch file for each part. Each holds two blocks for each template, R1 then R2, so + // the file is positional in exactly the way the single-end file is. for (index, part) in parts.iter().enumerate() { let opt = part_opt(map_opt, part); let path = split::split_tmp_path(&prefix, index); @@ -330,7 +337,7 @@ fn merge_pairs( return Err(AlignError::Cancelled); } for (r1, r2) in &batch { - // Two blocks per template per part, in the order they were written. + // Two blocks for each template and each part, in the order the writer made them. let mut blocks1 = Vec::with_capacity(parts); let mut blocks2 = Vec::with_capacity(parts); for (path, reader) in readers.iter_mut() { @@ -353,15 +360,16 @@ fn merge_pairs( frag_gap: m2.frag_gap, }; - // Restore orientation only now: the per-part blocks were written in the flipped space - // the mapper worked in, and the merge operates on those coordinates. + // Restore orientation only now. The blocks for each part are in the flipped space + // that the mapper worked in, and the merge operates on those coordinates. let (rev1, rev2) = orient_flags(&opt); restore_orientation(&mut res1, r1.l_seq as i32, rev1); restore_orientation(&mut res2, r2.l_seq as i32, rev2); - // Re-pair *after* merging: the merge rebuilt each end's regions from scratch, so - // whatever pairing the per-part passes established is gone. Pairing decided per part - // would rest on a fraction of the genome anyway — the error the merge exists to undo. + // Pair again *after* the merge. The merge built the regions of each end again from + // the start, so any pairing that the passes for each part made is gone. Pairing that + // the code decides for each part would use only a fraction of the genome anyway. That + // is the error the merge exists to undo. repair(&opt, &mut res1, &mut res2, r1, r2); emit_pair(&mut writer, merged_header, &opt, r1, r2, &res1, &res2, out, &mut stats)?; } @@ -373,14 +381,15 @@ fn merge_pairs( Ok(stats) } -/// Map a batch of templates as fragments, preserving input order. +/// Map a batch of templates as fragments, and keep the input order. /// -/// Each element is that template's per-segment results, R1 then R2. Both ends go in together -/// because that is what lets a confidently-placed mate inform an ambiguous one — mapping them -/// separately and reconciling afterwards throws that away. +/// Each element is the result for each segment of that template, R1 then R2. Both ends go in +/// together, because that is what lets a mate with a confident position inform a mate that is +/// ambiguous. To map them apart and then join the results throws that away. /// -/// Results come back in **flipped** coordinate space when the library orientation calls for it; -/// callers restore at the right moment, which differs between the whole-index and split paths. +/// Results come back in **flipped** coordinate space when the library orientation needs it. A +/// caller restores them at the right moment, which is different in the whole-index path and the +/// split path. fn map_frag_batch( pool: &rayon::ThreadPool, index: &MmIdx, @@ -399,11 +408,11 @@ fn map_frag_batch( }) } -/// Turn a mapped batch into finished records, in the pool, preserving batch order. +/// Turn a mapped batch into finished records, in the pool, and keep the batch order. /// -/// The orientation restore moves in here with the formatting: it is per-template work that was -/// also running on the writing thread. `collect` into a `Result` keeps the first error and keeps -/// the output ordered, so a failure reads the same as it did when this was a serial loop. +/// The orientation restore moves in here with the record build. It is work for each template that +/// also ran on the write thread. `collect` into a `Result` keeps the first error, and keeps the +/// output in order. So a failure reads the same as it did when this was a serial loop. #[allow(clippy::too_many_arguments)] fn build_batch_records( pool: &rayon::ThreadPool, @@ -420,42 +429,45 @@ fn build_batch_records( .par_iter() .zip(mapped.into_par_iter()) .map(|((r1, r2), mut results)| { - // Fragment mapping returns one result per segment, R1 then R2. Popping in reverse - // keeps that association; an absent segment (which should not happen) degrades to - // "this end mapped nowhere" rather than shifting the pairing. + // Fragment mapping returns one result for each segment, R1 then R2. A pop in + // reverse keeps that association. A segment that is absent (which must not happen) + // becomes "this end mapped nowhere", and does not move the pairing. let mut res2 = results.pop().unwrap_or_else(empty_result); let mut res1 = results.pop().unwrap_or_else(empty_result); let (rev1, rev2) = orient_flags(opt); restore_orientation(&mut res1, r1.l_seq as i32, rev1); restore_orientation(&mut res2, r2.l_seq as i32, rev2); - // No `repair` here, deliberately. `map_frag_queries` already ran `pe::pair` over - // the fragment, so the ends arrive paired: `proper_frag`, MAPQ, and `sam_pri` are - // set. Pairing them a second time *clears* `proper_frag` — the re-pair is scored - // against a fragment gap that only means something in the split path, where - // merging discarded the original pairing. Adding a call here is the - // obvious-looking change that silently drops 0x2 from every record. + // No `repair` here, and that is deliberate. `map_frag_queries` already ran + // `pe::pair` over the fragment, so the ends arrive in a pair, with `proper_frag`, + // MAPQ and `sam_pri` all set. A second pair step *clears* `proper_frag`. It scores + // against a fragment gap that matters only in the split path, where the merge + // discarded the original pairing. A call here looks like the correct change, and + // it drops 0x2 from every record with no warning. build_pair_records(index, opt, header, r1, r2, &res1, &res2, out) }) .collect() }) } -/// Whether each end is flipped for mapping, from the preset's library orientation. +/// Which ends the code flips for mapping, from the library orientation of the preset. fn orient_flags(opt: &MapOpt) -> (bool, bool) { ((opt.pe_ori >> 1) & 1 != 0, opt.pe_ori & 1 != 0) } -/// Put both ends into the orientation the fragment mapper expects, per the preset's library -/// orientation (`pe_ori`). +/// Put both ends into the orientation the fragment mapper expects, from the library orientation +/// of the preset (`pe_ori`). +/// +/// This is easy to miss, and it fails with no message. `sr` sets `pe_ori = 1`, which is FR. R2 +/// arrives as the reverse complement of R1. The code must flip it, so that both ends read the same +/// way before the chaining step. /// -/// This is easy to miss and fails quietly. `sr` sets `pe_ori = 1`, meaning FR: R2 arrives -/// reverse-complemented relative to R1, and must be flipped so both ends read the same way before -/// the fragment is chained. Skip it and the ends still *map* — coordinates, strands, and mate -/// fields all come out right — but no pair is ever judged concordant, so `proper_frag` is never -/// set and every record loses its 0x2 flag. +/// Skip that flip and the ends still *map*: coordinates, strands and mate fields all come out +/// right. But no pair is ever concordant, so nothing ever sets `proper_frag`, and every record +/// loses its 0x2 flag. /// -/// Returns the possibly-flipped sequences and whether each was flipped, for [`restore_orientation`]. +/// Returns the sequences, which it may have flipped, and which ends it flipped. That second value +/// is for [`restore_orientation`]. fn orient(opt: &MapOpt, seq1: &[u8], seq2: &[u8]) -> (Vec, Vec, bool, bool) { let rev1 = (opt.pe_ori >> 1) & 1 != 0; let rev2 = opt.pe_ori & 1 != 0; @@ -470,8 +482,8 @@ fn flip(seq: &[u8], revcomp: bool) -> Vec { out } -/// Undo [`orient`] on the results, so coordinates and strands describe the read as it was given -/// to us rather than the flipped copy the mapper saw. +/// Undo [`orient`] on the results. Coordinates and strands then describe the read as the caller +/// gave it, and not the flipped copy the mapper saw. fn restore_orientation(result: &mut MapResult, qlen: i32, was_flipped: bool) { if !was_flipped { return; @@ -491,8 +503,8 @@ fn restore_orientation(result: &mut MapResult, qlen: i32, was_flipped: bool) { } } -/// A result with no alignments. `MapResult` has no `Default`, and the fields it would need are -/// not obviously zero, so this is spelled out once. +/// A result with no alignments. `MapResult` has no `Default`, and the fields it needs are not +/// clearly zero, so this file writes the value out one time. fn empty_result() -> MapResult { MapResult { regs: Vec::new(), @@ -503,8 +515,9 @@ fn empty_result() -> MapResult { /// Run the pairing step over an already-mapped pair. fn repair(opt: &MapOpt, res1: &mut MapResult, res2: &mut MapResult, r1: &BseqRecord, r2: &BseqRecord) { - // `pe::pair` reads each end's alignment extras; without base-level alignment on both ends - // there is nothing to score a pairing against, and upstream skips it on the same condition. + // `pe::pair` reads the alignment extras of each end. Without base-level alignment on both + // ends there is nothing to score a pairing against, and upstream skips it on the same + // condition. if res1.regs.is_empty() || res2.regs.is_empty() || res1.regs[0].extra.is_none() || res2.regs[0].extra.is_none() { return; } @@ -544,14 +557,15 @@ fn emit_pair( Ok(()) } -/// Every SAM record for one template, built without touching the writer. +/// Every SAM record for one template, and the writer does not take part. /// -/// This is the work that used to happen on the writing thread: minimap2 formats each alignment as -/// a SAM line, the line is parsed back into a typed record, and the paired fields are filled in. -/// All of it is per-record independent, so it belongs in the mapping pool. Leaving it on the -/// writing thread made mapping a strictly alternating read → map → write cycle in which the pool -/// was idle for two of the three phases: a WGS run measured ~26% pool utilisation, with the -/// mapping itself accounting for only a quarter of a stage that took 3 h 40 m. +/// This is the work that used to happen on the write thread. minimap2 makes a SAM line from each +/// alignment. The code parses that line back into a typed record, and then fills in the paired +/// fields. The work for each record is independent, so it belongs in the mapping pool. +/// +/// On the write thread it made mapping a strict read → map → write cycle, and the pool was idle +/// for two of the three phases. A WGS run measured ~26% pool use, and the mapping itself was only +/// a quarter of a stage that took 3 h 40 m. #[allow(clippy::too_many_arguments)] fn build_pair_records( index: &MmIdx, @@ -682,33 +696,34 @@ fn emit_end( /// The mapper's own rule for which regions reach the output. /// -/// A region whose `parent` is not itself is a *secondary* alignment — another place the read could -/// have gone. `NO_PRINT_2ND` says not to emit those, and the `sr` preset sets it, because for short -/// reads the ambiguity is already carried by MAPQ. +/// A region whose `parent` is not itself is a *secondary* alignment. It is another place the read +/// could have gone. `NO_PRINT_2ND` says not to emit those, and the `sr` preset sets it, because +/// for short reads MAPQ already carries the ambiguity. +/// +/// This module must apply the rule here, because Navigator builds records itself. +/// `minimap2-pure-rs` keeps its PE SAM assembly private. So this code does not go through the +/// pipeline that would apply the rule: `minimap2::map`, on the `r.id != r.parent` test. /// -/// This has to be applied here because Navigator formats records itself: `minimap2-pure-rs` keeps -/// its PE SAM assembly private, so the pipeline that would have applied this rule -/// (`minimap2::map`, on the `r.id != r.parent` test) is the one part of the crate we do not go -/// through. Without it every alternative placement was written out — on a targeted-Y sample, -/// 404 million secondary records against 62 million primaries, 86.6% of the file, each with no -/// SEQ, inflating the alignment to 17 GB and dragging every later stage through them. +/// Without the rule, every alternative placement went into the file. On a targeted-Y sample that +/// was 404 million secondary records against 62 million primaries, or 86.6% of the file, and none +/// carried SEQ. It grew the alignment to 17 GB, and every later stage had to read all of them. /// -/// Supplementary alignments are kept: their `parent` *is* themselves, they carry sequence, and -/// they are how a split read is represented. +/// This keeps supplementary alignments. Their `parent` *is* themselves, they carry sequence, and +/// they are how the format shows a split read. pub(crate) fn emits_record(opt: &MapOpt, reg: &AlignReg) -> bool { !(opt.flag.contains(MapFlags::NO_PRINT_2ND) && reg.id != reg.parent) } -/// The region a mate is "at" for the purposes of `RNEXT`/`PNEXT` — its primary alignment. +/// The region a mate is "at" for `RNEXT` and `PNEXT`: its primary alignment. fn primary(result: &MapResult) -> Option<&AlignReg> { result.regs.iter().find(|r| r.sam_pri).or_else(|| result.regs.first()) } /// Fill in the paired half of a record: flags, `RNEXT`, `PNEXT`, `TLEN`. /// -/// The single-end writer produced everything else. This used to patch the formatted SAM text by -/// column position; it now mutates a typed [`RecordBuf`], so a mate position can not end up in the -/// template-length field however the formatter's layout changes. +/// The single-end writer made everything else. This code used to patch the SAM text by column +/// position. It now changes a typed [`RecordBuf`], so a mate position can not land in the +/// template-length field, whatever the layout of the formatter does. /// /// `own` is this record's region (`None` for an unmapped read) and `mate` is the mate's primary. fn set_pair_fields(record: &mut RecordBuf, own: Option<&AlignReg>, mate: Option<&AlignReg>, is_first: bool) { @@ -737,8 +752,8 @@ fn set_pair_fields(record: &mut RecordBuf, own: Option<&AlignReg>, mate: Option< *record.mate_alignment_start_mut() = Position::new(m.rs as usize + 1); } (Some(o), None) => { - // An unmapped mate is conventionally reported at this record's own locus, so the pair - // stays together once the file is coordinate-sorted. + // By convention, an unmapped mate goes at the locus of this record, so the pair + // stays together after a coordinate sort of the file. *record.mate_reference_sequence_id_mut() = Some(o.rid as usize); *record.mate_alignment_start_mut() = Position::new(o.rs as usize + 1); } @@ -776,10 +791,10 @@ fn tlen(own: &AlignReg, mate: &AlignReg) -> i64 { } } -/// Both ends of a template must share a QNAME, so a trailing `/1` or `/2` has to go. +/// Both ends of a template must share a QNAME, so a `/1` or `/2` at the end has to go. /// -/// Our own revert stage writes bare names, but vendor FASTQ frequently carries the suffix, and a -/// mismatched QNAME silently breaks pairing for every downstream tool. +/// Our own revert stage writes bare names. But vendor FASTQ often carries the suffix, and a QNAME +/// that does not match breaks pairing for every downstream tool, with no warning. fn strip_mate_suffix(name: &str) -> &str { let bytes = name.as_bytes(); if bytes.len() > 2 && bytes[bytes.len() - 2] == b'/' { @@ -813,17 +828,19 @@ impl PairReader { /// The next batch of templates, or `None` at end of input. /// - /// Reads the two files **one record at a time in step**, accumulating until the base budget is - /// reached. The obvious implementation — ask each file for a batch and zip the results — is - /// wrong, and wrong in a way that looks right on tidy data: the underlying reader batches by - /// *bases*, so two files whose reads differ in length yield different record counts from the - /// same budget. Real data has a tail of shorter reads from adapter and quality trimming, so a - /// 332,653-against-332,722 mismatch appears on a genuine WGS and never on a fixture where every - /// read is the same length. + /// This reads the two files **one record at a time in step**, and collects records until it + /// reaches the base budget. + /// + /// There is a simpler implementation: ask each file for a batch, and zip the results. It is + /// wrong, and it looks right on tidy data. The reader below batches by *bases*, so two files + /// whose reads differ in length give different record counts from the same budget. Real data + /// has a tail of shorter reads, which adapter trimming and quality trimming make. So a + /// mismatch of 332,653 against 332,722 appears on a real WGS, and never on a fixture where + /// every read is the same length. /// - /// A file genuinely ending before the other is still an error rather than a truncation: R1/R2 - /// that have lost their order would pair every later read with the wrong mate, which is far - /// worse than refusing to run. + /// A file that truly stops before the other is still an error, and not a truncation. R1 and R2 + /// that have lost their order would pair every later read with the wrong mate. That is much + /// worse than a refusal to run. fn next_batch(&mut self, chunk: i64) -> Result>, AlignError> { let mut batch = Vec::new(); let mut bases: i64 = 0; diff --git a/crates/navigator-align/src/pe/tests.rs b/crates/navigator-align/src/pe/tests.rs index 6da7145b..52e59ec3 100644 --- a/crates/navigator-align/src/pe/tests.rs +++ b/crates/navigator-align/src/pe/tests.rs @@ -1,9 +1,9 @@ //! Tests for paired-end mapping. //! -//! Two things get the most attention because they are what this module adds over the single-end -//! path and what nothing else checks: the paired SAM fields (flags, `RNEXT`/`PNEXT`, `TLEN`), and -//! that pairing survives a split index — pairing decided per part would rest on a fraction of the -//! genome. +//! Two things get the most attention. They are what this module adds to the single-end path, and +//! nothing else checks them. The first is the paired SAM fields: flags, `RNEXT`/`PNEXT` and +//! `TLEN`. The second is that pairing survives a split index. Pairing that the code decides for +//! each part would use only a fraction of the genome. use std::path::{Path, PathBuf}; @@ -150,7 +150,8 @@ fn records(sam: &Path) -> Vec { .collect() } -/// Primary records only — supplementary/secondary ones repeat a template and would double-count. +/// Primary records only. A supplementary record or a secondary record repeats a template, and it +/// would double-count. fn primaries(sam: &Path) -> Vec { records(sam).into_iter().filter(|r| r.flag & 0x900 == 0).collect() } @@ -226,8 +227,8 @@ fn proper_pairs_get_paired_flags_mate_fields_and_opposing_tlen() { assert!(proper >= 35, "expected most FR pairs to be proper, got {proper}"); } -/// An FR pair is one forward and one reverse read; if both came out on the same strand the -/// orientation handling is wrong and `proper_frag` would be meaningless. +/// An FR pair is one forward read and one reverse read. If both are on the same strand, the code +/// that controls orientation is wrong, and `proper_frag` then has no value. #[test] fn the_two_ends_map_to_opposite_strands() { let dir = scratch("strand"); @@ -248,8 +249,8 @@ fn the_two_ends_map_to_opposite_strands() { assert!(opposite >= 35, "expected FR orientation on most pairs, got {opposite}"); } -/// A read whose mate did not map still has to say so, and both records must stay at the same -/// locus so a coordinate sort keeps the template together. +/// A read whose mate did not map must still say so. Both records must stay at the same locus, so +/// that a coordinate sort keeps the template together. #[test] fn an_unmappable_mate_is_flagged_and_placed_with_its_partner() { let dir = scratch("halfmapped"); @@ -302,12 +303,13 @@ fn mate_suffixes_are_stripped_so_both_ends_share_a_qname() { assert_eq!(strip_mate_suffix("read/1"), "read"); assert_eq!(strip_mate_suffix("read/2"), "read"); assert_eq!(strip_mate_suffix("read"), "read"); - // Not a mate suffix — a name that merely ends in a digit, or in /3. + // Not a mate suffix. This is a name that only ends in a digit, or in /3. assert_eq!(strip_mate_suffix("read1"), "read1"); assert_eq!(strip_mate_suffix("read/3"), "read/3"); } -/// TLEN is signed by which end is leftmost, and zero when neither is. +/// The sign of TLEN comes from which end is leftmost. TLEN is zero when neither end is +/// leftmost. #[test] fn tlen_is_signed_by_position_and_zero_on_a_tie() { let reg = |rs: i32, re: i32| AlignReg { @@ -322,9 +324,9 @@ fn tlen_is_signed_by_position_and_zero_on_a_tie() { // ---- the split index ------------------------------------------------------ -/// The same claim the single-end path makes, for pairs: splitting the index is a memory decision, -/// not an accuracy one. Pairing in particular must be re-derived after the merge — done per part -/// it would rest on whichever fraction of the genome was resident. +/// The same claim that the single-end path makes, but for pairs: a split index is a memory +/// decision, and not an accuracy decision. The code must do the pairing again after the merge. +/// Pairing for one part alone would use only the fraction of the genome that was in memory. #[test] fn a_split_index_pairs_reads_exactly_as_a_whole_index_does() { let dir = scratch("equivalence"); @@ -372,8 +374,8 @@ fn a_split_index_pairs_reads_exactly_as_a_whole_index_does() { } } -/// R1/R2 that have lost their order would pair every later read with the wrong mate — a -/// corruption that produces confident, wrong alignments. It must refuse rather than truncate. +/// R1 and R2 that have lost their order would pair every later read with the wrong mate. That +/// corruption gives confident, wrong alignments. The reader must refuse, and must not truncate. #[test] fn mismatched_read_counts_are_refused() { let dir = scratch("lockstep"); @@ -448,17 +450,17 @@ fn cancellation_stops_paired_mapping() { assert!(matches!(err, AlignError::Cancelled)); } -/// **Regression.** Every fixture above uses fixed-length reads, and that is exactly why they all -/// passed while a real WGS failed 74 minutes into a run. +/// **Regression.** Every fixture above uses fixed-length reads. That is why they all passed, and +/// why a real WGS failed 74 minutes into a run. /// -/// The paired reader batches by *bases*. If the two files are read independently with the same -/// base budget, files whose reads differ in length return different record counts — and real data -/// always has a tail of shorter reads from adapter and quality trimming. On WGS229 that surfaced -/// as 332,653 against 332,722 in one batch, tripping the lockstep guard on files that were -/// perfectly in step. +/// The paired reader makes its batches by *bases*. Read the two files on their own with the same +/// base budget, and two files whose reads differ in length give different record counts. Real +/// data always has a tail of shorter reads, which adapter trimming and quality trimming make. On +/// WGS229 that showed as 332,653 against 332,722 in one batch. The lockstep guard then fired on +/// two files that were exactly in step. /// -/// So this fixture deliberately gives R1 and R2 *different* length distributions, and asserts -/// every pair still comes back matched. +/// So this fixture gives R1 and R2 *different* length distributions on purpose. It asserts that +/// every pair still comes back with its mate. #[test] fn pairs_stay_in_step_when_reads_have_different_lengths() { let dir = scratch("varlen"); @@ -470,7 +472,8 @@ fn pairs_stay_in_step_when_reads_have_different_lengths() { let (mut t1, mut t2) = (Vec::new(), Vec::new()); for i in 0..400usize { let start = 1000 + i * 200; - // R1 keeps full length; R2 is trimmed by a varying amount, as a trimmer would leave it. + // R1 keeps its full length. Each R2 read loses a different number of bases, as a + // trimmer leaves them. let len1 = 150; let len2 = 150 - (i % 37); let r1 = &bases[start..start + len1]; @@ -515,8 +518,8 @@ fn pairs_stay_in_step_when_reads_have_different_lengths() { } } -/// The guard still has to fire when the files really are mismatched, which is the case it exists -/// for — one file ending before the other. +/// The guard must still fire when the two files do not match. That is the case it exists for: +/// one file stops before the other. #[test] fn a_truncated_mate_file_is_still_refused() { let dir = scratch("truncated"); @@ -577,10 +580,10 @@ fn a_truncated_mate_file_is_still_refused() { /// Secondary alignments must not reach the output under the `sr` preset. /// -/// The preset sets `NO_PRINT_2ND`, and minimap2 honours it in the pipeline Navigator does not go -/// through — so the rule has to be applied where the records are actually built. It was not, and a -/// targeted-Y sample came out 86.6% secondary records: 404 million of them against 62 million -/// primaries, none carrying SEQ. +/// The preset sets `NO_PRINT_2ND`. minimap2 obeys that flag in a pipeline that Navigator does not +/// use. So this module must apply the rule where it builds the records. It did not, and a +/// targeted-Y sample came out with 86.6% secondary records. That was 404 million of them against +/// 62 million primaries, and not one carried SEQ. #[test] fn secondary_regions_are_not_emitted_but_supplementary_ones_are() { use minimap2::types::AlignReg; @@ -591,7 +594,7 @@ fn secondary_regions_are_not_emitted_but_supplementary_ones_are() { "the sr preset is what makes this rule apply" ); - // `parent == id` is a primary chain — the representative, or a supplementary part of a split + // `parent == id` is a primary chain: the representative, or a supplementary part of a split // read. `parent != id` is another place the read could have gone. let primary = AlignReg { id: 7, diff --git a/crates/navigator-align/src/preset.rs b/crates/navigator-align/src/preset.rs index 7beab7db..fceaea8a 100644 --- a/crates/navigator-align/src/preset.rs +++ b/crates/navigator-align/src/preset.rs @@ -1,17 +1,19 @@ //! Which mapper preset a sample's reads need. //! -//! minimap2's presets are not interchangeable tunings of one algorithm — `sr` and `map-ont` differ -//! in k-mer size, chaining, and gap costs by more than an order of magnitude in effect. Mapping -//! long reads under `sr` does not fail loudly; it produces plausible-looking, wrong alignments. So -//! the inference here refuses rather than guesses, which is the behaviour the design asks for -//! ("refuse (or warn loudly) on mixed/unknown technology rather than guessing"). +//! minimap2's presets are not different settings of one algorithm. `sr` and `map-ont` differ in +//! k-mer size, chaining, and gap costs. The effect of that difference is more than an order of +//! magnitude. A long read that maps under `sr` does not fail with a loud message. It gives wrong +//! alignments that look correct. +//! +//! So the inference here refuses, and it does not guess. The design asks for this behaviour: on a +//! mixed or an unknown technology, refuse or give a loud warning. use crate::error::AlignError; -/// A minimap2 preset, restricted to the ones this module maps reads under. +/// A minimap2 preset, limited to the ones this module maps reads under. /// -/// The assembly and splice presets exist upstream but have no meaning for realigning a -/// resequenced human sample, so they are deliberately not modelled. +/// The assembly and splice presets exist upstream, but they have no use when the module realigns +/// a resequenced human sample. This type does not model them, and that is deliberate. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Preset { /// Illumina / short-read WGS and WES. @@ -32,9 +34,9 @@ impl Preset { } } - /// Whether reads under this preset come in pairs. Only short-read data is mapped as pairs; - /// long-read presets are single-end, and this is also what decides duplicate marking later - /// (stage C marks duplicates for short reads only). + /// True when reads under this preset come in pairs. The mapper pairs short-read data only. + /// Long-read presets are single-end. This flag also decides which reads get duplicate marks + /// later: stage C marks duplicates for short reads only. pub fn is_paired(self) -> bool { matches!(self, Preset::ShortRead) } @@ -54,12 +56,13 @@ impl Preset { /// Choose a preset from what the workspace already inferred about the run. /// /// `test_type` is `SequenceRun.test_type` (`WGS`, `WGS_HIFI`, `WGS_NANOPORE`, `WES`, - /// `BIG_Y_700`, …) and `platform` is `SequenceRun.platform_name` (from `@RG PL`). The test type - /// is consulted first because `testtype.rs` has already combined platform and read-length - /// evidence to produce it; the platform is only a fallback for runs that never got one. + /// `BIG_Y_700`, …) and `platform` is `SequenceRun.platform_name` (from `@RG PL`). This function + /// reads the test type first, because `testtype.rs` already combined the platform evidence and + /// the read-length evidence to make it. The platform is only a fallback for a run that never + /// got a test type. /// - /// Targeted panels (Big Y and friends) map under the chemistry that produced them, which for - /// every such product Navigator ingests is short-read. + /// A targeted panel (Big Y and the products like it) maps under the chemistry that made it. + /// For every such product that Navigator imports, that chemistry is short-read. pub fn infer(test_type: Option<&str>, platform: Option<&str>) -> Result { if let Some(t) = test_type { let t = t.trim().to_ascii_uppercase(); @@ -77,12 +80,12 @@ impl Preset { } } - // No test type recorded — fall back to the raw platform string. + // The run has no test type, so fall back to the raw platform string. let p = platform.unwrap_or_default().to_ascii_uppercase(); if p.contains("PACBIO") { - // PacBio without a test type is ambiguous between CLR and HiFi. HiFi is what every - // consumer PacBio product Navigator sees actually is, but the guess is worth flagging - // rather than burying, so callers can surface it. + // PacBio without a test type is ambiguous between CLR and HiFi. Every consumer PacBio + // product that Navigator sees is HiFi, so the code chooses HiFi. It is still a guess, + // and the return value says so, so that a caller can show it to the user. return Ok(Preset::MapHifi); } if p.contains("NANOPORE") || p == "ONT" || p.contains("OXFORD") { @@ -119,8 +122,8 @@ mod tests { } } - /// The test type is the workspace's own considered inference, so it beats the raw platform - /// string — a HiFi run still reports `PACBIO` as its platform. + /// The test type is the workspace's own considered inference, so it wins over the raw platform + /// string. A HiFi run still reports `PACBIO` as its platform. #[test] fn test_type_wins_over_platform() { assert_eq!( @@ -137,7 +140,8 @@ mod tests { } /// The property that matters: an unrecognized technology must stop the job. Mapping under the - /// wrong preset yields wrong alignments quietly, which is worse than not running. + /// wrong preset gives wrong alignments and no warning, which is worse than a job that does not + /// run. #[test] fn an_unknown_technology_is_an_error_not_a_guess() { assert!(matches!( diff --git a/crates/navigator-domain/src/ancestry.rs b/crates/navigator-domain/src/ancestry.rs index eae9760c..78c01ece 100644 --- a/crates/navigator-domain/src/ancestry.rs +++ b/crates/navigator-domain/src/ancestry.rs @@ -1,11 +1,11 @@ -//! Ancestry estimation result types — a sample's population-proportion estimate plus the -//! reference population catalog. Pure types; the estimator lives in `navigator-analysis` -//! (which builds these), persistence in `navigator-store`/the app. +//! Ancestry estimation result types: the population-proportion estimate of a sample, plus the +//! reference population catalog. Pure types. The estimator lives in `navigator-analysis`, which +//! builds these, and persistence is in `navigator-store` and the app. //! -//! Phase 1 works at **super-population** granularity (AFR/AMR/EAS/EUR/SAS), the resolution -//! the 1000G-on-CHM13 INFO allele counts give us directly. The fine-grained 26/33-population -//! catalog (and PCA coordinates) is deferred to phase 2 — the `pca_coordinates` field is -//! already carried so the result shape does not change when PCA lands. +//! Phase 1 works at **super-population** granularity (AFR/AMR/EAS/EUR/SAS). That is the resolution +//! the 1000G-on-CHM13 INFO allele counts give us directly. Phase 2 holds the fine-grained +//! 26/33-population catalog, and the PCA coordinates. The `pca_coordinates` field is already here, +//! so the result shape does not change when PCA lands. use serde::{Deserialize, Serialize}; @@ -62,16 +62,17 @@ const FINE_POPULATIONS: [(&str, &str, &str); 35] = [ ("CHS", "Southern Han Chinese", "EAS"), ("CDX", "Dai Chinese", "EAS"), ("KHV", "Kinh (Vietnam)", "EAS"), - // European — 1000G reference set (CEU is the sole NW/continental proxy)… + // European: the 1000G reference set (CEU is the only NW or continental proxy)… ("CEU", "NW European (Utah)", "EUR"), ("TSI", "Tuscan (Italy)", "EUR"), ("FIN", "Finnish", "EUR"), ("GBR", "British", "EUR"), ("IBS", "Iberian (Spain)", "EUR"), - // …plus present-day AADR reference groups that fill continental West/Central/South/East - // Europe, which 1000G lacks. Without these a continental European's ancestry has no home and - // smears into CEU + spurious Iberian/Tuscan. (German/Dutch/Swiss remain unavailable in any - // public academic panel — French/Orcadian are the nearest continental-NW anchors.) + // …plus present-day AADR reference groups that fill continental West, Central, South and + // East Europe, which 1000G lacks. Without these, the ancestry of a continental European has no + // home and smears into CEU plus a spurious Iberian or Tuscan component. (German, Dutch and + // Swiss groups are in no public academic panel. French and Orcadian are the nearest + // continental-NW anchors.) ("FRN", "French", "EUR"), ("ORC", "Orcadian", "EUR"), ("SRD", "Sardinian", "EUR"), @@ -91,9 +92,9 @@ const FINE_POPULATIONS: [(&str, &str, &str); 35] = [ ]; /// Ancient reference components for the PCA-projection GMM model: `(code, name, hex color)`. -/// These are the labels of the ancient `PcaLoadings` asset (built by `navigator-panelbuild` -/// from labelled ancient reference genomes); each is its own continental-equivalent group, so -/// it rolls up to itself in the super-population summary. +/// These are the labels of the ancient `PcaLoadings` asset, which `navigator-panelbuild` builds +/// from labelled ancient reference genomes. Each is its own continental-equivalent group, so it +/// rolls up to itself in the super-population summary. const ANCIENT_POPULATIONS: [(&str, &str, &str); 3] = [ ("Steppe", "Steppe pastoralist", "#4e79a7"), ("EEF", "Early European Farmer", "#f28e2b"), @@ -101,10 +102,11 @@ const ANCIENT_POPULATIONS: [(&str, &str, &str); 3] = [ ]; /// HGDP reference populations (Bergström 2020), `(code, display-name, super-population)`. These -/// enrich the copying-LAI **haplotype** reference (`ancestry_haps`) with sub-continental depth 1000G -/// lacks (French/Sardinian/Basque/…); they are NOT part of the modern admixture EM's fine set -/// ([`fine_population_codes`]), so they live in their own table consulted by [`population_super`] / -/// [`population_name`] / [`population_color`]. Super-population = the standard HGDP 7-region grouping. +/// add sub-continental depth to the copying-LAI **haplotype** reference (`ancestry_haps`), which +/// 1000G lacks: French, Sardinian, Basque and others. They are NOT part of the fine set of the +/// modern admixture EM ([`fine_population_codes`]). So they live in their own table, which +/// [`population_super`], [`population_name`] and [`population_color`] read. The super-population +/// is the standard HGDP 7-region division. const HGDP_POPULATIONS: [(&str, &str, &str); 62] = [ // Europe ("Adygei", "Adygei", "EUR"), @@ -178,10 +180,11 @@ const HGDP_POPULATIONS: [(&str, &str, &str); 62] = [ ("Yoruba", "Yoruba (HGDP)", "AFR"), ]; -/// The curated **modern** fine-population codes (1000G fine pops + SGDP-backed continents) — the -/// reference subset a fine admixture EM runs over. Excludes ancient components (which are handled by -/// the distance/PCA estimators, not the modern EM). The fine-frequency asset may carry more -/// populations; the estimator restricts to this set to stay well-conditioned. +/// The curated **modern** fine-population codes (1000G fine pops and SGDP-backed continents). +/// This is the reference subset a fine admixture EM runs over. It leaves out the ancient +/// components, which the distance and PCA estimators control, and not the modern EM. The +/// fine-frequency asset can carry more populations, but the estimator limits itself to this set, +/// to stay well-conditioned. pub fn fine_population_codes() -> Vec<&'static str> { FINE_POPULATIONS.iter().map(|(c, _, _)| *c).collect() } @@ -207,7 +210,8 @@ pub fn population_super(code: &str) -> Option<&'static str> { const SUPER_CODES: [&str; 8] = ["AFR", "AMR", "EAS", "SAS", "EUR", "MEA", "CAS", "OCE"]; -/// Display name for a fine or super population code, falling back to the code itself. +/// Display name for a fine or super population code. If there is none, this returns the code +/// itself. pub fn population_name(code: &str) -> String { if let Some((_, name, _)) = ANCIENT_POPULATIONS.iter().find(|(c, _, _)| *c == code) { return name.to_string(); @@ -240,8 +244,8 @@ pub fn population_color(code: &str) -> String { } /// Approximate `(longitude, latitude)` of a population's homeland, for the geographic map. -/// Representative points (degrees); fine populations are placed in-country, super-only groups -/// (MEA/CAS/OCE) at a regional centroid. +/// These are representative points in degrees. A fine population sits in its country, and a +/// super-only group (MEA/CAS/OCE) sits at a regional centroid. pub fn population_lonlat(code: &str) -> Option<(f32, f32)> { let p = match code { // African @@ -295,14 +299,14 @@ pub fn population_lonlat(code: &str) -> Option<(f32, f32)> { Some(p) } -/// A contiguous stretch of one chromosome assigned to a single ancestry — a segment of the -/// per-chromosome "DNA painting" (local ancestry). `start`/`end` are 1-based inclusive bp. +/// A contiguous stretch of one chromosome with a single ancestry. It is a segment of the "DNA +/// painting" (local ancestry) of that chromosome. `start` and `end` are 1-based inclusive bp. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AncestrySegment { pub contig: String, pub start: i64, pub end: i64, - /// Super-population code (AFR/EUR/…) the segment is painted with. + /// Super-population code (AFR/EUR/…) that the painting gives this segment. pub population_code: String, /// Mean posterior support for the assignment over the segment (0–1). pub posterior: f64, @@ -311,17 +315,17 @@ pub struct AncestrySegment { /// Older single-track paintings decode to copy 0. #[serde(default)] pub copy: u8, - /// Fine (sub-continental) population resolved within this super-population segment by the - /// two-tier fine-resolution step (e.g. "GBR" within "EUR"). `None` when the fine call is too - /// uncertain, or for older paintings (decodes to `None`) — fall back to `population_code`. + /// Fine (sub-continental) population that the two-tier fine-resolution step found inside this + /// super-population segment (for example "GBR" inside "EUR"). `None` when the fine call is too + /// uncertain, and for an older painting, which decodes to `None`. Then use `population_code`. #[serde(default)] pub fine_population_code: Option, } -/// A chromosome painting: the per-side ancestry segments plus a human label for each of the two -/// sides. When the painting is phased and a parent was found in the workspace, the sides are -/// anchored to that parent (labels like "Mother"/"Father"); otherwise they are the neutral -/// "Side A"/"Side B". `side_labels[i]` labels the side whose segments carry `copy == i`. +/// A chromosome painting: the ancestry segments of each side, plus a human label for each of the +/// two sides. When the painting has phase and the workspace holds a parent, the labels anchor to +/// that parent, with names like "Mother" and "Father". If not, they are the neutral "Side A" and +/// "Side B". `side_labels[i]` labels the side whose segments carry `copy == i`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PaintingResult { pub segments: Vec, @@ -367,9 +371,9 @@ pub struct PopulationComponent { pub rank: usize, } -/// A super-population (continental) summary. With the phase-1 super-population panel this is -/// 1:1 with the components; it stays distinct so the fine-grained phase-2 panel can roll its -/// constituent populations up here without changing the result shape. +/// A super-population (continental) summary. With the phase-1 super-population panel this is 1:1 +/// with the components. It stays distinct, so that the fine-grained phase-2 panel can roll its +/// constituent populations up here, and the result shape does not change. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SuperPopulationSummary { pub super_population: String, @@ -380,9 +384,10 @@ pub struct SuperPopulationSummary { /// A sample's ancestry estimate. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AncestryResult { - /// The estimator that produced this result: `"AF_LIKELIHOOD"` | `"ADMIXTURE"` | - /// `"PCA_PROJECTION_GMM"`. Carried verbatim into the published record's `analysisMethod` - /// (and the per-(alignment, method) store key) so the method is captured, never inferred. + /// The estimator that made this result: `"AF_LIKELIHOOD"` | `"ADMIXTURE"` | + /// `"PCA_PROJECTION_GMM"`. It goes verbatim into the `analysisMethod` of the published record, + /// and into the store key of (alignment, method). So the method is a record, and never an + /// inference. pub method: String, /// "aims" | "genome-wide". pub panel_type: String, @@ -396,8 +401,9 @@ pub struct AncestryResult { pub super_population_summary: Vec, /// Overall confidence (0–1) from data completeness. pub confidence_level: f64, - /// Fit residual for distance-minimizing models (nMonte/G25): the Euclidean distance between - /// the sample and its fitted mixture in PC space. Lower is better; `None` for non-fit methods. + /// Fit residual for a model that minimizes distance (nMonte/G25): the Euclidean distance + /// between the sample and its fitted mixture in PC space. Lower is better. `None` for a method + /// that does not fit. pub fit_distance: Option, pub pipeline_version: String, pub reference_version: String, diff --git a/crates/navigator-domain/src/bisdna.rs b/crates/navigator-domain/src/bisdna.rs index dcfa010c..6e4168ea 100644 --- a/crates/navigator-domain/src/bisdna.rs +++ b/crates/navigator-domain/src/bisdna.rs @@ -1,13 +1,15 @@ -//! BISDNA chromo2 Y-chromosome raw-data parsing. The export is a tab-delimited table — -//! `SNPID`, an Illumina TOP-strand `genotype`, and a `result` verdict (positive/negative/ -//! no_call/back-mutated) — preceded by a multi-line prose preamble. Crucially it carries -//! **no positions or alleles**: a SNP name plus a derived/ancestral verdict. Turning those -//! into placeable variant calls needs an external name→locus dictionary (see the design -//! `documents/design/bisdna-import.md`); this module is only the faithful, IO-free file parse. +//! The parse of BISDNA chromo2 Y-chromosome raw data. The export is a tab-delimited table with +//! three columns: `SNPID`, an Illumina TOP-strand `genotype`, and a `result` verdict (positive, +//! negative, no_call, back-mutated). A multi-line prose preamble comes before it. //! -//! Strand note: the genotype is on the Illumina TOP strand, which need not match the -//! reference + strand, so it is kept verbatim and is *not* the source of truth for -//! derived/ancestral — the `result` column is (see [`Verdict`]). +//! Above all, the export carries **no positions and no alleles**. It gives a SNP name plus a +//! derived or ancestral verdict. To make placeable variant calls from those needs an external +//! name→locus dictionary (see the design `documents/design/bisdna-import.md`). This module is only +//! the faithful, IO-free file parse. +//! +//! Strand note: the genotype is on the Illumina TOP strand, which does not have to match the + +//! strand of the reference. So this module keeps it verbatim, and it is *not* the source of truth +//! for derived or ancestral. The `result` column is that source (see [`Verdict`]). use std::collections::HashMap; @@ -24,11 +26,11 @@ pub enum Verdict { Positive, /// Ancestral allele carried. Negative, - /// Undetermined — the genotype is `00` and BISDNA could not call the marker. + /// Undetermined: the genotype is `00`, and BISDNA could not call the marker. NoCall, - /// The lineage is derived but the base reads ancestral (a documented back-mutation, e.g. - /// S163). The placement layer flags and excludes these — a position→base call can't - /// represent "derived lineage showing the ancestral base". + /// The lineage is derived, but the base reads ancestral (a documented back-mutation, for + /// example S163). The placement layer flags these and excludes them. A position→base call can + /// not say "a derived lineage that shows the ancestral base". BackMutated, } @@ -42,7 +44,7 @@ pub struct BisdnaCall { pub verdict: Verdict, } -/// Trim whitespace and one layer of surrounding double-quotes from a cell. +/// Trim whitespace and one layer of double-quotes from around a cell. fn clean_cell(s: &str) -> &str { s.trim().trim_matches('"').trim() } @@ -71,16 +73,16 @@ fn is_header(cols: &[&str]) -> bool { && clean_cell(cols[2]).eq_ignore_ascii_case("result") } -/// Parse a BISDNA chromo2 export into calls. Skips the prose preamble by seeking the -/// `SNPIDgenotyperesult` header, then reads each tab-delimited data row -/// (`name`, `genotype`, `result`). Blank lines and rows with an unrecognized verdict are -/// skipped; every recognized row is kept verbatim (including `NoCall`/`BackMutated` — the -/// importer, not the parser, decides what to drop). Errors only if the header is missing or -/// no data rows follow it. +/// Parse a BISDNA chromo2 export into calls. It steps over the prose preamble to the +/// `SNPIDgenotyperesult` header, then reads each tab-delimited data row (`name`, +/// `genotype`, `result`). It drops a blank line, and a row with a verdict it does not recognize. +/// It keeps every row it does recognize, verbatim, and that includes `NoCall` and `BackMutated`. +/// The importer decides what to drop, and not the parser. Errors only if the header is absent, or +/// if no data row follows it. pub fn parse(text: &str) -> Result, String> { let mut lines = text.lines(); - // Seek the header, skipping the multi-line prose preamble. + // Find the header, and step over the multi-line prose preamble. let header_found = lines.by_ref().any(|line| { let cols: Vec<&str> = line.split('\t').collect(); is_header(&cols) @@ -119,8 +121,9 @@ pub fn parse(text: &str) -> Result, String> { Ok(calls) } -/// Does `genotype` carry `allele` (or its complement)? QC only — a miss on both strands flags -/// a likely dictionary/name mismatch, but the verdict (not the genotype) decides the call. +/// Does `genotype` carry `allele`, or its complement? QC only. A miss on both strands flags a +/// probable mismatch of the dictionary and the name, but the verdict decides the call, and not the +/// genotype. fn genotype_supports(genotype: &str, allele: &str) -> bool { let Some(want) = allele.bytes().next().map(|b| b.to_ascii_uppercase()) else { return true; @@ -132,18 +135,18 @@ fn genotype_supports(genotype: &str, allele: &str) -> bool { .any(|b| b == want || b == comp) } -/// The result of resolving BISDNA calls against the Y-SNP dictionary on a given build: the -/// emitted variant calls (positives only) plus a per-category tally. +/// What comes back when BISDNA calls resolve against the Y-SNP dictionary on one build. It holds +/// the variant calls it emits (positives only), plus a tally for each category. // Not `Eq`: a call now carries `CallEvidence`, whose QUAL is an `f64`. #[derive(Debug, Clone, Default, PartialEq)] pub struct ResolveOutcome { /// Positive (derived) calls resolved to a locus, as carried `VariantCall`s. pub calls: Vec, - /// Negative (ancestral) markers — not variants, so not emitted. + /// Negative (ancestral) markers. They are not variants, so this does not emit them. pub ancestral: usize, /// `no_call` markers. pub no_call: usize, - /// Back-mutated markers — flagged, excluded from placement. + /// Back-mutated markers. This flags them, and placement leaves them out. pub back_mutated: usize, /// Positive markers whose name the dictionary could not place on this build. pub unresolved: usize, @@ -153,12 +156,12 @@ pub struct ResolveOutcome { pub strand_mismatches: usize, } -/// Resolve parsed BISDNA `calls` to carried Y-SNP variant calls on `build`, using `dict` for -/// name→locus. Only **positive** (derived) markers are emitted (`reference` = ancestral, -/// `alternate` = derived, genotype `"1"`); a negative is not a variant, and the variant-level -/// reconciler weights every stored call as a carried allele. Negative/no_call/back-mutated and -/// dictionary-unresolved markers are tallied, not emitted. `unresolved_cap` bounds the sample -/// of unresolved names kept. Pure — no IO. +/// Resolve parsed BISDNA `calls` to carried Y-SNP variant calls on `build`, with `dict` for +/// name→locus. This emits only **positive** (derived) markers, as `reference` = ancestral, +/// `alternate` = derived, genotype `"1"`. A negative is not a variant, and the variant-level +/// reconciler weights every stored call as a carried allele. It tallies a negative, a no_call, a +/// back-mutated marker, and a marker the dictionary does not resolve, but it emits none of them. +/// `unresolved_cap` limits how many unresolved names it keeps. Pure, with no IO. pub fn resolve_calls( calls: &[BisdnaCall], dict: &YsnpDictionary, @@ -199,12 +202,14 @@ pub fn resolve_calls( out } -/// Build the position→base map for **haplogroup placement** from BISDNA calls resolved on -/// `build`. Unlike [`resolve_calls`] (which emits only carried variants, for storage and the -/// allele-weighted reconciler), this includes **negatives** too: a negative is genuine -/// ancestral evidence that prunes over-deep branches in the Kulczynski scorer. Positive → -/// derived base, negative → ancestral base; `no_call`/back-mutated/dictionary-unresolved -/// markers are omitted (no confident base). Bases are uppercased; on duplicate positions the +/// Build the position→base map for **haplogroup placement** from BISDNA calls that resolved on +/// `build`. [`resolve_calls`] emits only carried variants, for storage and the allele-weighted +/// reconciler. This map holds the **negatives** too, because a negative is genuine ancestral +/// evidence, and it prunes over-deep branches in the Kulczynski scorer. +/// +/// A positive gives the derived base, and a negative gives the ancestral base. This leaves out a +/// `no_call`, a back-mutated marker, and a marker the dictionary does not resolve, because none of +/// those has a confident base. It puts the bases into upper case, and on a duplicate position the /// last call wins. The result feeds `haplo::score` directly (`HashMap`). pub fn placement_calls(calls: &[BisdnaCall], dict: &YsnpDictionary, build: &str) -> HashMap { let mut map = HashMap::new(); @@ -361,7 +366,7 @@ S163\ths1\tchrY\t15000000\t+\tA\tC #[test] fn missing_build_makes_positives_unresolved() { - // Dict only has hs1 coords; asking for GRCh38 resolves nothing. + // Dict only has hs1 coords, so a request for GRCh38 resolves nothing. let out = resolve_calls(&parse(SAMPLE).unwrap(), &dict(), "GRCh38", 10); assert!(out.calls.is_empty()); assert_eq!(out.unresolved, 3); // the three positives diff --git a/crates/navigator-domain/src/brief.rs b/crates/navigator-domain/src/brief.rs index 8fc2554d..d5716854 100644 --- a/crates/navigator-domain/src/brief.rs +++ b/crates/navigator-domain/src/brief.rs @@ -1,14 +1,16 @@ -//! Plain-language **subject brief** model + the reference-content pack that supplies its narrative. +//! The plain-language **subject brief** model, and the reference-content pack that gives it its +//! narrative. //! -//! This module is pure (no I/O): it owns the render-ready [`SubjectBrief`] tree, the [`BriefPack`] -//! reference-content schema, and the deterministic templating that turns structured analysis signals -//! (ages, depths, confidences) into casual-reader sentences. Composition — pulling the signals and -//! loading/enriching the pack — lives in `navigator-app::brief`; rendering lives in `navigator-ui`. +//! This module is pure, with no I/O. It owns three things: the [`SubjectBrief`] tree, which is +//! ready to draw, the [`BriefPack`] reference-content schema, and the deterministic templates. +//! Those templates turn structured analysis signals (ages, depths, confidences) into sentences for +//! a casual reader. Composition lives in `navigator-app::brief`: it collects the signals, and it +//! loads the pack and adds to it. The UI draws the result, in `navigator-ui`. //! -//! The narrative content (haplogroup origins, ages, stories, test descriptions) is *not* derivable -//! from the analysis; it comes from the [`BriefPack`], shipped as a bundled seed and refreshed from a -//! CDN asset. Lookups fall back up the lineage path so a compact pack still tells a useful story for -//! a rare terminal haplogroup (see [`BriefPack::lineage_lookup`]). +//! The narrative content (haplogroup origins, ages, stories, test descriptions) does *not* come +//! from the analysis. It comes from the [`BriefPack`], which ships as a bundled seed, and which a +//! CDN asset refreshes. A lookup falls back up the lineage path, so a compact pack still tells a +//! useful story for a rare terminal haplogroup (see [`BriefPack::lineage_lookup`]). use crate::ancestry::SuperPopulationSummary; use crate::i18n::{tr, tr_fmt, Lang}; @@ -21,11 +23,11 @@ use std::collections::HashMap; // Reference pack (narrative content) // --------------------------------------------------------------------------------------------- -/// One haplogroup's narrative content: when it formed, where it is associated with, and a short -/// curated story. Every field is optional so a sparse pack still contributes what it has. +/// The narrative content of one haplogroup: when it formed, which places it goes with, and a short +/// curated story. Every field is optional, so a sparse pack still gives what it has. #[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] pub struct HaploEntry { - /// Years before present the haplogroup is estimated to have formed. + /// Years before present when the haplogroup formed, as an estimate. #[serde(default)] pub formed_ybp: Option, /// Broad geographic / cultural association ("the Pontic-Caspian steppe and early Europe"). @@ -44,7 +46,7 @@ pub struct HaploEntry { pub struct TestEntry { /// What the test tells you ("reads your whole genome, so it covers every lineage and ancestry"). pub what: String, - /// Honest limitation, when there is one ("covers only the Y chromosome — no ancestry or + /// Honest limitation, when there is one ("covers only the Y chromosome: no ancestry, and no /// maternal line"). #[serde(default)] pub limits: Option, @@ -62,8 +64,8 @@ pub struct PopEntry { pub blurb: Option, } -/// The bundled/downloaded reference pack. Maps are keyed by haplogroup name / test-type code / -/// population code. +/// The reference pack, from the bundle or from a download. The key of a map is a haplogroup name, +/// a test-type code, or a population code. #[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] pub struct BriefPack { pub version: String, @@ -110,9 +112,10 @@ impl BriefPack { self.test_types.get(code) } - /// Look up `terminal` in `map`; if absent, walk the **root→tip** `lineage` and return the entry - /// for the haplogroup *closest to the tip* that the pack covers. Returns the matched name (which - /// may be an ancestor of `terminal`) and its entry, or `None` if nothing on the lineage is known. + /// Look up `terminal` in `map`. If it is not there, walk the **root→tip** `lineage` and return + /// the entry for the haplogroup *closest to the tip* that the pack covers. Returns the name it + /// matched, which can be an ancestor of `terminal`, and its entry. `None` if the pack covers + /// nothing on the lineage. fn lineage_lookup<'a>( map: &'a HashMap, terminal: &str, @@ -152,7 +155,7 @@ pub enum PackStatus { Cached, /// The bundled seed only (offline / CDN unavailable). Bundled, - /// No pack at all (even the seed failed to parse) — briefs degrade to structured facts. + /// No pack at all (even the seed failed to parse). A brief then degrades to structured facts. Unavailable, } @@ -212,7 +215,7 @@ pub struct AncientComponent { /// The ancestry-composition section. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AncestryBrief { - /// One-line framing, e.g. "Predominantly European". + /// A one-line summary, for example "Predominantly European". pub summary_phrase: String, /// Continental breakdown (carried whole so the UI can reuse the existing donut). pub super_populations: Vec, @@ -222,13 +225,13 @@ pub struct AncestryBrief { pub ancient_pops: Vec, /// Optional plain-language note about the mix (from the reference pack). pub interpretation: Option, - /// How the estimate was made, e.g. "estimated from 412,000 genome-wide markers". + /// What made the estimate, for example "estimated from 412,000 genome-wide markers". pub method_note: String, } -/// The runs-of-homozygosity (relatedness / endogamy) section — present only once ROH has been -/// computed for the subject. F_ROH is the share of the genome in long homozygous runs, which reflects -/// how much recent shared ancestry there is between a person's two parental lines. +/// The runs-of-homozygosity section (relatedness and endogamy). It is here only after ROH runs for +/// the subject. F_ROH is the share of the genome in long homozygous runs. That share shows how much +/// recent shared ancestry the two parental lines of a person have. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RohBrief { /// Inbreeding coefficient F_ROH (0.0–1.0), the physical share of autosomes in runs of homozygosity. @@ -244,7 +247,7 @@ pub struct RohBrief { pub longest_mb: f64, } -/// The "your test & quality" section — always present. +/// The "your test & quality" section. It is always there. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TestBrief { pub test_name: String, @@ -258,11 +261,13 @@ pub struct TestBrief { /// An offer to rebuild one alignment against CHM13, for a reader who should not have to know what /// a reference build is. /// -/// It is deliberately narrow. Realignment costs hours and hundreds of GB, and it buys exactly one -/// thing: Y-chromosome discovery on a reference whose Y is complete. So the offer is only made when -/// there is a paternal line to improve *and* an alignment on an older reference that has not already -/// been realigned. Everyone else — a subject with no Y, a chip-only subject, one already on CHM13 — -/// is never shown it, because for them the answer would not change. +/// It is narrow on purpose. Realignment costs hours and hundreds of GB, and it gives exactly one +/// thing: Y-chromosome discovery on a reference whose Y is complete. +/// +/// So the offer appears only when two things hold. There must be a paternal line to improve, and an +/// alignment on an older reference that no realignment has touched. Everybody else never sees it: a +/// subject with no Y, a chip-only subject, and a subject already on CHM13. For them the answer +/// would not change. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RealignOffer { /// The alignment that would be rebuilt. @@ -271,28 +276,30 @@ pub struct RealignOffer { pub current_build: String, } -/// A casual-reader brief for one subject. Sections are `Option` — each degrades to absent when its -/// data is missing (Y-only test → no maternal line; no haplogroup placed yet → no lineage section). +/// A brief for one subject, for a casual reader. Each section is an `Option`, and it becomes +/// absent when its data is missing. A Y-only test gives no maternal line, and a subject with no +/// haplogroup placed yet gives no lineage section. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SubjectBrief { pub headline: Headline, pub paternal: Option, pub maternal: Option, pub ancestry: Option, - /// Relatedness / endogamy read from runs of homozygosity. Absent until ROH is computed. + /// Relatedness and endogamy, read from runs of homozygosity. Absent until ROH runs. #[serde(default)] pub roh: Option, - /// Archaic (Neanderthal) marker count. Absent until the archaic count is computed. + /// Archaic (Neanderthal) marker count. Absent until that count runs. #[serde(default)] pub archaic: Option, pub test: TestBrief, - /// True when the subject has a sequencing alignment that has not been analyzed yet (data present, - /// no coverage computed) — the signal for the Simple-mode one-click "Analyze" prompt. False for - /// an already-analyzed subject or one with no alignment (chip/VCF-only, nothing to analyze). + /// True when the subject has a sequencing alignment that no analysis has touched: the data is + /// there, and no coverage exists. This is the signal for the one-click "Analyze" prompt in + /// Simple mode. False for a subject that an analysis already covered, and for a subject with no + /// alignment (chip or VCF only, with nothing to analyze). #[serde(default)] pub needs_analysis: bool, - /// An alignment worth realigning to CHM13, when doing so would actually tell the reader - /// something new. Absent whenever it would not — see [`RealignOffer`]. + /// An alignment that is worth a realignment to CHM13, when that would tell the reader + /// something new. Absent whenever it would not. See [`RealignOffer`]. #[serde(default)] pub realign_offer: Option, /// Global uncertainty notes. @@ -300,12 +307,12 @@ pub struct SubjectBrief { /// Loaded pack version (for display), if any. pub pack_version: Option, pub pack_status: PackStatus, - /// True when live AppView/DecodingUs content (haplogroup ages/provenance) was folded in. + /// True when live AppView or DecodingUs content (haplogroup ages, provenance) went into this. pub enriched: bool, } // --------------------------------------------------------------------------------------------- -// Templating (deterministic, unit-tested) +// Templates (deterministic, with unit tests) // --------------------------------------------------------------------------------------------- /// Group an integer with thousands separators ("4000" → "4,000"). Small helper to keep the phrase @@ -343,7 +350,7 @@ fn round_age(ybp: i32) -> i64 { ((y + step / 2) / step) * step } -/// "formed roughly 4,200 years ago" — `None` when the age is unknown. +/// "formed roughly 4,200 years ago". `None` when the age is unknown. pub fn age_phrase(lang: Lang, formed_ybp: Option) -> Option { let ybp = formed_ybp?; if ybp <= 0 { @@ -352,7 +359,7 @@ pub fn age_phrase(lang: Lang, formed_ybp: Option) -> Option { Some(tr_fmt(lang, "brief.agePhrase", &[&group_thousands(round_age(ybp))])) } -/// "associated with the Pontic-Caspian steppe and early Europe" — `None` when unknown. +/// "associated with the Pontic-Caspian steppe and early Europe". `None` when unknown. pub fn origin_phrase(lang: Lang, origin: Option<&str>) -> Option { let o = origin?.trim(); if o.is_empty() { @@ -381,8 +388,8 @@ pub fn confidence_phrase(lang: Lang, confidence: f64, run_count: usize, conflict tr_fmt(lang, key, &[sources]) } -/// Sequencing-depth quality, gated by what the test targets. Returns the phrase and an ok flag -/// (drives a ✓/⚠ chip). A targeted test (Y/mt) is judged on its own target depth, which is much +/// Sequencing-depth quality, gated by what the test targets. Returns the phrase and an ok flag, +/// which drives a ✓/⚠ chip. A targeted test (Y or mt) uses its own target depth. That depth is much /// higher than a WGS average, so the WGS thresholds do not apply. pub fn quality_phrase(lang: Lang, mean_coverage: f64, target: TargetType) -> (String, bool) { let (label_key, ok) = match target { @@ -416,8 +423,8 @@ pub fn quality_phrase(lang: Lang, mean_coverage: f64, target: TargetType) -> (St ) } -/// One-line framing of an ancestry mix from the continental breakdown. Sorts a copy by share so the -/// caller needn't pre-sort. Empty input → a neutral phrase. +/// A one-line summary of an ancestry mix, from the continental breakdown. It sorts a copy by +/// share, so a caller does not have to sort first. Empty input → a neutral phrase. pub fn ancestry_summary(lang: Lang, super_pops: &[SuperPopulationSummary]) -> String { let mut sorted: Vec<&SuperPopulationSummary> = super_pops.iter().collect(); sorted.sort_by(|a, b| b.percentage.total_cmp(&a.percentage)); @@ -442,21 +449,17 @@ pub fn ancestry_summary(lang: Lang, super_pops: &[SuperPopulationSummary]) -> St } } -/// Put the plain-language wording on the runs-of-homozygosity verdict the analysis engine already -/// reached. `pattern` is `navigator_analysis::roh`'s own [`RohPattern`] — the classification is *not* -/// re-derived here, so the Simple brief and the Advanced ROH chart can never disagree about whether -/// a subject reads as outbred, endogamous, or recently consanguineous. Framed strictly as *shared -/// ancestry between the parents' lines* — a genealogical read, never a clinical one. /// The casual-mode read of the archaic (Neanderthal) marker count. /// -/// Deliberately mirrors how the Advanced card frames it: a **count over what the subject's test -/// actually covered**, never a "percent Neanderthal", and no Denisovan figure — outside Oceania that -/// signal sits at the noise floor and reporting it would be inventing a finding (design §7). +/// This says it the same way as the Advanced card, on purpose. It gives a **count over what the +/// test of the subject covered**, never a "percent Neanderthal", and no Denisovan figure. Outside +/// Oceania that signal sits at the noise floor, and to report it would invent a result (design +/// §7). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ArchaicBrief { /// Archaic-derived allele copies carried. pub total_copies: u32, - /// Copies assayed — twice the number of marker sites the subject's data covered. + /// Copies assayed: two times the number of marker sites the data of the subject covered. pub possible_copies: u32, /// Marker sites covered, and how many exist in the panel. pub called_sites: usize, @@ -472,9 +475,9 @@ pub struct ArchaicBrief { /// Build the casual archaic read. /// -/// The pattern comes from the **percentile** where one is available, because a raw count means -/// nothing on its own — it scales with how many sites the test covered. With no percentile the -/// label stays neutral ("Neanderthal markers found") rather than implying a comparison that was not +/// The pattern comes from the **percentile** when one is available. A raw count says nothing on +/// its own, because it scales with how many sites the test covered. With no percentile, the label +/// stays neutral ("Neanderthal markers found"), and does not suggest a comparison that nobody /// made. pub fn archaic_brief( lang: Lang, @@ -503,6 +506,12 @@ pub fn archaic_brief( } } +/// Put the plain-language words on the runs-of-homozygosity verdict that the analysis engine +/// already reached. `pattern` is the [`RohPattern`] of `navigator_analysis::roh` itself. This does +/// *not* derive the classification again. So the Simple brief and the Advanced ROH chart can never +/// disagree about whether a subject reads as outbred, endogamous, or recently consanguineous. The +/// words are strictly about *shared ancestry between the lines of the parents*. That is a +/// genealogical read, and never a clinical one. pub fn roh_brief( lang: Lang, pattern: RohPattern, @@ -557,8 +566,8 @@ pub fn ancestry_method_note(lang: Lang, snps_with_genotype: usize, panel_type: & ) } -/// Quality phrasing for a genotyping array (chip) test, which has no sequencing depth — judged on -/// the number of markers genotyped. +/// Quality words for a genotyping array (chip) test, which has no sequencing depth. The judgement +/// is on how many markers the test genotyped. pub fn chip_quality_phrase(lang: Lang, markers: usize) -> (String, bool) { let count = group_thousands(markers as i64); if markers >= 100_000 { @@ -610,8 +619,9 @@ mod tests { #[test] fn roh_brief_reads_the_pattern() { - // The wording follows the analysis engine's verdict; it is not re-derived from the numbers. - // Trace-endogamy / outbred (James: F_ROH ~0.008): outbred phrasing, no scary numbers cited. + // The words follow the verdict of the analysis engine, and nothing derives them again + // from the numbers. Trace-endogamy or outbred (James: F_ROH ~0.008) gives outbred words, + // and cites no number that would alarm the reader. let outbred = roh_brief(Lang::En, RohPattern::Outbred, 0.008, 6, 22.7, 7.1); assert_eq!(outbred.pattern, "Outbred"); assert!(outbred.summary_phrase.contains("outbred")); @@ -623,7 +633,7 @@ mod tests { let recent = roh_brief(Lang::En, RohPattern::RecentConsanguinity, 0.08, 12, 220.0, 40.0); assert_eq!(recent.pattern, "Recent shared ancestry"); assert!(recent.summary_phrase.contains("few generations")); - // Both classes present — previously mislabelled as "recent" purely because one run was ≥15 Mb. + // Both classes are here. This used to read as "recent" only because one run was ≥15 Mb. 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. @@ -682,7 +692,7 @@ mod tests { ]; let (matched, _) = pack.y_lookup("R-FGC29071", &lineage).unwrap(); assert_eq!(matched, "R-M269"); - // Nothing on the lineage is covered. + // The pack covers nothing on the lineage. assert!(pack.y_lookup("Q-M3", &["Q".into(), "Q-M242".into()]).is_none()); } @@ -704,7 +714,7 @@ mod tests { ancestry_summary(Lang::En, &[sp("European", 92.0)]), "Predominantly European" ); - // Unsorted input is sorted by share. + // The function sorts input by share. assert_eq!( ancestry_summary(Lang::En, &[sp("African", 30.0), sp("European", 70.0)]), "Mostly European, with some African" @@ -732,12 +742,12 @@ mod tests { ); } - /// The point of routing this prose through the catalog: a reader in another language gets it in - /// their own. Before, every one of these sentences was a hardcoded English literal below the UI - /// layer, so the Simple-mode brief was permanently English no matter the chosen locale. + /// Why this prose goes through the catalog: a reader of another language gets it in their own. + /// Before, every one of these sentences was a hardcoded English literal below the UI layer. The + /// Simple-mode brief was then permanently English, whatever locale the user chose. #[test] fn brief_prose_is_localized_not_hardcoded() { - // Sentences differ by language, and the Spanish is real text rather than a key fallback. + // Sentences differ by language, and the Spanish is real text, and not a key fallback. for (en, es) in [ ( age_phrase(Lang::En, Some(4237)).unwrap(), @@ -764,12 +774,12 @@ mod tests { assert!(!es.starts_with("brief."), "rendered a raw key instead of text: {es}"); } - // The numbers survive interpolation in both languages — a template that dropped `{0}` would - // quietly lose the figure the sentence is about. + // The numbers survive interpolation in both languages. A template that dropped `{0}` + // would lose the figure the sentence is about, and give no message. assert!(age_phrase(Lang::Es, Some(4237)).unwrap().contains("4,200")); let es_roh = roh_brief(Lang::Es, RohPattern::Endogamy, 0.035, 40, 90.0, 8.0); assert!(es_roh.summary_phrase.contains("90") && es_roh.summary_phrase.contains("40")); - // And no `{n}` placeholder is left unsubstituted. + // And no `{n}` placeholder stays without a substitution. for text in [ age_phrase(Lang::Es, Some(4237)).unwrap(), es_roh.summary_phrase, diff --git a/crates/navigator-domain/src/chipprofile.rs b/crates/navigator-domain/src/chipprofile.rs index 7dee323c..7ebcb2b4 100644 --- a/crates/navigator-domain/src/chipprofile.rs +++ b/crates/navigator-domain/src/chipprofile.rs @@ -1,8 +1,9 @@ -//! Genotyping-array (chip) profiles — the QC summary of a vendor raw-data export +//! Genotyping-array (chip) profiles: the QC summary of a vendor raw-data export //! (23andMe, AncestryDNA, MyHeritage, …), a pragmatic port of the Scala `ChipProfile`. -//! We do not keep every genotype (a chip is ~600–700k markers); we keep the call/no-call/ -//! het summary and per-region counts that drive quality and downstream eligibility. -//! [`summarize`] is a pure pass over the file text (no IO) that also guesses the vendor. +//! We do not keep every genotype, because a chip is ~600–700k markers. We keep the call, +//! no-call and het summary, and the counts for each region that drive quality and downstream +//! eligibility. [`summarize`] is a pure pass over the file text (no IO) that also guesses the +//! vendor. use du_domain::ids::SampleGuid; use serde::{Deserialize, Serialize}; @@ -31,12 +32,12 @@ pub struct ChipProfile { pub chip_version: Option, pub summary: ChipSummary, pub source_file_name: Option, - /// Absolute path of the imported raw-data file, for re-reading the autosomal genotypes on - /// demand (ancestry). `None` for older rows imported before this was tracked. + /// Absolute path of the imported raw-data file, to read the autosomal genotypes again on + /// demand (ancestry). `None` for an older row that an import made before this field existed. pub source_path: Option, } -/// Fields for creating a chip profile (the store assigns the id). +/// Fields to make a chip profile (the store assigns the id). #[derive(Debug, Clone, PartialEq)] pub struct NewChipProfile { pub biosample_guid: SampleGuid, @@ -57,8 +58,8 @@ enum Zygosity { Het, } -/// Classify a genotype token (e.g. "AA", "AG", "--", "00", "DI"). Non-A/C/G/T characters -/// are ignored, so any all-symbol token (no-call, indel) classifies as a no-call. +/// Classify a genotype token (for example "AA", "AG", "--", "00", "DI"). This drops any character +/// that is not A, C, G or T, so a token of symbols only (no-call, indel) classifies as a no-call. fn classify(genotype: &str) -> Zygosity { let bases: Vec = genotype .trim() @@ -92,7 +93,7 @@ fn region(chrom: &str) -> Region { } } -/// Is this row a header (`rsid …`) rather than data? +/// Is this row a header (`rsid …`) and not data? fn is_header(first_field: &str) -> bool { let f = first_field.trim().trim_matches('"').to_ascii_lowercase(); f == "rsid" || f == "rs_id" || f == "snp" || f == "#rsid" @@ -211,9 +212,9 @@ pub enum ChipDna { Mt, } -/// A single haploid Y or mtDNA genotype pulled from a chip export — the raw observed allele on -/// the vendor's reference build, for on-import haplogroup placement. (Consumer arrays report -/// Y/MT as a single haploid base; we keep only unambiguous single-base calls.) +/// A single haploid Y or mtDNA genotype from a chip export. It is the raw observed allele on the +/// reference build of the vendor, for haplogroup placement at import. (A consumer array reports Y +/// and MT as one haploid base, and we keep only single-base calls that are not ambiguous.) #[derive(Debug, Clone, PartialEq)] pub struct ChipHaploCall { pub dna: ChipDna, @@ -223,9 +224,9 @@ pub struct ChipHaploCall { pub base: char, } -/// The single haploid base of a genotype token, or `None` if it is a no-call, an indel -/// (`I`/`D`), or heterozygous (two different bases — on a true haploid Y/MT that is -/// contamination, so we drop it rather than guess). +/// The single haploid base of a genotype token. `None` if it is a no-call, an indel (`I`/`D`), or +/// heterozygous, which is two different bases. On a true haploid Y or MT, two bases are +/// contamination, so we drop the token and do not guess. fn haploid_base(genotype: &str) -> Option { let mut bases = genotype .bytes() @@ -237,7 +238,7 @@ fn haploid_base(genotype: &str) -> Option { /// Extract the Y and mtDNA haploid calls from a vendor raw-data export, for on-import /// haplogroup placement. Skips autosomal/X rows, no-calls, indels, and heterozygous calls. -/// Positions are on the vendor build (consumer arrays are GRCh37 — see [`detect_build`]). +/// Positions are on the vendor build (a consumer array is GRCh37, see [`detect_build`]). /// Pairs with [`summarize`]: same row layouts (tab/comma, optional `#` header, then /// `rsid,chrom,pos,genotype` or `rsid,chrom,pos,allele1,allele2`). pub fn haplo_calls(text: &str) -> Vec { @@ -274,8 +275,9 @@ pub fn haplo_calls(text: &str) -> Vec { out } -/// A single autosomal diploid genotype from a chip export — the two observed alleles at a SNP, on -/// the vendor build (GRCh37). Fed (after liftover to the panel build) into the ancestry estimators. +/// A single autosomal diploid genotype from a chip export: the two observed alleles at a SNP, on +/// the vendor build (GRCh37). It goes to the ancestry estimators after a liftover to the panel +/// build. #[derive(Debug, Clone, PartialEq)] pub struct ChipAutosomalCall { /// Chromosome, normalized to `chr1`..`chr22`. @@ -285,8 +287,8 @@ pub struct ChipAutosomalCall { pub a2: char, } -/// The two A/C/G/T bases of a diploid genotype token (`"AG"`, or two allele columns joined), or -/// `None` for a no-call / indel / not-exactly-two-bases token. +/// The two A/C/G/T bases of a diploid genotype token (`"AG"`, or two allele columns joined). +/// `None` for a token that is a no-call, an indel, or not exactly two bases. fn diploid_bases(genotype: &str) -> Option<(char, char)> { let bases: Vec = genotype .bytes() @@ -299,7 +301,8 @@ fn diploid_bases(genotype: &str) -> Option<(char, char)> { /// Extract the **autosomal** diploid SNP calls from a vendor raw-data export, for ancestry. Keeps /// only chr1–22, called, biallelic-SNP rows (drops Y/MT/X, no-calls, indels). Same row layouts as -/// [`summarize`]/[`haplo_calls`]. Positions are on the vendor build (GRCh37 — see [`detect_build`]). +/// [`summarize`] and [`haplo_calls`]. Positions are on the vendor build (GRCh37, see +/// [`detect_build`]). pub fn autosomal_calls(text: &str) -> Vec { let mut out = Vec::new(); for raw in text.lines() { @@ -315,7 +318,8 @@ pub fn autosomal_calls(text: &str) -> Vec { if !matches!(region(cols[1]), Region::Autosomal) { continue; } - // Normalize the chromosome to chrN (1..22) — matches the CHM13 panel + liftover contig naming. + // Normalize the chromosome to chrN (1..22). This matches the contig names of the CHM13 + // panel and the liftover. let core = crate::contig::bare(cols[1].trim().trim_matches('"')).to_ascii_lowercase(); let Ok(position) = cols[2].parse::() else { continue }; let genotype = if cols.len() >= 5 { @@ -336,9 +340,9 @@ pub fn autosomal_calls(text: &str) -> Vec { out } -/// The reference build a vendor export is reported on. Consumer arrays (23andMe v4/v5, -/// AncestryDNA v1/v2) are GRCh37, so that is the default; a header naming build 38 / GRCh38 / -/// hg38 overrides it. Scans only the comment header. +/// The reference build that a vendor export uses. Consumer arrays (23andMe v4/v5, AncestryDNA +/// v1/v2) are GRCh37, so that is the default. A header that names build 38, GRCh38 or hg38 +/// overrides it. This scans only the comment header. pub fn detect_build(text: &str) -> String { for raw in text.lines() { let line = raw.trim(); diff --git a/crates/navigator-domain/src/consensus.rs b/crates/navigator-domain/src/consensus.rs index 3e7d324c..480683b8 100644 --- a/crates/navigator-domain/src/consensus.rs +++ b/crates/navigator-domain/src/consensus.rs @@ -1,15 +1,19 @@ -//! Multi-source variant **consensus engine** — DNA-type-agnostic. +//! Multi-source variant **consensus engine**, with no dependence on the DNA type. //! -//! Given a set of sources (a WGS alignment's placement, a chip/BISDNA panel, a private bucket, …), -//! each contributing per-variant calls keyed **by name** (build-independent — M269 is M269 whether -//! the source aligned to GRCh37 or GRCh38), [`reconcile`] groups them and weight-votes the consensus -//! state, classifying each variant as confirmed / novel / conflict / single-source and computing a -//! quality-weighted confidence. Mirrors the Scala `YVariantConcordance`. +//! The input is a set of sources: the placement of a WGS alignment, a chip or BISDNA panel, a +//! private bucket, and others. Each source gives calls at each variant, with the **name** as the +//! key. The name is independent of the build, because M269 is M269 whether the source aligned to +//! GRCh37 or to GRCh38. //! -//! This engine is the shared foundation for the Y-DNA profile (the [`crate::yprofile`] adapter today) -//! and — by design — the future mtDNA (variants vs rCRS) and autosomal consumers. It carries no -//! DNA-type specifics: callers gather observations and supply the variant identity; the DNA type and -//! consensus label (haplogroup, where applicable) live at the persistence / app layer. +//! [`reconcile`] groups those calls and weight-votes the consensus state. It classifies each +//! variant as confirmed, novel, conflict, or single-source, and it calculates a quality-weighted +//! confidence. This mirrors the Scala `YVariantConcordance`. +//! +//! This engine is the shared foundation for the Y-DNA profile, through the [`crate::yprofile`] +//! adapter today. By design it is also the foundation for the future mtDNA consumer (variants vs +//! rCRS) and the future autosomal consumer. It holds nothing specific to a DNA type. A caller +//! collects the observations and gives the variant identity. The DNA type, and the consensus label +//! (a haplogroup, where that applies), live at the persistence layer and the app layer. use std::collections::BTreeMap; @@ -20,8 +24,9 @@ use crate::variants::SourceType; /// One source's call state at a variant position. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ConsensusState { - /// Carries the derived (mutant) allele — positive for the variant's branch. For mtDNA this is - /// "differs from rCRS"; for autosomes a future adapter maps a diploid genotype onto this axis. + /// Carries the derived (mutant) allele, so it is positive for the branch of the variant. For + /// mtDNA this is "differs from rCRS". For autosomes, a future adapter maps a diploid genotype + /// onto this axis. Derived, /// Carries the ancestral (reference) allele. Ancestral, @@ -40,15 +45,17 @@ pub enum ConsensusStatus { Conflict, /// Only one source reports the variant. SingleSource, - /// Has data but the weighted confidence is below the confirmation threshold without crossing the - /// conflict line (rare — kept for parity with the Scala `YVariantConcordance`). + /// It has data, but the weighted confidence is below the confirmation threshold, and it does + /// not reach the conflict line. This is rare, and it stays for parity with the Scala + /// `YVariantConcordance`. Pending, /// No source made a confident call (every observation was NoCall). NoCoverage, } -/// Per-position callability of a source's observation — scales its concordance weight (a base in a -/// no-coverage / poor-mapping region carries little confidence). Mirrors the Scala `YCallableState`. +/// How callable each position of an observation is. It scales the concordance weight of that +/// observation, because a base in a region with no coverage, or with poor mapping, carries little +/// confidence. Mirrors the Scala `YCallableState`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CallableState { Callable, @@ -77,10 +84,11 @@ pub struct SourceObs { pub label: String, pub source_type: SourceType, pub state: ConsensusState, - /// The **observed base** (allele) this source called at the variant — `None` for a no-call, or - /// for sources/legacy profiles that carry only a state. Persisting the base (not just the - /// derived/ancestral interpretation) lets the state be re-[`impute_state`]d against a corrected - /// or different tree polarity via [`reproject`] — without re-reading the BAM/CRAM. + /// The **observed base** (allele) this source called at the variant. `None` for a no-call, and + /// for a source or legacy profile that carries only a state. The store keeps the base, and not + /// only the derived or ancestral reading of it. [`reproject`] can then run [`impute_state`] + /// again against a corrected tree polarity, or a different one, and it never reads the BAM or + /// CRAM again. #[serde(default)] pub base: Option, } @@ -94,10 +102,11 @@ pub struct ConsensusVariant { pub position: i64, pub ancestral: String, pub derived: String, - /// The **consensus observed base** — the weighted-majority nucleotide across sources (strand- - /// normalized to this SNP's alleles). This is the primary observation; [`consensus`](Self::consensus) - /// is its derived/ancestral interpretation against the tree. `None` = no source made a call. A base - /// matching neither allele (a genuine third allele) survives here as itself. + /// The **consensus observed base**: the weighted-majority nucleotide over the sources, with the + /// strand normalized to the alleles of this SNP. This is the primary observation. + /// [`consensus`](Self::consensus) is its derived or ancestral reading against the tree. `None` + /// means no source made a call. A base that matches neither allele is a genuine third allele, + /// and it survives here as itself. #[serde(default)] pub consensus_base: Option, pub consensus: ConsensusState, @@ -114,7 +123,7 @@ pub struct ConsensusVariant { pub sources: Vec, } -/// Per-status counts for the profile header. +/// Counts for each status, for the profile header. #[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] pub struct ConsensusSummary { pub total: usize, @@ -128,11 +137,12 @@ pub struct ConsensusSummary { } // --------------------------------------------------------------------------------------------- -// Observation-first storage. A persisted profile holds only OBSERVATIONS — per-SNP, per-source -// observed bases + quality + identity — never a baked derived/ancestral interpretation. The state, -// vote, status, support, and summary are computed on demand by [`interpret`] against the CURRENT -// tree's polarity, so a tree-polarity fix (or provider switch) corrects every view with no -// re-genotyping. This is the type actually written to the `consensus_profile` payload. +// Observation-first storage. A persisted profile holds only OBSERVATIONS: for each SNP and each +// source, the observed base, the quality, and the identity. It never holds a fixed derived or +// ancestral reading. [`interpret`] calculates the state, the vote, the status, the support and the +// summary on demand, against the polarity of the CURRENT tree. So a fix to the tree polarity, or a +// switch of provider, corrects every view, and nothing genotypes again. This is the type the code +// writes to the `consensus_profile` payload. // --------------------------------------------------------------------------------------------- fn one() -> f64 { @@ -142,10 +152,11 @@ fn schema_v1() -> u8 { 1 } -/// One source's raw observation of a variant — the **observed base** plus the quality inputs to the -/// concordance weight. Carries no derived/ancestral state: that is [`impute_state`]d at read time. -/// (Persisting depth/MQ/callable/region — which `reconcile`'s in-memory tally used but never stored — -/// lets [`interpret`] re-weight exactly, fixing the quality-loss the old `reproject` warned about.) +/// The raw observation of a variant by one source: the **observed base**, plus the quality inputs +/// to the concordance weight. It carries no derived or ancestral state, because [`impute_state`] +/// makes that at read time. The store keeps depth, MQ, callable and region. The in-memory tally of +/// `reconcile` used those and never stored them. Now [`interpret`] can weight exactly, which fixes +/// the loss of quality that the old `reproject` warned about. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ObservedSource { pub label: String, @@ -163,10 +174,10 @@ pub struct ObservedSource { pub region_modifier: f64, } -/// A variant observed across the subject's sources — identity + each source's observed base. The -/// derived/ancestral polarity comes from the current tree at [`interpret`] time (by name); for an -/// off-tree novel/private call — and for mtDNA mutations absent from the tree map — the stored -/// `ref_allele`/`alt_allele` are the polarity fallback. +/// A variant that the sources of the subject observed: the identity, plus the observed base from +/// each source. The derived and ancestral polarity comes from the current tree at [`interpret`] +/// time, by name. The stored `ref_allele` and `alt_allele` are the polarity fallback. That fallback +/// serves a novel or private call off the tree, and an mtDNA mutation the tree map does not hold. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ObservedVariant { /// Variant name (e.g. "M269"); empty for a novel/unnamed call (then keyed by position). @@ -180,7 +191,8 @@ pub struct ObservedVariant { pub sources: Vec, } -/// One contributing source's provenance (label, type, count) — non-interpretive, carried for display. +/// The provenance of one source that contributes (label, type, count). It reads nothing into the +/// data, and it is here for display. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SourceSummary { pub label: String, @@ -192,20 +204,21 @@ pub struct SourceSummary { /// display view (`ConsensusVariant` + `ConsensusSummary`) on demand by [`interpret`]. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ObservedProfile { - /// Payload schema tag — presence distinguishes this from a legacy baked `ConsensusProfile` JSON. + /// Payload schema tag. If it is there, this is not a legacy fixed `ConsensusProfile` JSON. #[serde(default = "schema_v1")] pub schema_version: u8, pub variants: Vec, #[serde(default)] pub sources: Vec, - /// The placement's terminal haplogroup label (a placement output, not a per-SNP interpretation). + /// The terminal haplogroup label of the placement. It is an output of the placement, and not a + /// reading of each SNP. #[serde(default)] pub terminal_hint: Option, } -/// One source's call at a variant, fed into [`reconcile`]. Quality fields refine the concordance -/// weight (see [`obs_weight`]); sources that do not carry them (chip, tree placement) leave them -/// `None` / `1.0` and fall back to the plain source-type weight. +/// The call of one source at a variant, for [`reconcile`]. The quality fields refine the +/// concordance weight (see [`obs_weight`]). A source that does not carry them (a chip, a tree +/// placement) leaves them `None` or `1.0`, and falls back to the plain source-type weight. #[derive(Debug, Clone, PartialEq)] pub struct ConsensusObs { pub name: String, @@ -220,21 +233,21 @@ pub struct ConsensusObs { /// Whether this variant is a known tree/reference variant (true for placement SNPs, false for /// private calls). pub in_tree: bool, - /// Read depth at the call (sequencing sources) — a `√depth/10` bonus, capped at +1.0. + /// Read depth at the call (sequencing sources): a `√depth/10` bonus, with a cap of +1.0. pub depth: Option, - /// Mean mapping quality — an `MQ/60` factor, capped at 1.0. + /// Mean mapping quality: an `MQ/60` factor, with a cap of 1.0. pub mapq: Option, - /// Callability of the position — scales the weight (`NoCoverage`/`RefN` → 0). + /// How callable the position is. It scales the weight (`NoCoverage`/`RefN` → 0). pub callable: Option, /// Region-confidence modifier (e.g. <1 in palindrome/amplicon zones), clamped [0.1, 1.0]. pub region_modifier: f64, } impl ConsensusObs { - /// A SNP/variant observation with no per-call quality data (weight = the source-type weight). - /// Quality fields can be set afterward for sources that carry them (e.g. sequencing depth). - /// `base` is left `None`; for an observation that carries its called allele use - /// [`ConsensusObs::observed`]. + /// A SNP or variant observation with no quality data for the call, so the weight is the + /// source-type weight. A caller can set the quality fields after this, for a source that + /// carries them (for example sequencing depth). `base` stays `None`. For an observation that + /// carries its called allele, use [`ConsensusObs::observed`]. pub fn snp( name: impl Into, position: i64, @@ -258,9 +271,9 @@ impl ConsensusObs { } } - /// A SNP/variant observation carrying the **observed base**; the state is imputed from the base - /// against the variant's polarity ([`impute_state`]) and the base is retained for later - /// re-imputation ([`reproject`]). `base = None` means a no-call (`NoCall`). + /// A SNP or variant observation with the **observed base**. [`impute_state`] makes the state + /// from that base against the polarity of the variant, and the base stays for a later + /// imputation ([`reproject`]). `base = None` means a no-call (`NoCall`). pub fn observed( name: impl Into, position: i64, @@ -299,40 +312,42 @@ fn complement_base(b: char) -> char { } } -/// Whether a SNP's two alleles are strand-ambiguous (`A↔T` / `C↔G`): the complement of one allele -/// equals the other, so strand can't be inferred from the observed base. +/// True when the two alleles of a SNP are strand-ambiguous (`A↔T` / `C↔G`). The complement of one +/// allele is the other, so the observed base does not give the strand. fn strand_ambiguous(a: char, d: char) -> bool { let mut pair = [a.to_ascii_uppercase(), d.to_ascii_uppercase()]; pair.sort_unstable(); pair == ['A', 'T'] || pair == ['C', 'G'] } -/// Impute a [`ConsensusState`] from an observed `base` against a variant's `ancestral`/`derived` -/// alleles. The canonical projection that turns a stored base back into derived/ancestral — applied -/// at genotyping time ([`ConsensusObs::observed`]) and re-applied against corrected polarity by -/// [`reproject`]. Accepts the strand-complement of the alleles (some trees record a SNP on the -/// opposite strand from the reference) except for strand-ambiguous SNPs, where literal matching is -/// kept. A base matching neither strand of either allele, or no base, is `NoCall`. -/// -/// Mirrors `navigator_analysis::haplo::locus_state` (which operates on the analysis `CallState` / -/// `Locus` types); keep the two in step. -/// Sentinel observed "base" for an indel locus the sample **carries** (derived), written by the -/// indel genotyper (`navigator_analysis::caller::call_indels_at`). Mirrors +/// Sentinel observed "base" for an indel locus the sample **carries** (derived). The indel +/// genotyper (`navigator_analysis::caller::call_indels_at`) writes it. Mirrors /// `navigator_analysis::haplo::INDEL_DERIVED`. pub const INDEL_DERIVED: char = '+'; /// Sentinel for an indel locus the sample does not carry (ancestral). Mirrors `haplo::INDEL_ANCESTRAL`. pub const INDEL_ANCESTRAL: char = '-'; +/// Make a [`ConsensusState`] from an observed `base`, against the `ancestral` and `derived` alleles +/// of a variant. This is the canonical projection that turns a stored base back into derived or +/// ancestral. Genotyping applies it ([`ConsensusObs::observed`]), and [`reproject`] applies it again +/// against a corrected polarity. It accepts the strand-complement of the alleles, because some +/// trees record a SNP on the strand opposite to the reference. For a strand-ambiguous SNP it keeps +/// a literal match. A base that matches neither strand of either allele, and no base at all, is +/// `NoCall`. +/// +/// Mirrors `navigator_analysis::haplo::locus_state`, which works on the analysis `CallState` and +/// `Locus` types. Keep the two in step. pub fn impute_state(base: Option, ancestral: &str, derived: &str) -> ConsensusState { - // Indel / MNP (multi-character allele): a single *base* can't evaluate it, but the indel - // genotyper resolves it and passes its verdict as a sentinel — honor that first. + // Indel or MNP (an allele of more than one character). One *base* can not evaluate it. But the + // indel genotyper resolves it and passes its verdict as a sentinel, so obey that first. match base { Some(INDEL_DERIVED) => return ConsensusState::Derived, Some(INDEL_ANCESTRAL) => return ConsensusState::Ancestral, _ => {} } - // Otherwise a multi-base allele with a raw base observation can't be evaluated (an insertion or - // deletion shares its anchor base, so a first-base compare would read every sample as derived). + // If not, nothing can evaluate a multi-base allele from a raw base observation. An insertion + // or a deletion shares its anchor base, so a compare of the first base would read every sample + // as derived. if ancestral.chars().count() > 1 || derived.chars().count() > 1 { return ConsensusState::NoCall; } @@ -383,13 +398,15 @@ pub fn obs_weight( method * (1.0 + depth_bonus) * mapq_factor * callable_factor * region_factor } -/// Fraction of disagreeing (weighted) support above which a variant is a conflict. +/// The weighted share of support that does not agree, above which a variant is a conflict. const CONFLICT_FRACTION: f64 = 0.30; -/// Consensus confidence at or above which a multi-source, non-conflicting variant is confirmed. +/// Consensus confidence at or above which a variant from more than one source, with no conflict, +/// counts as confirmed. const CONFIRMATION_FRACTION: f64 = 0.70; -/// Key a variant for cross-source/cross-build grouping: by name when present (build-independent), -/// else by position (a novel/unnamed call only ever matches the same build's same position). +/// Key a variant, so that it groups across sources and across builds. Use the name when there is +/// one, which is independent of the build. If not, use the position: a novel or unnamed call only +/// ever matches the same position on the same build. fn group_key(obs: &ConsensusObs) -> String { if obs.name.trim().is_empty() { format!("@{}", obs.position) @@ -398,11 +415,14 @@ fn group_key(obs: &ConsensusObs) -> String { } } -/// Strand-normalize an observed base to this SNP's allele space: if it matches an allele keep it; -/// if (for a non-strand-ambiguous SNP) its complement matches an allele, use the complement (an -/// opposite-strand read); otherwise keep it as-is — a genuine third allele that survives the vote as -/// itself rather than being discarded. Compares the first base of each allele (SNPs are single-base; -/// indel alleles fall through to a literal compare). +/// Normalize the strand of an observed base into the allele space of this SNP. If the base matches +/// an allele, keep it. If the SNP is not strand-ambiguous, and the complement of the base matches +/// an allele, use the complement. That is a read on the opposite strand. If neither, keep the +/// base as it is: it is a genuine third allele, it survives the vote as itself, and nothing drops +/// it. +/// +/// This compares the first base of each allele, because a SNP is one base. An indel allele falls +/// through to a literal compare. fn canonicalize_base(base: char, ancestral: &str, derived: &str) -> String { let b = base.to_ascii_uppercase(); let a = ancestral.chars().next().map(|c| c.to_ascii_uppercase()); @@ -420,7 +440,7 @@ fn canonicalize_base(base: char, ancestral: &str, derived: &str) -> String { b.to_string() } -/// The voted outcome over one variant's per-source **observed bases**. +/// The voted outcome over the **observed bases** of one variant, from each source. struct BaseTally { /// The weighted-majority base (already strand-normalized), or `None` when no source called. consensus_base: Option, @@ -428,14 +448,15 @@ struct BaseTally { support: usize, /// Sources with a call (base present). total: usize, - /// Winning base weight / total weight. + /// The weight of the base that won, divided by the total weight. confidence_score: f64, } -/// Weight-vote a variant's per-source **canonicalized bases** into a consensus base. This is the -/// observation-first core: the consensus is the actual nucleotide the sources agree on (any of -/// A/C/G/T, incl. a third allele), not a binary derived/ancestral collapse — the state is derived -/// afterward by [`impute_state`]ing the consensus base against the tree. +/// Weight-vote the **canonical bases** of a variant, from each source, into a consensus base. This +/// is the observation-first core. The consensus is the real nucleotide the sources agree on, which +/// is any of A/C/G/T, and a third allele too. It is not a collapse into a binary derived or +/// ancestral value. [`impute_state`] makes the state afterward, from the consensus base against the +/// tree. fn tally_bases(obs: &[(Option, f64)]) -> BaseTally { let mut weights: BTreeMap = BTreeMap::new(); let mut counts: BTreeMap = BTreeMap::new(); @@ -455,7 +476,8 @@ fn tally_bases(obs: &[(Option, f64)]) -> BaseTally { confidence_score: 0.0, }; } - // Argmax by weight; ties broken by more raw supporting sources, then a stable lexical order. + // Argmax by weight. A tie goes to the base with more raw sources behind it, then to a stable + // lexical order. let consensus_base = weights .iter() .max_by(|a, b| { @@ -490,7 +512,8 @@ fn status_of(state: ConsensusState, in_tree: bool, total: usize, confidence_scor } else if minority_fraction > CONFLICT_FRACTION { ConsensusStatus::Conflict } else if state == ConsensusState::Derived && !in_tree { - // Derived off-tree call is novel/private — even from a single source (the common case). + // A derived call that is off the tree is novel or private, even from one source, which is + // the common case. ConsensusStatus::Novel } else if total == 1 { ConsensusStatus::SingleSource @@ -501,11 +524,13 @@ fn status_of(state: ConsensusState, in_tree: bool, total: usize, confidence_scor } } -/// Group per-source [`ConsensusObs`] into an [`ObservedProfile`] — the persisted, observation-only -/// form. Groups by name (build-independent) else position, keeping each source's observed base + -/// quality; the state is NOT stored (it is [`interpret`]ed on read). The representative's -/// ancestral/derived become the variant's `ref_allele`/`alt_allele` polarity fallback (used for -/// off-tree novel calls and mtDNA mutations absent from the tree map). +/// Group the [`ConsensusObs`] of each source into an [`ObservedProfile`], which is the persisted +/// form that holds observations only. It groups by name, which is independent of the build, and by +/// position when there is no name. It keeps the observed base and the quality of each source. It +/// does NOT store the state, because [`interpret`] makes that on read. The ancestral and derived +/// alleles of the representative become the `ref_allele` and `alt_allele` polarity fallback of the +/// variant. That fallback serves a novel call off the tree, and an mtDNA mutation the tree map does +/// not hold. pub fn to_observed(sources: &[(String, SourceType, Vec)]) -> ObservedProfile { struct Acc { repr: ConsensusObs, @@ -560,17 +585,18 @@ pub fn to_observed(sources: &[(String, SourceType, Vec)]) -> Obser } } -/// Interpret an [`ObservedProfile`] against a `polarity` map (`SNP name → (ancestral, derived)`, e.g. -/// from the current DecodingUs/FTDNA/rCRS tree) into the display view — the reconciled -/// [`ConsensusVariant`]s + [`ConsensusSummary`]. This is the whole point of observation-first -/// storage: state/status/support/consensus are derived here, fresh, from each source's **observed -/// base** against the **current** polarity — so a corrected tree flips every view with no -/// re-genotyping. +/// Interpret an [`ObservedProfile`] against a `polarity` map (`SNP name → (ancestral, derived)`, +/// for example from the current DecodingUs, FTDNA or rCRS tree). The output is the display view: +/// the reconciled [`ConsensusVariant`] list and the [`ConsensusSummary`]. This is the whole point of +/// observation-first storage. The state, the status, the support and the consensus come fresh from +/// the **observed base** of each source, against the **current** polarity. So a corrected tree +/// changes every view, and nothing genotypes again. /// -/// Per variant: resolve polarity from the map by upper-cased name, else fall back to the stored -/// `ref_allele`/`alt_allele` (novel/private, and mtDNA mutations not in the map). Each source's state -/// is [`impute_state`]d from its base (base-less sources → `NoCall`), weighted by [`obs_weight`] over -/// the persisted quality, then [`tally_states`]d. +/// For each variant, resolve the polarity from the map by the upper-case name. If it is not there, +/// fall back to the stored `ref_allele` and `alt_allele`. That covers a novel or private call, and +/// an mtDNA mutation the map does not hold. [`impute_state`] makes the state of each source from +/// its base, and a source with no base gives `NoCall`. [`obs_weight`] weights it over the persisted +/// quality, and [`tally_states`] then counts it. pub fn interpret( observed: &ObservedProfile, polarity: &BTreeMap, @@ -596,8 +622,9 @@ pub fn interpret( let mut bases = Vec::with_capacity(v.sources.len()); for s in &v.sources { let base = s.base.as_deref().and_then(|b| b.chars().next()); - // Vote the actual nucleotide (strand-normalized to this SNP's alleles), not a binary - // derived/ancestral collapse — so multiallelic / third-allele calls survive. + // Vote the real nucleotide, with the strand normalized to the alleles of this SNP. + // Do not collapse it into a binary derived or ancestral value, so that a + // multiallelic call and a third-allele call survive. let canonical = base.map(|b| canonicalize_base(b, &ancestral, &derived)); let weight = obs_weight(s.source_type, s.depth, s.mapq, s.callable, s.region_modifier); bases.push((canonical, weight)); @@ -640,11 +667,12 @@ pub fn interpret( (out, summary) } -/// Reconcile per-source variant observations into the display view. Convenience wrapper: group into -/// an [`ObservedProfile`] then [`interpret`] against each variant's **own** stored polarity (empty -/// map → the observations' `ancestral`/`derived`). New code that persists should call [`to_observed`] -/// and interpret against the current tree, so a polarity fix propagates. Each source's state is -/// imputed from its observed base — a base-less observation is a `NoCall`. +/// Reconcile the variant observations of each source into the display view. This is a convenience +/// wrapper: it groups into an [`ObservedProfile`], then it runs [`interpret`] against the **own** +/// stored polarity of each variant. An empty map gives the `ancestral` and `derived` of the +/// observations. New code that persists must call [`to_observed`] and interpret against the current +/// tree, so that a polarity fix reaches everything. [`impute_state`] makes the state of each source +/// from its observed base, and an observation with no base is a `NoCall`. pub fn reconcile(sources: &[(String, SourceType, Vec)]) -> Vec { interpret(&to_observed(sources), &BTreeMap::new()).0 } @@ -660,7 +688,7 @@ fn status_rank(s: ConsensusStatus) -> u8 { } } -/// Per-status counts + overall confidence over a reconciled variant list. +/// Counts for each status, and the overall confidence, over a reconciled variant list. pub fn summarize(variants: &[ConsensusVariant]) -> ConsensusSummary { let mut s = ConsensusSummary { total: variants.len(), @@ -686,13 +714,14 @@ pub fn summarize(variants: &[ConsensusVariant]) -> ConsensusSummary { } // --------------------------------------------------------------------------------------------- -// Diploid (autosomal) reconciler — the same quality-weighting + status taxonomy + summary, but -// voting a three-class genotype (alt-allele dosage 0/1/2) instead of a binary derived/ancestral -// state. The autosomal adapter genotypes each source over a fixed site panel and reconciles here. +// Diploid (autosomal) reconciler. It has the same quality weights, the same status taxonomy, and +// the same summary. But it votes a genotype of three classes (alt-allele dosage 0/1/2), and not a +// binary derived or ancestral state. The autosomal adapter genotypes each source over a fixed site +// panel, and reconciles here. // --------------------------------------------------------------------------------------------- -/// One source's diploid call at an autosomal site, fed into [`reconcile_diploid`]. `dosage` is the -/// alt-allele count 0/1/2, or -1 for a no-call. `depth` drives the per-call weight bonus. +/// The diploid call of one source at an autosomal site, for [`reconcile_diploid`]. `dosage` is the +/// alt-allele count 0/1/2, or -1 for a no-call. `depth` drives the weight bonus of the call. #[derive(Debug, Clone, PartialEq)] pub struct DiploidObs { pub name: String, @@ -712,7 +741,7 @@ pub struct DiploidSourceObs { pub dosage: i8, } -/// A reconciled autosomal site across the subject's sources — a voted diploid genotype. +/// A reconciled autosomal site over the sources of the subject: a voted diploid genotype. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DiploidVariant { pub name: String, @@ -732,10 +761,10 @@ pub struct DiploidVariant { pub sources: Vec, } -/// Reconcile per-source diploid genotype calls into one profile, keyed by site name (rsID — -/// build-independent). Mirrors [`reconcile`] but votes a three-class genotype (dosage 0/1/2) -/// instead of a binary derived/ancestral state. `Novel` never applies — every site is a known -/// panel site. +/// Reconcile the diploid genotype calls of each source into one profile. The key is the site name, +/// an rsID, which is independent of the build. Mirrors [`reconcile`], but it votes a genotype +/// of three classes (dosage 0/1/2), and not a binary derived or ancestral state. `Novel` never +/// applies, because every site is a known panel site. pub fn reconcile_diploid(sources: &[(String, SourceType, Vec)]) -> Vec { struct ObsRec { label: String, @@ -782,7 +811,7 @@ pub fn reconcile_diploid(sources: &[(String, SourceType, Vec)]) -> V total += 1; } } - // argmax weight; tie → more raw supporting sources, then the lower dosage. + // argmax weight. A tie goes to more raw sources behind it, then to the lower dosage. let mut best = 0usize; for d in 1..3 { if w[d] > w[best] || (w[d] == w[best] && counts[d] > counts[best]) { @@ -850,8 +879,9 @@ pub fn reconcile_diploid(sources: &[(String, SourceType, Vec)]) -> V out } -/// Per-status counts + overall confidence over a reconciled diploid variant list. `Novel` does not -/// apply to autosomal sites, so the confidence is `(confirmed − 0.5·conflict) / total`. +/// Counts for each status, and the overall confidence, over a reconciled diploid variant list. +/// `Novel` does not apply to an autosomal site, so the confidence is +/// `(confirmed − 0.5·conflict) / total`. pub fn summarize_diploid(variants: &[DiploidVariant]) -> ConsensusSummary { let mut s = ConsensusSummary { total: variants.len(), @@ -877,8 +907,8 @@ pub fn summarize_diploid(variants: &[DiploidVariant]) -> ConsensusSummary { mod tests { use super::*; - // Build an observation carrying a base consistent with the desired state (anc=A, der=G), so the - // observation-first path (`to_observed` → `interpret`) re-derives that state from the base. + // Build an observation with a base that agrees with the wanted state (anc=A, der=G). The + // observation-first path (`to_observed` → `interpret`) then derives that state from the base. fn obs(name: &str, pos: i64, state: ConsensusState, in_tree: bool) -> ConsensusObs { let base = match state { ConsensusState::Derived => Some('G'), @@ -893,7 +923,8 @@ mod tests { // Literal matches. assert_eq!(impute_state(Some('G'), "A", "G"), ConsensusState::Derived); assert_eq!(impute_state(Some('A'), "A", "G"), ConsensusState::Ancestral); - // Opposite-strand reads match via the complement (non-ambiguous A>C: comp T/G). + // A read on the opposite strand matches through 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 @@ -907,8 +938,8 @@ mod tests { #[test] fn impute_state_indel_is_nocall() { - // An indel shares its anchor base between the alleles (G vs GAGC), so a single observed base - // can't evaluate it — must be no-call, not a false derived. + // An indel shares its anchor base between the alleles (G against GAGC), so one observed + // base can not evaluate it. It must be a no-call, and 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); @@ -924,10 +955,10 @@ mod tests { #[test] fn interpret_flips_state_against_corrected_polarity_from_stored_base() { - // One source observed base T. Interpreting against an FTDNA-style inverted polarity (anc=T, - // der=C) reads it Ancestral; against the true DecodingUs polarity (anc=C, der=T) the SAME - // stored base reads Derived — computed live, no re-genotyping. The consensus *base* is T in - // both; only its interpretation flips. + // One source observed base T. Against an FTDNA-style inverted polarity (anc=T, der=C) it + // reads Ancestral. Against the true DecodingUs polarity (anc=C, der=T) the SAME stored base + // reads Derived. The code calculates this live, and nothing genotypes again. The consensus + // *base* is T in both, and only the reading of it changes. let observed = to_observed(&[( "aln #1".into(), SourceType::WgsShortRead, @@ -953,9 +984,10 @@ mod tests { #[test] fn multiallelic_third_allele_survives_the_vote() { - // At an A>G SNP, two sources read a genuine third allele T on the *forward* strand (not the - // 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. + // At an A>G SNP, two sources read a genuine third allele T on the *forward* strand. It is + // not the A allele and not the G allele, and comp(T)=A is ancestral. So the code treats T + // as an ancestral read on the opposite strand. A cleaner third-allele case is next: a + // strand-ambiguous A/T with a C read stays C. let observed = to_observed(&[ ( "a".into(), @@ -969,8 +1001,8 @@ mod tests { ), ]); let (v, _) = interpret(&observed, &BTreeMap::new()); - // A/T is strand-ambiguous, so a C read matches no allele and is kept as itself — the - // consensus base is the actual third allele C, not folded away. + // A/T is strand-ambiguous, so a C read matches no allele, and it stays as itself. The + // consensus base is the real third allele C, and nothing folds it away. assert_eq!(v[0].consensus_base.as_deref(), Some("C")); assert_eq!(v[0].consensus, ConsensusState::NoCall); // C is neither ancestral nor derived assert_eq!(v[0].total, 2); @@ -978,7 +1010,7 @@ mod tests { #[test] fn to_observed_preserves_per_call_quality() { - // Depth/region are carried into the stored observation so interpret can weight exactly. + // Depth and region go into the stored observation, so that interpret can weight exactly. let mut o = ConsensusObs::observed("M269", 100, "A", "G", Some('G'), true); o.depth = Some(100); o.region_modifier = 0.4; diff --git a/crates/navigator-domain/src/contig.rs b/crates/navigator-domain/src/contig.rs index 71bbdb53..d0ca3a03 100644 --- a/crates/navigator-domain/src/contig.rs +++ b/crates/navigator-domain/src/contig.rs @@ -1,13 +1,13 @@ -//! Contig-name normalization — the one definition of "strip the `chr` prefix". +//! Contig-name normalization: the one definition of "strip the `chr` prefix". //! -//! Contig naming is build-determined: GRCh37 uses bare names (`22`, `X`, `MT`), GRCh38/CHM13 use a -//! `chr` prefix (`chr22`, `chrX`, `chrM`). Anything that matches loci across builds — panels, -//! liftover, callsets, chip/vendor imports, charts — has to normalize first. This lives in -//! `navigator-domain` because every other crate depends on it, so there is exactly one -//! implementation instead of a per-call-site closure. +//! The build determines the contig names. GRCh37 uses bare names (`22`, `X`, `MT`), and GRCh38 and +//! CHM13 use a `chr` prefix (`chr22`, `chrX`, `chrM`). Anything that matches loci across builds +//! must normalize first: panels, liftover, callsets, chip and vendor imports, and charts. This +//! lives in `navigator-domain` because every other crate depends on it, so there is exactly one +//! implementation, and not a closure at each call site. -/// `name` without a leading `chr` prefix, in any case (`chr7` / `Chr7` / `CHR7` → `7`). Names with -/// no prefix (`7`, `MT`, `HLA-A`) come back unchanged. +/// `name` without a `chr` prefix at the start, in any case (`chr7` / `Chr7` / `CHR7` → `7`). A +/// name with no prefix (`7`, `MT`, `HLA-A`) comes back unchanged. pub fn bare(name: &str) -> &str { match name.get(..3) { Some(p) if p.eq_ignore_ascii_case("chr") => &name[3..], @@ -15,8 +15,8 @@ pub fn bare(name: &str) -> &str { } } -/// [`bare`] uppercased — the canonical key for matching a contig across builds, so a source's -/// `chr1` lines up with a panel locus stored as `1`. +/// [`bare`] in upper case. This is the canonical key to match a contig across builds. The `chr1` +/// of a source then lines up with a panel locus that the store holds as `1`. pub fn bare_upper(name: &str) -> String { bare(name).to_ascii_uppercase() } @@ -44,7 +44,7 @@ mod tests { #[test] fn leaves_short_and_lookalike_names_alone() { - // Shorter than the prefix, or merely starting with some of its letters. + // Shorter than the prefix, or it only starts with some of its letters. for name in ["", "1", "ch", "chX"] { assert_eq!(bare(name), name, "bare({name})"); } @@ -52,7 +52,7 @@ mod tests { #[test] fn is_utf8_safe() { - // `get(..3)` returns None on a non-char-boundary rather than panicking. + // `get(..3)` returns None on a non-char-boundary, and does not panic. assert_eq!(bare("é1"), "é1"); } diff --git a/crates/navigator-domain/src/filetype.rs b/crates/navigator-domain/src/filetype.rs index 0ba09fbb..d3b39440 100644 --- a/crates/navigator-domain/src/filetype.rs +++ b/crates/navigator-domain/src/filetype.rs @@ -1,6 +1,6 @@ -//! File-type detection for the unified "Add data" flow (Scala's `FileTypeDetector`). -//! Binary/structured formats are detected by extension; ambiguous text tables (STR vs -//! chip) are scored by content fingerprint. Pure: callers pass the name + a head sample. +//! File-type detection for the unified "Add data" flow (Scala's `FileTypeDetector`). The extension +//! identifies a binary or structured format. A content fingerprint scores an ambiguous text table +//! (STR against chip). Pure: a caller passes the name and a head sample. /// What a dropped/picked file looks like. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -11,24 +11,27 @@ pub enum DetectedData { Variants, /// CompleteGenomics `masterVar` whole-genome variant table (`var-*-ASM.tsv[.bz2]`). CompleteGenomicsVar, - /// FTDNA Big Y CSV variant report (Named/Private Variants) — chrY derived calls, the - /// "lesser access" substitute for the BAM/CRAM/VCF. + /// FTDNA Big Y CSV variant report (Named or Private Variants): chrY derived calls, the + /// "lesser access" substitute for the BAM, CRAM or VCF. FtdnaCsvVariants, /// Y-STR profile table. StrProfile, - /// Named Y-SNP panel (e.g. BISDNA chromo2) — name + genotype + positive/negative verdict. + /// Named Y-SNP panel (for example BISDNA chromo2): name, genotype, and a positive or negative + /// verdict. YSnpPanel, /// Genotyping-array (chip) export. ChipData, /// mtDNA FASTA sequence. MtdnaFasta, - /// EIGENSTRAT autosomal call set (`.geno`/`.snp`/`.ind` triplet) — a trusted external caller's - /// 1240K genotypes (Reich-lab / `pileupCaller`), the autosomal counterpart to the Y/mt GVCFs. + /// EIGENSTRAT autosomal call set (the `.geno`/`.snp`/`.ind` triplet): the 1240K genotypes of a + /// trusted external caller (Reich-lab, `pileupCaller`). It is the autosomal equivalent of the + /// Y and mt GVCFs. EigenstratCallSet, - /// A trusted external caller's **autosomal** call set as a diploid VCF — a GATK4 gVCF - /// (`.g.vcf[.gz]`) *or* a genotyped all-sites VCF (e.g. `bcftools mpileup`/`call` over the 1240K - /// sites, with explicit `0/0` rows). Genotyped at the 1240K panel for the autosomal consensus. - /// (chrY/chrM GVCFs are the sidecar fast path, discovered in a directory, not here.) + /// The **autosomal** call set of a trusted external caller, as a diploid VCF. It is a GATK4 + /// gVCF (`.g.vcf[.gz]`), *or* a genotyped all-sites VCF (for example `bcftools mpileup` and + /// `call` over the 1240K sites, with explicit `0/0` rows). The genotypes are at the 1240K + /// panel, for the autosomal consensus. (A chrY or chrM GVCF goes to the sidecar fast path, + /// which finds it in a directory, and not here.) GvcfCallSet, /// Unrecognized. Unknown, @@ -62,31 +65,36 @@ pub fn detect(file_name: &str, head: &str) -> DetectedData { if ends(".bam") || ends(".cram") { return DetectedData::Alignment; } - // A GATK gVCF (`.g.vcf[.gz]`) is genotyped at the 1240K panel — checked BEFORE the plain `.vcf` - // rule below (a gVCF also ends `.vcf.gz`). A gVCF named plainly `.vcf.gz` is imported as a normal - // variant set instead; the `.g.` convention is how GATK marks its genome VCFs. - // A gVCF is an autosomal call set only if it actually covers autosomes. A chrY- or chrM-only - // gVCF falls through to the `.vcf` branch below (`.g.vcf.gz` also ends `.vcf.gz`) and becomes a - // variant set, which is what a haploid-lineage call set is. + // A GATK gVCF (`.g.vcf[.gz]`) has genotypes at the 1240K panel. Check it BEFORE the plain + // `.vcf` rule below, because a gVCF also ends `.vcf.gz`. A gVCF with the plain name `.vcf.gz` + // becomes a normal variant set instead. The `.g.` convention is how GATK marks its genome + // VCFs. + // + // A gVCF is an autosomal call set only if it covers autosomes. A gVCF of chrY or chrM only + // falls through to the `.vcf` branch below, because `.g.vcf.gz` also ends `.vcf.gz`. It then + // becomes a variant set, which is what a haploid-lineage call set is. if (ends(".g.vcf") || ends(".g.vcf.gz") || ends(".g.vcf.bgz")) && !vcf_known_lineage_only(head) { return DetectedData::GvcfCallSet; } - // EIGENSTRAT call-set triplet — the user can point at any member; the importer resolves the - // siblings by shared basename. `.geno`/`.ind` are unambiguous; `.snp` too (no other `.snp` type). + // EIGENSTRAT call-set triplet. The user can point at any member, and the importer resolves the + // siblings by their shared basename. `.geno` and `.ind` are unambiguous, and so is `.snp`, + // because there is no other `.snp` type. if ends(".geno") || ends(".snp") || ends(".ind") { return DetectedData::EigenstratCallSet; } if ends(".vcf") || ends(".vcf.gz") || ends(".vcf.bgz") { - // A genotyped **all-sites** VCF — one that emits explicit hom-ref (`0/0`) rows, e.g. a - // `bcftools mpileup`/`call` or joint-genotyped VCF over the 1240K sites — is a trusted - // external autosomal call set, not a variant-only list. Route it to the panel importer so it - // drives the autosomal consensus. A variant-only VCF (no `0/0`) stays a normal variant set. + // A genotyped **all-sites** VCF emits explicit hom-ref (`0/0`) rows. An example is a + // `bcftools mpileup` or `call` VCF, or a joint-genotyped VCF, over the 1240K sites. That is + // a trusted external autosomal call set, and not a variant-only list. Send it to the panel + // importer, so that it drives the autosomal consensus. A variant-only VCF, with no `0/0`, + // stays a normal variant set. // - // Emitting hom-ref rows is **not** on its own enough: a vendor Y/mt product does it too. - // FTDNA Big Y (aengine) reports reference sites across chrY, so on the `0/0` signal alone a - // Big Y export was classified an *autosomal* 1240K call set — landing ~260k chrY records in - // the panel importer, which recognized 266 of them and produced no Y variant set at all, so - // no Y placement and no private-Y source. A haploid-lineage call set is a variant set. + // Hom-ref rows on their own are **not** enough, because a vendor Y or mt product emits + // them too. FTDNA Big Y (aengine) reports reference sites across chrY. On the `0/0` signal + // alone, a Big Y export classified as an *autosomal* 1240K call set. About 260k chrY + // records went to the panel importer, which recognized 266 of them and made no Y variant + // set at all. There was then no Y placement, and no private-Y source. A haploid-lineage + // call set is a variant set. if looks_like_genotyped_callset_vcf(head) && !vcf_known_lineage_only(head) { return DetectedData::GvcfCallSet; } @@ -103,9 +111,10 @@ pub fn detect(file_name: &str, head: &str) -> DetectedData { return DetectedData::MtdnaFasta; } - // CompleteGenomics masterVar — a whole-genome variant TSV (`.tsv[.bz2]`) with an unambiguous - // `>locus ploidy allele chromosome …` column header and a `cgatools`/`VAR-ANNOTATION` preamble. - // Checked here (before the STR/chip scorer) on the head, which the caller has decompressed. + // CompleteGenomics masterVar: a whole-genome variant TSV (`.tsv[.bz2]`) with an unambiguous + // `>locus ploidy allele chromosome …` column header, and a `cgatools` or `VAR-ANNOTATION` + // preamble. Check it here, before the STR and chip scorer, on the head that the caller + // decompressed. if looks_like_cg_master_var(head) { return DetectedData::CompleteGenomicsVar; } @@ -121,13 +130,13 @@ pub fn detect(file_name: &str, head: &str) -> DetectedData { return DetectedData::Unknown; } - // FTDNA Big Y Named/Private Variants CSV — an exact header signature, checked before the - // STR/chip scorer (which would otherwise mis-score the named report as chip). + // FTDNA Big Y Named or Private Variants CSV: an exact header signature. Check it before the + // STR and chip scorer, which would otherwise mis-score the named report as chip. if crate::ftdna_csv::looks_like_ftdna_variant_csv(head) { return DetectedData::FtdnaCsvVariants; } - // A named Y-SNP panel (BISDNA chromo2) is unambiguous — check it before the STR/chip + // A named Y-SNP panel (BISDNA chromo2) is unambiguous. Check it before the STR and chip // scorer, which would otherwise mis-score it as chip. if looks_like_ysnp_panel(&lines) { return DetectedData::YSnpPanel; @@ -148,17 +157,17 @@ pub fn detect(file_name: &str, head: &str) -> DetectedData { } } -/// Whether a VCF head shows the file to be **positively confined to haploid lineages** — chrY and/or -/// chrM, with no autosome anywhere in sight. +/// True when the head of a VCF shows the file is **positively confined to haploid lineages**: +/// chrY, chrM, or both, with no autosome anywhere. /// -/// `##contig=` declarations are authoritative and enumerate every contig, so they win when -/// present; otherwise the `CHROM` column of the records in the head is used. VCFs are -/// coordinate-sorted with chr1 first, so a whole-genome file shows an autosome immediately while a -/// chrY/chrM product never does. +/// The `##contig=` declarations are authoritative, and they list every contig, so they win +/// when they are there. If not, this reads the `CHROM` column of the records in the head. A VCF is +/// in coordinate order with chr1 first. A whole-genome file shows an autosome at once, and a chrY +/// or chrM product never does. /// -/// Returns `false` when there is **no contig evidence at all** (an empty or unreadable head). Absence -/// of evidence is not evidence of absence: a `.g.vcf` we could not read should keep the claim its -/// extension makes rather than be demoted on a guess. +/// Returns `false` when there is **no contig evidence at all**, which is an empty or unreadable +/// head. Absence of evidence is not evidence of absence. A `.g.vcf` we could not read must keep the +/// claim its extension makes, and a guess must not demote it. /// /// This is the guard that keeps a haploid-lineage call set off the autosomal panel pipeline. fn vcf_known_lineage_only(head: &str) -> bool { @@ -185,10 +194,10 @@ fn vcf_known_lineage_only(head: &str) -> bool { saw_record && !saw_autosome } -/// A genotyped **all-sites** VCF: any data line whose `FORMAT` begins `GT` and whose sample genotype -/// is an explicit hom-ref (`0/0` / `0|0`). A variant-only VCF never emits hom-ref rows, so this -/// distinguishes a call set (which lists every site) from a plain variant list. It says nothing about -/// *which* sites — pair it with [`vcf_known_lineage_only`] before calling anything autosomal. +/// A genotyped **all-sites** VCF: any data line whose `FORMAT` starts with `GT`, and whose sample +/// genotype is an explicit hom-ref (`0/0` or `0|0`). A variant-only VCF never emits hom-ref rows, +/// so this separates a call set, which lists every site, from a plain variant list. It says nothing +/// about *which* sites. Use it with [`vcf_known_lineage_only`] before you call anything autosomal. fn looks_like_genotyped_callset_vcf(head: &str) -> bool { head.lines() .filter(|l| !l.starts_with('#') && !l.trim().is_empty()) @@ -201,9 +210,9 @@ fn looks_like_genotyped_callset_vcf(head: &str) -> bool { } /// Recognize a CompleteGenomics masterVar table from its head text. The `>locus … chromosome … -/// varType …` column header is the unambiguous signature; the `cgatools` / `VAR-ANNOTATION` -/// comment preamble corroborates it. Tolerant of the file being uncompressed here (the caller -/// decompresses `.bz2` / `.gz` before sniffing). +/// varType …` column header is the unambiguous signature, and the `cgatools` or `VAR-ANNOTATION` +/// comment preamble supports it. This reads plain text, which is correct: the caller decompresses +/// `.bz2` and `.gz` before this check. fn looks_like_cg_master_var(head: &str) -> bool { let mut has_column_header = false; let mut has_preamble = false; @@ -253,10 +262,11 @@ fn count_token(haystack: &str, prefix: &str, min_digits: usize, max_digits: usiz count } -/// Recognize a named Y-SNP panel (BISDNA chromo2): either the exact -/// `SNPIDgenotyperesult` header, or — lacking it — several tab rows whose third -/// column is a positive/negative/no_call/back-mutated verdict. Tolerant of the multi-line -/// prose preamble BISDNA prepends (those lines are not tab-delimited and never match). +/// Recognize a named Y-SNP panel (BISDNA chromo2). The signature is the exact +/// `SNPIDgenotyperesult` header. Without that header, it takes some tab rows whose third +/// column is a positive, negative, no_call or back-mutated verdict. The multi-line prose preamble +/// that BISDNA puts first is harmless, because those lines are not tab-delimited and never +/// match. fn looks_like_ysnp_panel(lines: &[&str]) -> bool { let is_verdict = |s: &str| { let v = s.trim().trim_matches(|c| c == '"').to_ascii_lowercase(); @@ -435,7 +445,7 @@ chr1\t246193\trs3094315\tG\tA\t225\t.\tDP=29\tGT:PL:DP:AD\t1/1:255,87,0:29:0,29 chr1\t270133\trs12124819\tA\t.\t281\t.\tDP=33\tGT:DP:AD\t0/0:32:32 "; assert_eq!(detect("WGS229.chm13.1240k.vcf.gz", head), DetectedData::GvcfCallSet); - // A variant-only VCF (only 1/1, 0/1 — no hom-ref) stays a normal variant set. + // A variant-only VCF (only 1/1 and 0/1, no hom-ref) stays a normal variant set. let variants_only = "\ #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tS chr1\t246193\t.\tG\tA\t225\t.\tDP=29\tGT\t1/1 @@ -443,10 +453,10 @@ chr1\t246193\t.\tG\tA\t225\t.\tDP=29\tGT\t1/1 assert_eq!(detect("calls.vcf.gz", variants_only), DetectedData::Variants); } - /// The real FTDNA Big Y (aengine) shape: hom-ref rows across chrY and nothing else. Reporting - /// reference sites made it look like a 1240K call set, so ~260k chrY records went to the - /// autosomal panel importer — which matched 266 of them and created no Y variant set, leaving the - /// subject with no Y placement and no private-Y source. + /// The real FTDNA Big Y (aengine) shape: hom-ref rows across chrY, and nothing else. Because + /// it reports reference sites, it looked like a 1240K call set. About 260k chrY records went to + /// the autosomal panel importer, which matched 266 of them and made no Y variant set. The + /// subject was then left with no Y placement and no private-Y source. #[test] fn a_chr_y_only_genotyped_vcf_is_a_variant_set_not_an_autosomal_call_set() { let head = "\ @@ -459,7 +469,7 @@ chrY\t2781205\t.\tC\tA\t10.47\tQUAL=10.4\tBQ=37\tGT:AD:DP:GQ\t0/0:0,5:5:0 chrY\t2781435\t.\tA\tT\t28.57\tQUAL=28.5\tBQ=37\tGT:AD:DP:GQ\t1/1:0,7:7:10 "; assert_eq!(detect("variants.vcf.gz", head), DetectedData::Variants); - // Same for a chrY gVCF handed over directly rather than via the sidecar directory. + // The same for a chrY gVCF that comes in directly, and not through the sidecar directory. assert_eq!(detect("chrY.g.vcf.gz", head), DetectedData::Variants); } @@ -497,7 +507,7 @@ chr1\t246193\trs3094315\tG\tA\t225\t.\tDP=29\tGT:DP\t0/0:29 #[test] fn an_unreadable_head_keeps_the_gvcf_extensions_claim() { - // No contig evidence is not evidence of no autosomes — do not demote on a guess. + // No contig evidence is not evidence of no autosomes, so do not demote on a guess. assert_eq!(detect("sample.g.vcf.gz", ""), DetectedData::GvcfCallSet); } diff --git a/crates/navigator-domain/src/ftdna.rs b/crates/navigator-domain/src/ftdna.rs index 18735cab..2790c493 100644 --- a/crates/navigator-domain/src/ftdna.rs +++ b/crates/navigator-domain/src/ftdna.rs @@ -1,24 +1,24 @@ -//! FTDNA project-export parsers (FTDNA project-import design §3). Pure text→typed rows, no IO — the +//! FTDNA project-export parsers (FTDNA project-import design §3). Pure text→typed rows, no IO. The //! app layer reads the files and hands the text here. //! -//! Covers the two batch report CSVs that seed the importer's spine (Phase 1): -//! - `Member_Information` — the roster (§3.1) -//! - `Paternal_Ancestry` / `Maternal_Ancestry` — MDKA + clade path (§3.2, identical layout) +//! It covers the two batch report CSVs that seed the spine of the importer (Phase 1): +//! - `Member_Information`: the roster (§3.1) +//! - `Paternal_Ancestry` and `Maternal_Ancestry`: MDKA and clade path (§3.2, identical layout) //! -//! The wide `YDNA_Results_Overview` Y-STR chart (§3.3) is parsed by [`crate::strprofile`]; this -//! module only handles the roster + ancestry files. All fields are looked up **by header name** -//! (not fixed position) since exports vary, columns are quoted-with-commas, and headers carry HTML -//! entities (`>`, `↓`, `&`) that are normalized here. +//! [`crate::strprofile`] parses the wide `YDNA_Results_Overview` Y-STR chart (§3.3). This module +//! controls only the roster file and the ancestry files. It finds every field **by header name**, +//! and not by a fixed position. Exports vary, a column can carry quotes and commas, and headers +//! carry HTML entities (`>`, `↓`, `&`) that this module normalizes. -/// One member from `Member_Information` (§3.1). PII fields (`name`) are carried so the matcher can -/// fuzzy-compare, but they are never federated. +/// One member from `Member_Information` (§3.1). This holds the PII fields (`name`) so that the +/// matcher can fuzzy-compare, but they never go to federation. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct MemberRow { pub kit_number: String, pub name: Option, - /// `Access Granted` — pose-as gate + Big Y data tier (`Advanced`/`Limited`/…). + /// `Access Granted`: the pose-as gate, and the Big Y data tier (`Advanced`/`Limited`/…). pub access_granted: Option, - /// `Publicly Share DNA Results` (YES/NO) — federation consent. + /// `Publicly Share DNA Results` (YES/NO): federation consent. pub publicly_shares: Option, } @@ -26,11 +26,12 @@ pub struct MemberRow { #[derive(Debug, Clone, PartialEq, Default)] pub struct AncestryRow { pub kit_number: String, - /// `Sub Group` — the project's clade/branch path (HTML-unescaped), e.g. `CTS4466>S1115>…`. + /// `Sub Group`: the clade or branch path of the project (HTML-unescaped), for example + /// `CTS4466>S1115>…`. pub sub_group: Option, pub country: Option, - /// `Paternal/Maternal Ancestor Name` with the inline `b.`/`d.` dates stripped to [`Self::birth_year`]/ - /// [`Self::death_year`]; the leading name portion is kept here. + /// `Paternal/Maternal Ancestor Name`, with the inline `b.` and `d.` dates moved to + /// [`Self::birth_year`] and [`Self::death_year`]. The name part at the start stays here. pub ancestor_name: Option, pub birth_year: Option, pub death_year: Option, @@ -44,24 +45,25 @@ pub struct AncestryRow { /// reliable). Used to route a multi-file pick into the right parser. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FtdnaFileKind { - /// `Member_Information` — the roster. + /// `Member_Information`: the roster. Member, - /// `Paternal_Ancestry` — paternal MDKA. + /// `Paternal_Ancestry`: paternal MDKA. PaternalAncestry, - /// `Maternal_Ancestry` — maternal MDKA. + /// `Maternal_Ancestry`: maternal MDKA. MaternalAncestry, - /// `YDNA_Results_Overview` — the wide Y-STR chart. + /// `YDNA_Results_Overview`: the wide Y-STR chart. YdnaOverview, } /// Classify an FTDNA export from its header row. `None` if it does not look like one of ours. -/// Disambiguators: the marker block (`DYS…`) is unique to the Y-STR overview; the roster has the -/// `Publicly Share DNA Results` consent column; the ancestry files use `Sub Group` (with a space) -/// and a `Paternal`/`Maternal Ancestor Name`. +/// Three signals separate them. Only the Y-STR overview has the marker block (`DYS…`). Only the +/// roster has the `Publicly Share DNA Results` consent column. The ancestry files use `Sub Group` +/// (with a space) and a `Paternal`/`Maternal Ancestor Name`. pub fn classify(text: &str) -> Option { - // Read the header through the CSV reader so fully-quoted headers (`"Kit Number",…`, which fresh - // FTDNA exports use) are de-quoted/trimmed like the parsers do — a naive comma split would keep - // the quotes and miss every column. + // Read the header through the CSV reader. A fully-quoted header (`"Kit Number",…`, which a + // fresh FTDNA export uses) then loses its quotes, and the reader trims it, the same as the + // parsers do. + // A naive comma split would keep the quotes and miss every column. let (cols, _) = open(text).ok()?; let has = |name: &str| cols.iter().any(|c| c == name); @@ -156,10 +158,11 @@ const YDNA_IDENTITY: &[&str] = &[ "Subgroup", ]; -/// Parse the wide `YDNA_Results_Overview` Y-STR chart (§3.3) into `(kit, markers)` per member. -/// Marker columns are every header not in [`YDNA_IDENTITY`]; multi-copy values stay dash-joined -/// (`"10-14"`), matching the [`crate::strprofile::StrMarker`] convention. Skips the two leading -/// non-member rows (panel / `MIN`) via the kit-number guard and drops blank/`0`/`-` marker cells. +/// Parse the wide `YDNA_Results_Overview` Y-STR chart (§3.3) into `(kit, markers)` for each +/// member. The marker columns are every header that [`YDNA_IDENTITY`] does not list. A multi-copy +/// value stays dash-joined (`"10-14"`), which matches the [`crate::strprofile::StrMarker`] +/// convention. The kit-number guard steps over the first two non-member rows (panel and `MIN`), and +/// this drops a marker cell that is blank, `0`, or `-`. pub fn parse_ydna_overview(text: &str) -> Result)>, String> { use crate::strprofile::StrMarker; let (headers, mut rdr) = open(text)?; @@ -197,8 +200,9 @@ pub fn parse_ydna_overview(text: &str) -> Result Result<(Vec, csv::Reader<&[u8]>), String> { let mut rdr = csv::ReaderBuilder::new() .flexible(true) @@ -232,14 +236,14 @@ fn nonblank(s: String) -> Option { } } -/// A `Sub Group` value is only a clade path when it actually contains a lineage (`>`); FTDNA also -/// uses free-text placeholders there ("Not Yet Tested Positive for Relevant SNPs"). +/// A `Sub Group` value is a clade path only when it holds a lineage (`>`). FTDNA also puts +/// free-text placeholders there ("Not Yet Tested Positive for Relevant SNPs"). fn nonblank_clade(s: String) -> Option { nonblank(s).filter(|v| v.contains('>')) } -/// A real kit number is non-empty and not one of the two leading non-member sentinel rows -/// (`00000.` panel / `MIN`) the Y-STR overview carries — harmless to guard here too. +/// A real kit number is not empty. It is also not one of the two non-member sentinel rows at the +/// start (`00000.` panel and `MIN`) that the Y-STR overview carries. The guard here is harmless. fn is_real_kit(kit: &str) -> bool { let k = kit.trim(); !k.is_empty() && k != "MIN" && !k.starts_with("00000") @@ -254,13 +258,13 @@ fn parse_yes_no(s: &str) -> Option { } } -/// Coordinate cell → f64, dropping the FTDNA `0` sentinel (means "no location"). +/// Coordinate cell → f64. This drops the FTDNA `0` sentinel, which means "no location". fn parse_coord(s: &str) -> Option { let v: f64 = s.trim().parse().ok()?; (v != 0.0).then_some(v) } -/// Minimal HTML-entity unescape for the entities FTDNA emits in headers/values. +/// A small HTML-entity unescape, for the entities FTDNA emits in headers and values. fn unescape_html(s: &str) -> String { s.replace(">", ">") .replace("<", "<") @@ -272,11 +276,11 @@ fn unescape_html(s: &str) -> String { .to_string() } -/// Split an FTDNA ancestor field into `(name, birth_year, death_year)`. The dates are embedded -/// inline in varied shapes — `"Thomas Michael Kane, b. 1830 Clare, IE d. 1908 WI"`, -/// `"Joseph Abbett, b. 19 Mar 1819 and d. 2 Nov 1852"` — so we locate `b.`/`d.` markers and take the -/// first 4-digit year after each. The name is everything before the first marker (trailing comma -/// trimmed). +/// Split an FTDNA ancestor field into `(name, birth_year, death_year)`. The field holds the dates +/// inline, in different shapes: `"Thomas Michael Kane, b. 1830 Clare, IE d. 1908 WI"`, or +/// `"Joseph Abbett, b. 19 Mar 1819 and d. 2 Nov 1852"`. So we find the `b.` and `d.` markers, and +/// take the first 4-digit year after each. The name is everything before the first marker, with a +/// comma at the end trimmed. fn parse_ancestor_name(raw: &str) -> (Option, Option, Option) { let lower = raw.to_ascii_lowercase(); let b_pos = find_marker(&lower, "b."); @@ -291,8 +295,8 @@ fn parse_ancestor_name(raw: &str) -> (Option, Option, Option) (name, birth, death) } -/// Byte offset of a `b.`/`d.` date marker, requiring a word boundary before it (so the `b` in -/// "Abbett" does not match). Returns the offset of the marker letter. +/// Byte offset of a `b.` or `d.` date marker. It needs a word boundary before the marker, so that +/// the `b` in "Abbett" does not match. Returns the offset of the marker letter. fn find_marker(lower: &str, marker: &str) -> Option { let bytes = lower.as_bytes(); let mut from = 0; @@ -380,7 +384,8 @@ mod tests { #[test] fn ydna_overview_parses_per_kit_markers_skipping_junk_rows() { - // header + the two leading non-member rows (panel / MIN) + B5163, space-padded + multi-copy. + // header, the two non-member rows at the start (panel, MIN), and B5163, space-padded and + // multi-copy. let csv = "Kit Number,Name,Paternal Ancestor Name,Country,Haplogroup,Test,Subgroup,DYS393,DYS390,DYS385,DYS459\n\ 00000. R-FGC11134,,,,,, 00000. R-FGC11134, 13, 22, 10-14, 9-10\n\ diff --git a/crates/navigator-domain/src/ftdna_csv.rs b/crates/navigator-domain/src/ftdna_csv.rs index ac2f8e6d..e69c25f9 100644 --- a/crates/navigator-domain/src/ftdna_csv.rs +++ b/crates/navigator-domain/src/ftdna_csv.rs @@ -1,15 +1,16 @@ -//! FTDNA Big Y CSV variant reports — the "lesser access" substitute for the BAM/CRAM/VCF, for -//! project admins whose access tier exposes only the browser CSV exports. Two report flavors, +//! FTDNA Big Y CSV variant reports: the "lesser access" substitute for the BAM, CRAM or VCF. It is +//! for a project admin whose access tier gives only the browser CSV exports. Two report flavors, //! both **GRCh38 chrY** derived-allele calls: //! //! Named Variants: `SNP_Name,Position,On_Haplotree,Ancestral,Derived` //! Private Variants: `Position,Ancestral,Derived` //! -//! Each row is a position where the sample carries the **Derived** allele (a positive call), so we -//! emit a SNP [`VariantCall`] with `reference = ancestral`, `alternate = derived`, `genotype = "1"` -//! (derived), and `rs_id` = the SNP name when present. SNP-only (single-base ACGT); other rows are -//! skipped. The calls land in GRCh38 space, which is FTDNA's native Y-tree build — so Y placement -//! matches positions directly, no liftover. +//! Each row is a position where the sample carries the **Derived** allele, which is a positive +//! call. So this module emits a SNP [`VariantCall`] with `reference = ancestral`, +//! `alternate = derived`, `genotype = "1"` (derived), and `rs_id` = the SNP name when present. +//! It reads SNP rows only (single-base ACGT), and drops the others. The calls land in GRCh38 +//! space, which is FTDNA's native Y-tree build. So Y placement matches positions directly, with +//! no liftover. use serde::{Deserialize, Serialize}; @@ -33,7 +34,8 @@ impl FtdnaReport { } } -/// chrY contig label for the emitted calls — matches the FTDNA GRCh38 Y-tree's contig. +/// chrY contig label for the calls this module emits. It matches the contig of the FTDNA GRCh38 +/// Y-tree. const CONTIG: &str = "chrY"; /// Split a CSV line into trimmed, unquoted cells. @@ -66,9 +68,9 @@ pub fn looks_like_ftdna_variant_csv(text: &str) -> bool { .unwrap_or(false) } -/// Parse an FTDNA Big Y Named/Private Variants CSV into chrY derived-allele SNP calls, returning -/// the report flavor alongside. Errors if the header is not a recognized FTDNA report or no SNP -/// rows parse. +/// Parse an FTDNA Big Y Named or Private Variants CSV into chrY derived-allele SNP calls. Also +/// returns the report flavor. Errors if the header is not a recognized FTDNA report, or if no SNP +/// row parses. 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")?; @@ -138,7 +140,7 @@ mod tests { #[test] fn skips_non_snp_rows_and_rejects_foreign_headers() { - // An indel row is dropped; the SNP row survives. + // The parser drops an indel row. The SNP row survives. let csv = "SNP_Name,Position,On_Haplotree,Ancestral,Derived\n\ ins1,100,No,A,AT\n\ M1,200,Yes,C,T\n"; diff --git a/crates/navigator-domain/src/i18n.rs b/crates/navigator-domain/src/i18n.rs index 052ffb9f..f0c6ffd0 100644 --- a/crates/navigator-domain/src/i18n.rs +++ b/crates/navigator-domain/src/i18n.rs @@ -1,18 +1,20 @@ -//! Lightweight i18n: Play-style `key=value` catalogs embedded at compile time, mirroring the -//! AppView's (`decodingus/rust` du-web) approach so both Rust front-ends share one catalog format. -//! Dependency-free (no fluent). +//! Lightweight i18n: Play-style `key=value` catalogs that the build embeds at compile time. This +//! is the same approach as the AppView (`decodingus/rust` du-web), so both Rust front-ends share +//! one catalog format. No dependency (no fluent). //! -//! This lives in `navigator-domain`, the bottom of the crate stack, because user-facing text is not -//! produced only by the UI. The Simple-mode Subject Brief ([`crate::brief`]) writes whole sentences -//! about someone's results, and those same sentences are also consumed by the HTML report export -//! (`navigator-app`) and the local-LLM prompt — none of which can reach a catalog that only the UI -//! crate owns. Keeping the catalog here is what lets every layer that writes for a person localize; -//! `navigator-ui` re-exports it, so `self.tr(...)` in the UI is unchanged. +//! This lives in `navigator-domain`, the bottom of the crate stack, because the UI is not the only +//! source of text for a person to read. The Simple-mode Subject Brief ([`crate::brief`]) writes +//! whole sentences about the results of a person. The HTML report export (`navigator-app`) and the +//! local-LLM prompt also read those same sentences. None of them can reach a catalog that only the +//! UI crate owns. //! -//! Lookup falls back from the active language → English → the key itself, so a partial -//! translation degrades to English rather than showing raw keys. Catalog values are -//! `&'static str`, so `tr()` returns `&'static str` and never borrows app state — convenient -//! inside egui closures. +//! So the catalog is here, and every layer that writes for a person can localize. `navigator-ui` +//! re-exports it, so `self.tr(...)` in the UI does not change. +//! +//! A lookup falls back from the active language → English → the key itself. A partial translation +//! then degrades to English, and does not put raw keys on the screen. Catalog values are +//! `&'static str`, so `tr()` returns `&'static str` and never borrows app state, which is +//! convenient inside an egui closure. use std::collections::HashMap; use std::sync::OnceLock; @@ -50,7 +52,7 @@ impl Lang { } } - /// All languages, for rendering the switcher. + /// All languages, to draw the switcher. pub fn all() -> &'static [Lang] { &[Lang::En, Lang::Es] } @@ -62,7 +64,7 @@ fn lang_file() -> std::path::PathBuf { crate::paths::decodingus_dir().join("navigator-lang") } -/// The previously chosen UI language, if one was saved. +/// The UI language chosen earlier, if the app saved one. pub fn load_lang() -> Option { std::fs::read_to_string(lang_file()) .ok() @@ -102,12 +104,12 @@ fn catalog(lang: Lang) -> &'static HashMap<&'static str, &'static str> { } } -/// Translate `key` and substitute positional arguments: `{0}` is replaced by `args[0]`, and so on. +/// Translate `key` and substitute positional arguments: `args[0]` replaces `{0}`, and so on. /// -/// Positional rather than named because word order differs between languages — a translator has to -/// be able to move `{0}` after `{1}` without the code caring. An index with no argument is left in -/// place rather than blanked, so a miscounted template is visible instead of silently dropping a -/// number from a sentence about someone's results. +/// The arguments are positional, and not named, because word order differs between languages. A +/// translator must be able to move `{0}` after `{1}`, and the code must not care. An index with no +/// argument stays in place, and nothing blanks it. A template with a wrong count is then visible, +/// and it does not drop a number from a sentence about the results of a person. pub fn tr_fmt(lang: Lang, key: &'static str, args: &[&str]) -> String { let mut out = tr(lang, key).to_string(); for (i, a) in args.iter().enumerate() { @@ -118,16 +120,16 @@ pub fn tr_fmt(lang: Lang, key: &'static str, args: &[&str]) -> String { /// Every `(key, translation)` pair for `lang`. /// -/// Exists for checks that must see *every* string the app can display rather than the ones a test -/// happens to name — the UI's glyph-coverage test being the case in point: a character with no -/// glyph renders as an empty box, which no other test and no compiler can see. +/// This exists for a check that must see *every* string the app can display, and not only the ones +/// a test names. The glyph-coverage test of the UI is the example. A character with no glyph draws +/// as an empty box, which no other test and no compiler can see. pub fn entries(lang: Lang) -> Vec<(&'static str, &'static str)> { let mut v: Vec<_> = catalog(lang).iter().map(|(k, val)| (*k, *val)).collect(); v.sort_unstable(); v } -/// Translate `key` for `lang`, falling back to English then the key itself. +/// Translate `key` for `lang`. The fallback is English, then the key itself. pub fn tr(lang: Lang, key: &'static str) -> &'static str { if let Some(v) = catalog(lang).get(key).copied() { return v; @@ -148,15 +150,15 @@ mod tests { fn translates_and_falls_back() { assert_eq!(tr(Lang::En, "nav.subjects"), "Subjects"); assert_eq!(tr(Lang::Es, "nav.subjects"), "Sujetos"); - // Missing in Es → English fallback (assuming this key is not translated). + // Missing in Es → English fallback (if this key has no translation). assert_eq!(tr(Lang::Es, "status.label"), tr(Lang::Es, "status.label")); // Unknown key → the key itself. assert_eq!(tr(Lang::En, "totally.unknown.key"), "totally.unknown.key"); } - /// The diagnosis modal exists to be read and pasted by someone filing a bug report, so a - /// missing key there renders a raw `diagnosis.title` into the exact artifact that is supposed - /// to be legible. `tr` falls back to the key itself, which fails silently — assert instead. + /// A person who reports a bug reads the diagnosis modal and pastes it. So a key that is + /// missing there draws a raw `diagnosis.title` into the exact artifact that must be legible. + /// `tr` falls back to the key itself, and gives no message, so assert here instead. #[test] fn diagnosis_strings_are_translated_in_every_language() { for key in [ @@ -177,9 +179,10 @@ mod tests { } } - /// The Subject Brief's sentences are the reason this catalog lives in `navigator-domain`. An - /// English-only `brief.*` key would silently reinstate exactly the defect that move fixed — the - /// reader gets English prose regardless of locale — and `tr`'s fallback makes that invisible. + /// The sentences of the Subject Brief are the reason this catalog lives in `navigator-domain`. + /// A `brief.*` key in English only would bring back exactly the defect that move fixed, and it + /// would give no message. The reader would get English prose whatever the locale, and the + /// fallback of `tr` makes that invisible. #[test] fn brief_prose_is_translated_in_every_language() { let en = catalog(Lang::En); @@ -199,8 +202,9 @@ mod tests { } } - /// A template and its translations must take the same arguments: a `{1}` that only exists in one - /// language either drops a number from a sentence or leaves a literal `{1}` in the text. + /// A template and its translations must take the same arguments. A `{1}` that exists in one + /// language only either drops a number from a sentence, or leaves a literal `{1}` in the + /// text. #[test] fn placeholders_match_across_languages() { let en = catalog(Lang::En); diff --git a/crates/navigator-domain/src/identity.rs b/crates/navigator-domain/src/identity.rs index 005362eb..79e0980b 100644 --- a/crates/navigator-domain/src/identity.rs +++ b/crates/navigator-domain/src/identity.rs @@ -1,10 +1,10 @@ -//! Vendor-neutral Subject identity + FTDNA-specific member/MDKA types (FTDNA project-import -//! design §4). Pure types, no IO. +//! Vendor-neutral Subject identity, and the FTDNA-specific member and MDKA types (FTDNA +//! project-import design §4). Pure types, no IO. //! -//! **Privacy:** [`ExternalId`], [`FtdnaMember`], and [`Mdka`] are **PII / never-federated** — they -//! must not be derived into a public PDS `fed` record nor put in an AppView-bound payload. They may -//! only ever enter the encrypted Edge-to-Edge tier. Keep distinct from our own computed haplogroup -//! calls (those live in `RunHaplogroupCall`). +//! **Privacy:** [`ExternalId`], [`FtdnaMember`] and [`Mdka`] are **PII, and never federated**. No +//! code may derive them into a public PDS `fed` record, or put them in a payload bound for the +//! AppView. They may enter the encrypted Edge-to-Edge tier, and nothing else. Keep them separate +//! from the haplogroup calls we compute, which live in `RunHaplogroupCall`. use du_domain::ids::SampleGuid; use serde::{Deserialize, Serialize}; @@ -26,17 +26,18 @@ pub struct ProjectMembership { pub struct ExternalId { pub id: i64, pub biosample_guid: SampleGuid, - /// `FTDNA` | `YSEQ` | `NEBULA` | `WGS` | `MANUAL` | … — see [`IdSource`] for the well-known set. + /// `FTDNA` | `YSEQ` | `NEBULA` | `WGS` | `MANUAL` | … See [`IdSource`] for the well-known set. pub source: String, /// Kit number / vendor id. pub external_id: String, } -/// Well-known [`ExternalId::source`] values. Stored as plain strings (open set — new vendors are -/// just a new value), but the common ones get constants to avoid typos at call sites. +/// Well-known [`ExternalId::source`] values. The store holds them as plain strings, because the +/// set is open and a new vendor is only a new value. The common ones have constants, to stop a typo +/// at a call site. pub struct IdSource; impl IdSource { - // ── vendor kits (background-only on the AppView — never surfaced publicly) ── + // ── vendor kits (background-only on the AppView, and never public) ── pub const FTDNA: &'static str = "FTDNA"; pub const YSEQ: &'static str = "YSEQ"; pub const NEBULA: &'static str = "NEBULA"; @@ -47,9 +48,10 @@ impl IdSource { /// The Big Y variant/BAM package's internal sample UUID (links BAM ↔ variants; design §5). pub const FTDNA_BIGY_UUID: &'static str = "FTDNA_BIGY_UUID"; - // ── public / open-consent catalog ids (the AppView surfaces these) ── - // These namespace tokens MUST match the AppView's `is_public` set exactly — it derives - // displayability from the namespace, so a typo silently demotes a public id to background-only. + // ── public / open-consent catalog ids (the AppView shows these) ── + // These namespace tokens MUST match the `is_public` set of the AppView exactly. The AppView + // decides what to display from the namespace, so a typo demotes a public id to + // background-only, and gives no message. pub const PGP: &'static str = "PGP"; pub const IGSR: &'static str = "IGSR"; pub const THOUSAND_GENOMES: &'static str = "1000G"; @@ -59,9 +61,10 @@ impl IdSource { pub const HGDP: &'static str = "HGDP"; pub const SGDP: &'static str = "SGDP"; - /// Whether a namespace is a public/open-consent catalog id (surfaced by the AppView) rather than - /// a vendor kit (kept off every public surface). Mirrors the AppView's `is_public` policy so the - /// two ends agree; an unrecognized namespace is treated as private (the safe default). + /// True when a namespace is a public open-consent catalog id, which the AppView shows, and + /// not a vendor kit, which stays off every public surface. This mirrors the `is_public` policy + /// of the AppView, so that the two ends agree. A namespace it does not recognize is private, + /// which is the safe default. pub fn is_public(source: &str) -> bool { matches!( source, @@ -77,19 +80,21 @@ impl IdSource { } } -/// Public/open-consent catalog identifiers derivable **purely from a sample's local provenance** — -/// used to seed the AppView-visible `external_ids` for bulk-imported public datasets so they match -/// their existing catalog rows. Deterministic pattern match only (no network/manifest lookup): +/// Public open-consent catalog identifiers that come **only from the local provenance of a +/// sample**. They seed the `external_ids` the AppView can see, for a public dataset that a bulk +/// import brought in. The dataset then matches the catalog rows that already exist. This is a +/// deterministic pattern match only, with no network or manifest lookup: /// -/// - a 1000 Genomes / IGSR sample name (`HG#####` / `NA#####`) → `(IGSR, name)`; +/// - a 1000 Genomes or IGSR sample name (`HG#####` / `NA#####`) → `(IGSR, name)`; /// - an HGDP catalog id (`HGDP#####`) → `(HGDP, name)`; /// - a genuine INSDC **sample** accession in `sample_accession` (`SAM*` → BIOSAMPLE, `ERS…` → ENA, /// `SRS…` → SRA). /// -/// A dataset-specific friendly name (the common case in ancient-DNA / population sets, where the -/// accession is just a copy of the label) yields nothing — we never guess a namespace, because a -/// wrong token silently fails the AppView's `(namespace, value)` dedup. GIAB `HG00x` (< 5 digits) -/// is intentionally excluded to avoid colliding with build names. +/// A friendly name that belongs to one dataset gives nothing. That is the common case in +/// ancient-DNA and population sets, where the accession is only a copy of the label. We never guess +/// a namespace, because a wrong token fails the `(namespace, value)` dedup of the AppView, and +/// gives no message. GIAB `HG00x` (< 5 digits) stays out on purpose, so that it does not collide +/// with a build name. pub fn catalog_ids_from_provenance(donor_identifier: &str, sample_accession: Option<&str>) -> Vec<(String, String)> { let mut out = Vec::new(); let d = donor_identifier.trim(); @@ -106,13 +111,13 @@ pub fn catalog_ids_from_provenance(donor_identifier: &str, sample_accession: Opt out } -/// `HG#####` / `NA#####` — a 1000 Genomes / IGSR sample name (≥ 5 digits after the prefix). +/// `HG#####` / `NA#####`: a 1000 Genomes or IGSR sample name (≥ 5 digits after the prefix). fn is_igsr_name(s: &str) -> bool { let rest = s.strip_prefix("HG").or_else(|| s.strip_prefix("NA")); matches!(rest, Some(r) if r.len() >= 5 && r.bytes().all(|b| b.is_ascii_digit())) } -/// `HGDP#####` (optionally `HGDP_#####`) — an HGDP catalog id. +/// `HGDP#####` (also `HGDP_#####`): an HGDP catalog id. fn is_hgdp_name(s: &str) -> bool { let rest = s.strip_prefix("HGDP").map(|r| r.strip_prefix('_').unwrap_or(r)); matches!(rest, Some(r) if !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) @@ -138,8 +143,9 @@ pub fn insdc_sample_namespace(acc: &str) -> Option<&'static str> { } } -/// FTDNA-reported member labels only (the batch-file metadata we do not otherwise model). Computed -/// haplogroups stay in the haplogroup-call store — different provenance (design §4.2). +/// FTDNA-reported member labels only: the batch-file metadata we do not model in another place. A +/// computed haplogroup stays in the haplogroup-call store, because its provenance is different +/// (design §4.2). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FtdnaMember { pub biosample_guid: SampleGuid, @@ -148,10 +154,10 @@ pub struct FtdnaMember { pub mt_haplogroup_ftdna: Option, /// `predicted` | `confirmed`. pub haplo_status: Option, - /// `Advanced` | `Limited` | `None` — the pose-as gate, which also determines the reachable Big Y - /// data tier (design §3.5). + /// `Advanced` | `Limited` | `None`: the pose-as gate. It also sets which Big Y data tier the + /// code can reach (design §3.5). pub access_granted: Option, - /// `Publicly Share DNA Results` consent flag — gates whether this Subject may federate. + /// `Publicly Share DNA Results` consent flag. It gates whether this Subject may federate. pub publicly_shares: Option, } @@ -185,7 +191,7 @@ impl Lineage { } } -/// Most Distant Known Ancestor on a lineage (design §4.3). One per Subject per lineage. +/// Most Distant Known Ancestor on a lineage (design §4.3). One for each Subject and each lineage. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Mdka { pub id: i64, @@ -220,16 +226,16 @@ pub struct NewMdka { pub notes: Option, } -/// Name particles that belong to the surname rather than to a given name — the tokens a surname -/// may legitimately begin with. Without these, [`surname_of`] would refuse `van der Berg` and -/// `de la Cruz`, which are surnames, while still refusing `Thomas Michael Kane`, which is not. +/// Name particles that belong to the surname, and not to a given name. These are the tokens a +/// surname may correctly start with. Without them, [`surname_of`] would refuse `van der Berg` and +/// `de la Cruz`, which are surnames. It still refuses `Thomas Michael Kane`, which is not one. const NAME_PARTICLES: &[&str] = &[ "van", "von", "der", "den", "de", "del", "della", "di", "da", "dos", "du", "la", "le", "les", "mac", "mc", "st", "st.", "saint", "ter", "ten", "af", "av", "al", "bin", "ibn", "ap", "ó", "ni", "nic", "mag", "fitz", "o", "o'", ]; -/// A token that starts the biographical tail rather than continuing the name: a year, a date, or -/// the word that introduces one. Everything from here on is annotation. +/// A token that starts the biographical tail, and does not continue the name: a year, a date, or +/// the word before one. Everything from here on is annotation. fn is_annotation(token: &str) -> bool { let t = token.trim_matches(|c: char| !c.is_alphanumeric()).to_lowercase(); if matches!( @@ -238,8 +244,8 @@ fn is_annotation(token: &str) -> bool { ) { return true; } - // Any run of four digits reading as a year (1000-2099) — covers `1770`, `~1770`, `1919-1996` - // and `11/25/1843`. + // Any run of four digits that reads as a year (1000-2099). This covers `1770`, `~1770`, + // `1919-1996` and `11/25/1843`. token.as_bytes().windows(4).any(|w| { w.iter().all(u8::is_ascii_digit) && { let y: i32 = std::str::from_utf8(w).unwrap_or("0").parse().unwrap_or(0); @@ -250,9 +256,9 @@ fn is_annotation(token: &str) -> bool { /// Decode the HTML entities the FTDNA CSV importer leaves in place (`Láire`, `Died 26`). /// -/// The root cause is the importer, not this function — but this is the last point before a name is -/// published, and shipping `mac Láire` as a surname is worse than decoding it here. 61 of the -/// 6,218 names in the reference corpus carry one. +/// The root cause is the importer, and not this function. But this is the last point before a name +/// goes out, and to send `mac Láire` as a surname is worse than to decode it here. In the +/// reference corpus, 61 of the 6,218 names carry one. fn decode_entities(s: &str) -> String { if !s.contains('&') { return s.to_string(); @@ -298,21 +304,20 @@ fn decode_entities(s: &str) -> String { out } -/// Generational suffixes, dropped before the surname is taken. +/// Generational suffixes. This code drops them before it takes the surname. const NAME_SUFFIXES: &[&str] = &["jr", "jr.", "sr", "sr.", "i", "ii", "iii", "iv", "v", "esq", "esq."]; /// Reduce a most-distant-known-ancestor's full name to a **surname**. /// -/// This is a privacy gate, not a formatting nicety. An MDKA's surname, origin and dates are -/// genealogical context that may be published (`proposals/ancestral-origin-icicle.md` §2 in the -/// AppView repo); a given name is not, and it is what turns a published record into a named -/// individual. The split therefore happens here, at the edge, before anything is serialized — the -/// full name never leaves the workspace. +/// This is a privacy gate, and not a matter of style. The surname, origin and dates of an MDKA are +/// genealogical context that may go out (`proposals/ancestral-origin-icicle.md` §2 in the AppView +/// repo). A given name may not, and a given name is what turns a published record into a named +/// individual. So the split happens here, at the edge, before anything becomes bytes. The full name +/// never leaves the workspace. /// -/// Conservative by construction: it takes the **last** token plus any particles immediately -/// preceding it, and returns `None` when there is nothing usable. A wrong split leaks a forename, -/// so the AppView independently re-checks what arrives; this is the first of two gates, not the -/// only one. +/// The rule is conservative. It takes the **last** token, plus any particle directly before it, and +/// it returns `None` when there is nothing usable. A wrong split leaks a forename, so the AppView +/// checks again, on its own, what arrives. This is the first of two gates, and not the only one. /// /// ```text /// "Thomas Michael Kane" → "Kane" @@ -324,7 +329,7 @@ const NAME_SUFFIXES: &[&str] = &["jr", "jr.", "sr", "sr.", "i", "ii", "iii", "iv /// ``` pub fn surname_of(full_name: &str) -> Option { let decoded = decode_entities(full_name); - // `Surname, Given` — genealogy files are full of it, and the plain last-token rule would take + // `Surname, Given`. Genealogy files are full of it, and the plain last-token rule would take // the given name. let head = match decoded.split_once(',') { Some((last, _)) if !last.trim().is_empty() => last.to_string(), @@ -334,13 +339,13 @@ pub fn surname_of(full_name: &str) -> Option { .split_whitespace() .filter(|t| !t.trim_matches(|c: char| !c.is_alphanumeric()).is_empty()) .collect(); - // Cut at the first biographical annotation. A third of the reference corpus appends dates or a - // birthplace to the name — `William Macaulay ~1770 of Balnicol`, `Michael OConnell b1854 d1928 - // St Louis` — and a plain last-token rule takes `Balnicol` and `Louis` as surnames. + // Cut at the first biographical annotation. A third of the reference corpus adds dates or a + // birthplace to the name: `William Macaulay ~1770 of Balnicol`, `Michael OConnell b1854 d1928 + // St Louis`. A plain last-token rule takes `Balnicol` and `Louis` as surnames. if let Some(cut) = tokens.iter().position(|t| is_annotation(t)) { tokens.truncate(cut); } - // Drop trailing generational suffixes (`Jr.`, `III`). + // Drop a generational suffix at the end (`Jr.`, `III`). while tokens.last().is_some_and(|t| { NAME_SUFFIXES.contains(&t.to_lowercase().trim_end_matches('.').to_string().as_str()) || NAME_SUFFIXES.contains(&t.to_lowercase().as_str()) @@ -386,7 +391,8 @@ mod tests { // Dataset friendly names (the bulk-set common case) → nothing; we never guess. assert!(catalog_ids_from_provenance("Ale22", Some("Ale22")).is_empty()); assert!(catalog_ids_from_provenance("BulgarianB4", Some("BulgarianB4")).is_empty()); - // GIAB HG002 (< 5 digits) is deliberately excluded to avoid build-name collisions. + // GIAB HG002 (< 5 digits) stays out on purpose, so that it does not collide with a build + // name. assert!(catalog_ids_from_provenance("HG002", None).is_empty()); } @@ -423,8 +429,8 @@ mod tests { assert_eq!(surname_of("Kane").as_deref(), Some("Kane")); } - /// Surnames that genuinely contain spaces must survive whole — refusing them would quietly - /// mangle Dutch, Spanish and Gaelic lines while the English ones sailed through. + /// A surname that truly holds a space must survive whole. To refuse those would damage Dutch, + /// Spanish and Gaelic lines, with no message, while the English ones passed. #[test] fn surname_keeps_its_particles() { assert_eq!(surname_of("Pieter van der Berg").as_deref(), Some("van der Berg")); @@ -473,25 +479,25 @@ mod tests { ); } - /// The FTDNA importer leaves HTML entities in the value. Publishing `mac Láire` as a - /// surname is worse than decoding it at the last point before it leaves. + /// The FTDNA importer leaves HTML entities in the value. To send `mac Láire` out as a + /// surname is worse than to decode it at the last point before it goes. #[test] fn surname_decodes_the_importers_html_entities() { assert_eq!(surname_of("Conall Corc mac Láire").as_deref(), Some("mac Láire")); assert_eq!(surname_of("Diarmaid Ó Drisceoil").as_deref(), Some("Ó Drisceoil")); assert_eq!(surname_of("José de Mello").as_deref(), Some("de Mello")); - // A bare ampersand is left alone rather than eating the rest of the string. + // A bare ampersand stays as it is, and does not consume the rest of the string. assert_eq!(surname_of("Smith & Sons").as_deref(), Some("Sons")); } - /// Nothing usable yields nothing — never a stray fragment that would publish as a name. + /// Nothing usable gives nothing. It never gives a stray fragment that would go out as a name. #[test] fn surname_of_nothing_is_none() { assert_eq!(surname_of(""), None); assert_eq!(surname_of(" "), None); assert_eq!(surname_of("Jr."), None, "a suffix alone is not a surname"); assert_eq!(surname_of("?"), None); - // Junk the corpus actually contains — better nothing than a fragment published as a name. + // Junk that the corpus holds. Nothing is better than a fragment that goes out as a name. assert_eq!(surname_of("trees.ancestry.com/tree/49418381/family"), None); assert_eq!(surname_of("1846 Duplin County, NC"), None); assert_eq!(surname_of("ABT. 1769 • Kilmalkedar, Co Kerry, Ireland"), None); diff --git a/crates/navigator-domain/src/labs.rs b/crates/navigator-domain/src/labs.rs index c8596cd7..2f1fcd2f 100644 --- a/crates/navigator-domain/src/labs.rs +++ b/crates/navigator-domain/src/labs.rs @@ -1,9 +1,9 @@ -//! Catalog of known labs, sequencing centers, and genotyping vendors — a Rust port of the Scala -//! `LabsConfig`/`labs.conf`. Provides display names, ≤6-char abbreviations, categories, and -//! capabilities for the Data Sources UI (lab chips + the sequence-run lab dropdown), and -//! case-insensitive lookup by id / display name / alias for matching an inferred facility. +//! Catalog of known labs, sequencing centers, and genotyping vendors. This is a Rust port of the +//! Scala `LabsConfig`/`labs.conf`. It gives display names, ≤6-char abbreviations, categories, and +//! capabilities for the Data Sources UI (lab chips and the sequence-run lab dropdown). It also +//! gives case-insensitive lookup by id, display name, or alias, to match an inferred facility. //! -//! Static data (no config file): the set is small and stable; adding a lab is a code change. +//! Static data (no config file): the set is small and stable, and a new lab is a code change. /// A lab / sequencing center / vendor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -30,7 +30,7 @@ pub mod category { /// The full catalog (25 labs), ported from `labs.conf`. pub const CATALOG: &[Lab] = &[ - // Commercial DNA testing labs + // Commercial DNA test labs Lab { id: "familytreedna", display_name: "FamilyTreeDNA", @@ -299,8 +299,8 @@ pub fn display_name(identifier: &str) -> String { .unwrap_or_else(|| identifier.to_string()) } -/// Display names offered in the sequence-run lab dropdown: testing labs + sequencing platforms + -/// academic institutions (not consumer-array vendors), sorted. +/// Display names offered in the sequence-run lab dropdown: test labs, sequencing platforms, and +/// academic institutions, but not consumer-array vendors. Sorted. pub fn sequence_run_lab_names() -> Vec<&'static str> { let mut names: Vec<&'static str> = CATALOG .iter() diff --git a/crates/navigator-domain/src/lib.rs b/crates/navigator-domain/src/lib.rs index 686bf717..cb930ec6 100644 --- a/crates/navigator-domain/src/lib.rs +++ b/crates/navigator-domain/src/lib.rs @@ -1,4 +1,4 @@ -//! Navigator domain types — the desktop-only aggregates that `du-domain` does not +//! Navigator domain types: the desktop-only aggregates that `du-domain` does not //! cover: `SequenceRun`, `Alignment`, `AnalysisArtifact`, `YProfile`, IBD, and the //! `Workspace`/`Project` aggregate. Pure types, zero IO; this is the bottom of the //! dependency graph (`ui → app → {analysis, store, sync} → domain`). diff --git a/crates/navigator-domain/src/llm_prompt.rs b/crates/navigator-domain/src/llm_prompt.rs index b3045ce4..5f406249 100644 --- a/crates/navigator-domain/src/llm_prompt.rs +++ b/crates/navigator-domain/src/llm_prompt.rs @@ -1,17 +1,20 @@ -//! Pure prompt construction + grounding for the local-LLM narration (see -//! `documents/design/local-llm-integration.md`). No I/O — given a [`SubjectBrief`], produce the exact -//! `system`/`user` message text we send, so the guardrails are reviewable and unit-tested (the same -//! discipline as the deterministic brief templating). The model is a **rewriter, not a source of -//! facts**: it only restates the already-curated, already-rounded strings in the fact sheet. +//! Pure prompt construction and grounding for the local-LLM narration (see +//! `documents/design/local-llm-integration.md`). No I/O. From a [`SubjectBrief`] it makes the exact +//! `system` and `user` message text we send. A person can then review the guardrails, and a unit +//! test can hold them. This is the same discipline as the deterministic brief template. The model +//! is a **rewriter, not a source of facts**: it only restates the already-curated, already-rounded +//! strings in the fact sheet. use crate::brief::{LineageBrief, SubjectBrief}; -/// The shared grounding + safety core used by *both* the narration and Q&A system prompts, so the -/// guardrails have a single reviewed source. It carries the facts-only / no-new-claims / no-health / -/// preserve-uncertainty rules — but **no output-format** rules (those differ: narration writes a -/// story, Q&A answers a question). The explicit "no medical disclaimers" clause matters: a model -/// that volunteers a "this is not medical advice" hedge trips the post-generation [`mentions_health`] -/// guard and gets its otherwise-fine answer replaced by the deflection. +/// The shared grounding and safety core. *Both* the narration prompt and the Q&A system prompt use +/// it, so that the guardrails have one reviewed source. It carries the rules for facts only, no +/// new claims, no health, and keep the uncertainty. It carries **no output-format** rules, because +/// those differ: narration writes a story, and Q&A answers a question. +/// +/// The explicit "no medical disclaimers" clause matters. A model that adds a "this is not medical +/// advice" hedge trips the [`mentions_health`] guard after generation. The deflection then replaces +/// an answer that was otherwise good. fn grounding_rules() -> String { "Stay grounded in the facts given in the user message. You may interpret, connect, and add \ general context that follows directly from those facts, but do NOT introduce specific new \ @@ -23,9 +26,9 @@ fn grounding_rules() -> String { .to_string() } -/// The system prompt for brief narration (M1): the shared grounding rules plus the narration-specific -/// "warm connected story" formatting. Returned as an owned `String` so callers (and tests) see the -/// literal text we send. +/// The system prompt for brief narration (M1): the shared grounding rules, plus the "warm +/// connected story" format that only the narration uses. Returns an owned `String`, so that a +/// caller, and a test, sees the literal text we send. pub fn narrate_system_prompt() -> String { format!( "You are a genetic-genealogy guide writing a warm, insightful summary for a curious \ @@ -45,10 +48,11 @@ pub fn narrate_system_prompt() -> String { ) } -/// The system prompt for the "ask my results" chat (M2/M4): the same grounding rules, but instructed -/// to **answer the specific question** concisely rather than retell the whole genetic story (sharing -/// the narration formatting made the chat ignore questions and emit a brief). Out-of-scope medical -/// questions get the fixed ancestry-only deflection. +/// The system prompt for the "ask my results" chat (M2/M4). It has the same grounding rules. But +/// it tells the model to **answer the specific question** in few words, and not to tell the whole +/// genetic story again. When the chat shared the narration format, it ignored questions and +/// emitted a brief. A medical question that is out of scope gets the fixed ancestry-only +/// deflection. pub fn answer_system_prompt() -> String { format!( "You are a genetic-genealogy guide answering a specific question for a curious non-expert, \ @@ -63,9 +67,10 @@ pub fn answer_system_prompt() -> String { ) } -/// The system prompt for a per-tab "Explain this" narration (M5): the shared grounding rules, focused -/// on explaining a *single* signal (`signal_label`, e.g. "Y-STR markers") in plain language. Like the -/// Q&A prompt it carries no narration story-arc formatting — it explains just this one aspect. +/// The system prompt for an "Explain this" narration on one tab (M5). It has the shared grounding +/// rules, and it points the model at a *single* signal (`signal_label`, for example "Y-STR +/// markers") in plain language. Like the Q&A prompt, it carries no story-arc format. It explains +/// only this one aspect. pub fn narrate_signal_system_prompt(signal_label: &str) -> String { format!( "You are a genetic-genealogy guide helping a curious non-expert understand one part of their \ @@ -97,8 +102,9 @@ fn lineage_lines(out: &mut String, label: &str, lb: &LineageBrief) { out.push_str(&format!("- confidence: {}\n", lb.confidence_phrase)); } -/// The user-message fact sheet built from the brief — only already-curated strings from the -/// deterministic pipeline. A missing section is simply absent (so the model can't restate it). +/// The user-message fact sheet that comes from the brief. It holds only already-curated strings +/// from the deterministic pipeline. A section that is missing is absent, so the model can not +/// restate it. pub fn narrate_fact_sheet(b: &SubjectBrief) -> String { let mut s = String::from("FACTS:\n"); s.push_str(&format!("Name: {}\n", b.headline.name)); @@ -120,8 +126,9 @@ pub fn narrate_fact_sheet(b: &SubjectBrief) -> String { 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. + // Fine and modern populations (present-day reference groups the person most resembles). + // Without these the story rests on the ancient components alone. This is the + // recent-ancestry layer. for (name, pct) in a.fine_pops.iter().filter(|(_, pct)| *pct >= 0.5) { s.push_str(&format!("- closest modern population: {name} ({pct:.1}%)\n")); } @@ -138,7 +145,8 @@ pub fn narrate_fact_sheet(b: &SubjectBrief) -> String { } if let Some(r) = &b.roh { - // Shared ancestry between the parents' lines (genealogical relatedness) — NOT a health signal. + // Shared ancestry between the lines of the parents (genealogical relatedness). This is + // 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!( @@ -162,8 +170,8 @@ pub fn narrate_fact_sheet(b: &SubjectBrief) -> String { s } -/// The fixed reply for a health/medical question (the M2 scope guard) — keeps the assistant in the -/// ancestry/lineage lane instead of attempting a clinical answer. +/// The fixed reply for a health or medical question (the M2 scope guard). It keeps the assistant +/// on ancestry and lineage, and stops it from a clinical answer. pub fn health_deflection() -> &'static str { "I can only help with ancestry and lineage — Navigator doesn't provide health, medical, or \ clinical interpretation. Ask me about your paternal or maternal line, your ancestry, or your test." @@ -275,7 +283,7 @@ mod tests { assert!(s.contains("tentative placement"), "confidence must survive"); assert!(s.contains("Predominantly European")); assert!(s.contains("Western Hunter-Gatherer")); - // Modern/fine populations must reach the model too — not only the ancient sources. + // Modern and fine populations must reach the model too, and not only the ancient sources. assert!( s.contains("closest modern population: British (55.0%)"), "fine pops missing: {s}" diff --git a/crates/navigator-domain/src/mtdna.rs b/crates/navigator-domain/src/mtdna.rs index 33895b58..a8d227fc 100644 --- a/crates/navigator-domain/src/mtdna.rs +++ b/crates/navigator-domain/src/mtdna.rs @@ -1,4 +1,4 @@ -//! Vendor mtDNA FASTA sequences (Scala's `DataType.MtdnaFasta`) — a full mitochondrial +//! Vendor mtDNA FASTA sequences (Scala's `DataType.MtdnaFasta`): a full mitochondrial //! sequence (~16,569 bp, aligned to rCRS) imported from a `.fa`/`.fasta` export. Unlike a //! chip, an mtDNA sequence is tiny, so we keep the sequence itself; calling variants vs //! rCRS for haplogroup analysis is a later step. [`parse_fasta`] is a pure validator. @@ -6,7 +6,7 @@ use du_domain::ids::SampleGuid; use serde::{Deserialize, Serialize}; -/// Plausible mtDNA length window (rCRS is 16,569 bp); guards against importing the wrong file. +/// Plausible mtDNA length window (rCRS is 16,569 bp). It guards against an import of the wrong file. const MIN_LEN: usize = 16_000; const MAX_LEN: usize = 17_000; @@ -15,7 +15,7 @@ const MAX_LEN: usize = 17_000; pub struct MtdnaSequence { pub id: i64, pub biosample_guid: SampleGuid, - /// The FASTA header line (without the leading `>`), if any. + /// The FASTA header line (without the `>` at the start), if any. pub defline: Option, /// The full sequence, uppercased (A/C/G/T/N). pub sequence: String, @@ -30,7 +30,7 @@ impl MtdnaSequence { } } -/// Fields for creating an mtDNA sequence (the store assigns the id). +/// Fields to make an mtDNA sequence (the store assigns the id). #[derive(Debug, Clone, PartialEq, Eq)] pub struct NewMtdnaSequence { pub biosample_guid: SampleGuid, @@ -48,9 +48,9 @@ pub struct ParsedMtdna { pub n_count: i64, } -/// Parse and validate a single-record mtDNA FASTA: must start with a `>` header; the -/// concatenated sequence must be ~16,569 bp (16,000–17,000) and contain only A/C/G/T/N. -/// Only the first record is read. Returns the sequence + `N` count. +/// Parse and check a single-record mtDNA FASTA. It must start with a `>` header. The joined +/// sequence must be ~16,569 bp (16,000–17,000), and must contain only A/C/G/T/N. This reads only +/// the first record. Returns the sequence and the `N` count. pub fn parse_fasta(text: &str) -> Result { let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); diff --git a/crates/navigator-domain/src/paths.rs b/crates/navigator-domain/src/paths.rs index 8468cf2d..b11a779a 100644 --- a/crates/navigator-domain/src/paths.rs +++ b/crates/navigator-domain/src/paths.rs @@ -1,15 +1,15 @@ -//! Where the app's per-user data lives — the one place that answers "what is home?". +//! Where the data of each user lives: the one place that answers "what is home?". //! //! Everything the app persists hangs off `~/.decodingus` (workspace DB, references, liftover -//! chains, trees, panels, config). That root used to be derived independently in six places, each -//! with `std::env::var("HOME").unwrap_or(".")`, which is wrong on Windows: `HOME` is normally unset -//! there, so every one of those paths silently resolved **relative to the current working -//! directory** — a fresh `.decodingus` tree wherever the app happened to be launched from, and no -//! two launches necessarily sharing one. +//! chains, trees, panels, config). Six places used to derive that root on their own, each with +//! `std::env::var("HOME").unwrap_or(".")`. That is wrong on Windows, where `HOME` is normally +//! unset. Every one of those paths then resolved **relative to the current directory**, with no +//! message. The app made a fresh `.decodingus` tree wherever somebody started it, and two starts +//! did not always share one. //! -//! [`home_dir`] resolves the platform's real home instead, and the callers join their own subpaths -//! onto it. Deliberately no `dirs`/`directories` dependency: the rules are short enough to state -//! (and test) directly. +//! [`home_dir`] resolves the real home of the platform instead, and the callers join their own +//! subpaths onto it. No `dirs` or `directories` dependency, on purpose: the rules are short enough +//! to state, and to test, directly. use std::ffi::OsString; use std::path::PathBuf; @@ -32,9 +32,10 @@ pub fn home_dir() -> Option { } } -/// The `~/.decodingus` root: every persisted artifact hangs off this. Falls back to a relative -/// `.decodingus` only when the platform reports no home at all — the previous behaviour, kept so a -/// homeless environment (some CI containers) still runs rather than failing at startup. +/// The `~/.decodingus` root: every persisted artifact hangs off this. It falls back to a relative +/// `.decodingus` only when the platform reports no home at all. That is the previous behaviour, +/// and it stays. An environment with no home (some CI containers) then still runs, and does not +/// fail at startup. pub fn decodingus_dir() -> PathBuf { home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".decodingus") } @@ -42,11 +43,11 @@ pub fn decodingus_dir() -> PathBuf { /// Windows home resolution, split out from [`home_dir`] so its precedence is testable on any /// platform. /// -/// `%USERPROFILE%` first, `%HOMEDRIVE%` + `%HOMEPATH%` second (domain profiles where the former is -/// unset). `%HOME%` is **not** consulted: MSYS2 / Git-Bash set it to a POSIX path like -/// `/c/Users/name`, which native Windows APIs read as a rooted path on the *current drive* — so -/// honouring it would scatter user data to a plausible-looking but wrong location, which is worse -/// than the fallback. +/// `%USERPROFILE%` first, and `%HOMEDRIVE%` with `%HOMEPATH%` second, for a domain profile where +/// `%USERPROFILE%` is unset. This does **not** read `%HOME%`. MSYS2 and Git-Bash set it to a POSIX +/// path like `/c/Users/name`, which native Windows APIs read as a rooted path on the *current +/// drive*. To obey it would put user data in a wrong location that looks correct, and that is +/// worse 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( @@ -81,7 +82,7 @@ mod tests { windows_home(os(r"C:\Users\ada"), os("D:"), os(r"\profiles\ada")), Some(PathBuf::from(r"C:\Users\ada")) ); - // Domain profile: no USERPROFILE, so the drive + path pair is joined verbatim. + // Domain profile: no USERPROFILE, so this joins the drive and the path pair verbatim. assert_eq!( windows_home(None, os("D:"), os(r"\profiles\ada")), Some(PathBuf::from(r"D:\profiles\ada")) @@ -91,13 +92,13 @@ mod tests { windows_home(os(""), os("D:"), os(r"\profiles\ada")), Some(PathBuf::from(r"D:\profiles\ada")) ); - // Half a pair is no answer — better to fall back than to build a half-formed path. + // Half a pair is no answer. It is better to fall back than to build a half-formed path. assert_eq!(windows_home(None, os("D:"), None), None); assert_eq!(windows_home(None, None, os(r"\profiles\ada")), None); assert_eq!(windows_home(None, None, None), None); } - /// The fallback stays relative rather than panicking: a container with no home still runs. + /// The fallback stays relative, and does not panic: a container with no home still runs. #[test] fn decodingus_dir_ends_in_the_conventional_directory() { assert!(decodingus_dir().ends_with(".decodingus")); diff --git a/crates/navigator-domain/src/reconciliation.rs b/crates/navigator-domain/src/reconciliation.rs index 0d8b038e..77adc031 100644 --- a/crates/navigator-domain/src/reconciliation.rs +++ b/crates/navigator-domain/src/reconciliation.rs @@ -1,10 +1,10 @@ -//! Donor-level reconciliation of Y/mtDNA haplogroup calls across multiple sources (runs, -//! platforms, Sanger). Phase 1–2 of `documents/design/MultiSource_Reconciliation.md`: -//! per-source [`RunHaplogroupCall`]s combine into a [`Consensus`] by tree topology. +//! Donor-level reconciliation of Y and mtDNA haplogroup calls over more than one source (runs, +//! platforms, Sanger). Phase 1–2 of `documents/design/MultiSource_Reconciliation.md`: the +//! [`RunHaplogroupCall`] of each source combines into a [`Consensus`] by tree topology. //! -//! Pure types + the consensus algorithm; persistence and the per-source recording live in -//! the app/store. Per-variant concordance (all DNA types) lives in [`crate::consensus`]; -//! identity verification and heteroplasmy are later phases. +//! Pure types, and the consensus algorithm. Persistence, and the record of each source, live in +//! the app and the store. Concordance at each variant (all DNA types) lives in +//! [`crate::consensus`]. Identity verification and heteroplasmy are later phases. use serde::{Deserialize, Serialize}; @@ -24,12 +24,15 @@ impl DnaType { } } -/// Where a per-source haplogroup call came from — the precedence tier used when reconciling. +/// Where the haplogroup call of one source came from: the precedence tier that reconciliation +/// uses. /// -/// - `External`: a trusted external caller (a GATK4 GVCF / 1240K call set imported via the sidecar -/// fast path). The user runs an established pipeline and wants these preferred. -/// - `NavigatorWalk`: Navigator's own genotyping — the CRAM walk, and chip/vendor-VCF placement. -/// - `Manual`: a user override (persisted separately today; included for a complete precedence order). +/// - `External`: a trusted external caller (a GATK4 GVCF or 1240K call set that the sidecar fast +/// path imported). The user runs an established pipeline and wants these to win. +/// - `NavigatorWalk`: the genotyping Navigator does itself: the CRAM walk, and chip or vendor-VCF +/// placement. +/// - `Manual`: a user override. The store holds it separately today, and it is here to make the +/// precedence order complete. /// /// Higher [`rank`](CallProvenance::rank) wins when the "prefer external caller" policy is on. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -48,8 +51,9 @@ impl CallProvenance { } } - /// Parse a stored provenance token; anything unrecognized — including legacy `NULL`/empty rows - /// written before the column existed — is the internal `NavigatorWalk` tier. + /// Parse a stored provenance token. Anything it does not recognize is the internal + /// `NavigatorWalk` tier, and that includes a legacy `NULL` or empty row from before the column + /// existed. pub fn from_token(s: &str) -> CallProvenance { match s.trim() { "external" => CallProvenance::External, @@ -58,7 +62,7 @@ impl CallProvenance { } } - /// Precedence tier — higher wins under the prefer-external policy. + /// Precedence tier: higher wins under the prefer-external policy. pub fn rank(self) -> u8 { match self { CallProvenance::NavigatorWalk => 0, @@ -77,7 +81,7 @@ pub struct RunHaplogroupCall { pub haplogroup: String, /// Root→terminal lineage of haplogroup names. pub lineage: Vec, - /// Assignment score (Kulczynski) — confidence proxy. + /// Assignment score (Kulczynski): a proxy for confidence. pub score: f64, pub matched: i64, pub expected: i64, @@ -86,13 +90,13 @@ pub struct RunHaplogroupCall { /// How compatible a set of calls is (Scala `CompatibilityLevel`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CompatibilityLevel { - /// Same branch, differing depths — all calls lie on one root→tip path. + /// Same branch, different depths: all calls lie on one root→tip path. Compatible, /// Diverge near the tips. MinorDivergence, /// Diverge on the backbone. MajorDivergence, - /// Diverge near the root — likely different individuals. + /// Diverge near the root: probably different individuals. Incompatible, } @@ -121,7 +125,7 @@ pub struct AuditEntry { pub note: String, } -/// Whether multiple sources come from the same individual (Scala `IdentityVerification`). +/// Whether more than one source comes from the same individual (Scala `IdentityVerification`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum VerificationStatus { VerifiedSame, @@ -131,8 +135,9 @@ pub enum VerificationStatus { VerifiedDifferent, } -/// Identity evidence between two sources: autosomal genotype concordance (the primary -/// signal — same individual ≈ 1.0, relatives notably lower) plus Y-STR corroboration. +/// Identity evidence between two sources: autosomal genotype concordance, plus Y-STR +/// corroboration. Concordance is the primary signal, at ≈ 1.0 for the same individual, and clearly +/// lower for relatives. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct IdentityVerification { pub status: VerificationStatus, @@ -140,7 +145,8 @@ pub struct IdentityVerification { /// Fraction of shared-called sites with identical dosage (0–1), if any compared. pub snp_concordance: Option, pub sites_compared: i64, - /// Differing Y-STR markers across shared markers, if STR profiles were available. + /// The count of Y-STR markers that do not agree, over the shared markers, when STR profiles + /// exist. pub y_str_distance: Option, pub y_str_markers: i64, } @@ -175,7 +181,8 @@ pub fn classify_identity( }; (s, m.to_string()) } - // No shared genotypes: Y-STR alone is paternal-line only — never "verified". + // No shared genotypes. Y-STR alone covers the paternal line only, and is never + // "verified". _ if y_str_markers > 0 => { let s = match y_str_distance { Some(0) => VerificationStatus::LikelySame, @@ -213,13 +220,14 @@ fn common_prefix(calls: &[RunHaplogroupCall]) -> Vec { first.lineage[..len].to_vec() } -/// Reconcile per-source calls into a donor-level consensus. +/// Reconcile the calls of each source into a donor-level consensus. /// -/// When all calls lie on one root→tip path (compatible), the consensus is the **most -/// confident** call — not blindly the deepest, since a low-coverage source may extend one -/// node further on thin evidence; any strictly-deeper call is reported as a tentative -/// warning. When calls diverge, the consensus is the deepest node they all agree on (the -/// LCA), and the divergence depth sets the compatibility level. +/// When all calls lie on one root→tip path (compatible), the consensus is the **most confident** +/// call, and not only the deepest. A low-coverage source can extend one node further on thin +/// evidence. Any call that is strictly deeper becomes a tentative warning. +/// +/// When calls diverge, the consensus is the deepest node they all agree on (the LCA), and the +/// divergence depth sets the compatibility level. pub fn reconcile(calls: &[RunHaplogroupCall]) -> Option { if calls.is_empty() { return None; @@ -259,8 +267,8 @@ pub fn reconcile(calls: &[RunHaplogroupCall]) -> Option { let prefix = common_prefix(calls); let max_depth = longest.lineage.len().max(1); let ratio = prefix.len() as f64 / max_depth as f64; - // Sharing only the root (≤1 node) means different lineages entirely. Otherwise the - // LCA's relative depth distinguishes a tip split from a backbone split. + // Two lineages that share only the root (≤1 node) are completely different. If they share + // more, the relative depth of the LCA separates a tip split from a backbone split. let compatibility = if prefix.len() <= 1 { CompatibilityLevel::Incompatible } else if ratio >= 0.66 { @@ -294,15 +302,16 @@ pub fn reconcile(calls: &[RunHaplogroupCall]) -> Option { }) } -/// Reconcile per-source calls, honoring call provenance. +/// Reconcile the calls of each source, and obey the call provenance. +/// +/// With `prefer_external` set, only the highest-precedence tier present +/// (Manual > External > NavigatorWalk) goes into the consensus. A lower tier that places a +/// different terminal becomes a warning, and it can not pull the call away. A damaged ancient-DNA +/// CRAM walk can out-score a clean external GATK4 or 1240K placement, and then take its place with +/// no message. This is what stops that. /// -/// When `prefer_external` is set, only the highest-precedence tier present -/// (Manual > External > NavigatorWalk) is reconciled into the consensus; lower tiers that place a -/// different terminal are surfaced as a warning rather than allowed to drag the call. This is what -/// stops a damaged ancient-DNA CRAM walk from out-scoring — and silently replacing — a clean -/// external GATK4/1240K placement. When `prefer_external` is false, every call is reconciled -/// together by confidence (the source-blind [`reconcile`]), so the two behave identically whenever -/// no external call is present. +/// With `prefer_external` false, every call reconciles together by confidence, which is the +/// source-blind [`reconcile`]. So the two behave the same whenever there is no external call. pub fn reconcile_with_provenance( calls: &[(CallProvenance, RunHaplogroupCall)], prefer_external: bool, @@ -321,8 +330,8 @@ pub fn reconcile_with_provenance( .map(|(_, c)| c.clone()) .collect(); let mut consensus = reconcile(&top_calls)?; - // Note any lower-precedence source that places a *different* terminal — informative, not - // authoritative (the external caller was preferred). + // Note any lower-precedence source that places a *different* terminal. It is informative, and + // not authoritative, because the external caller won. let mut lower: Vec<&str> = calls .iter() .filter(|(p, _)| p.rank() < top) @@ -423,7 +432,7 @@ mod tests { #[test] fn root_divergence_is_incompatible() { - // Share only "root" — different haplogroups entirely (different individuals?). + // Share only "root": completely different haplogroups (different individuals?). let a = call("a", 0.8, &["root", "R", "R-M269", "R-L21"]); let b = call("b", 0.8, &["root", "J", "J-M267"]); let c = reconcile(&[a, b]).unwrap(); @@ -432,8 +441,9 @@ mod tests { #[test] fn prefer_external_wins_over_a_higher_scoring_walk() { - // The ancient-DNA case: the CRAM walk out-scores the clean external call (deamination - // inflates matched sites onto a *different* deep terminal), yet the external call must win. + // The ancient-DNA case. The CRAM walk out-scores the clean external call, because + // deamination inflates matched sites onto a *different* deep terminal. The external call + // must still win. 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( diff --git a/crates/navigator-domain/src/results_context.rs b/crates/navigator-domain/src/results_context.rs index 67c72a50..91949800 100644 --- a/crates/navigator-domain/src/results_context.rs +++ b/crates/navigator-domain/src/results_context.rs @@ -1,14 +1,17 @@ //! The grounding context for the M4 "ask my results" chat (see -//! `documents/design/local-llm-expansion.md`). Narration (M1) stays grounded in the lean -//! [`SubjectBrief`](crate::brief::SubjectBrief) fact sheet; the chat needs to answer about *more* -//! signals (Y-STR panels, private-Y variants, mtDNA mutations, IBD matches, genetic sex), so it -//! grounds in a [`ResultsContext`] = the brief plus curated, summary-level facts for those signals. +//! `documents/design/local-llm-expansion.md`). Narration (M1) keeps its grounding in the lean +//! [`SubjectBrief`](crate::brief::SubjectBrief) fact sheet. The chat must answer about *more* +//! signals: Y-STR panels, private-Y variants, mtDNA mutations, IBD matches, and genetic sex. So it +//! grounds in a [`ResultsContext`], which is the brief plus curated, summary-level facts for those +//! signals. //! -//! As with [`llm_prompt`](crate::llm_prompt), this layer is pure: the per-signal facts are plain, -//! already-vetted values the app fills in (the domain crate must not depend on analysis/store), and -//! [`results_fact_sheet`] is the unit-tested builder so the exact text we send stays reviewable. The -//! model remains a *rewriter, not a source of facts*: every section is summary-level, and the inline -//! notes keep STR/mtDNA/IBD framed as lineage facts, never health or trait claims. +//! Like [`llm_prompt`](crate::llm_prompt), this layer is pure. The facts for each signal are plain +//! values that the app already vetted and filled in. The domain crate must not depend on analysis +//! or the store. [`results_fact_sheet`] is the builder, and it has unit tests, so a person can +//! review the exact text we send. +//! +//! The model stays a *rewriter, not a source of facts*. Every section is summary-level, and the +//! inline notes keep STR, mtDNA and IBD as lineage facts, never as health or trait claims. use crate::brief::SubjectBrief; use crate::llm_prompt::narrate_fact_sheet; @@ -22,7 +25,8 @@ pub struct SexFact { pub confidence: String, } -/// One Y-STR panel: its name and how many markers it carries (summary only — never the raw values). +/// One Y-STR panel: its name, and how many markers it carries. Summary only, and never the raw +/// values. #[derive(Debug, Clone, PartialEq, Eq)] pub struct YStrPanelFact { pub panel: String, @@ -33,23 +37,25 @@ pub struct YStrPanelFact { /// distinction: novel-in-unique-sequence vs off-path vs structural-region). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PrivateYFact { - /// Novel calls in unique sequence — the high-confidence new-branch candidates. + /// Novel calls in unique sequence: the high-confidence new-branch candidates. pub novel_unique: usize, - /// Variants off known branches — suggest previously-unknown branch depth. + /// Variants off known branches. They suggest branch depth that nobody knew before. pub off_path: usize, - /// Calls in structural/paralog-prone regions — suspect, to be treated with caution. + /// Calls in structural regions, and regions where paralogs are common. Read them with + /// caution. pub structural: usize, } -/// Above this many novel-in-unique private-Y calls, a single sample is almost certainly reporting -/// artifacts rather than real new-branch candidates. The de-novo tree pipeline's per-WGS-sample novel -/// count runs ~3–39 (median), so a count in the dozens is normal and the low hundreds is a red flag — -/// typically contamination, shallow/uneven coverage, or a reference-build mismatch. +/// Above this count of novel-in-unique private-Y calls, one sample almost certainly gives +/// artifacts, and not real new-branch candidates. In the de-novo tree pipeline, the novel count of +/// one WGS sample runs ~3–39 (median). So a count in the dozens is normal, and a count in the low +/// hundreds is a warning. The usual causes are contamination, shallow or uneven coverage, and a +/// mismatch of the reference build. pub const PRIVATE_Y_QC_WARN: usize = 50; -/// A one-line QC banner when the novel-in-unique private-Y count is implausibly high for one sample -/// (see [`PRIVATE_Y_QC_WARN`]), else `None`. Surfaced in reports and the `private-y` CLI so an -/// elevated count reads as "check this sample" rather than "you have this many new branches". +/// A one-line QC banner when the novel-in-unique private-Y count is too high to be plausible for +/// one sample (see [`PRIVATE_Y_QC_WARN`]). `None` if not. Reports and the `private-y` CLI show it. +/// A high count then reads as "check this sample", and not as "you have this many new branches". pub fn private_y_qc_banner(novel_unique: usize) -> Option { (novel_unique >= PRIVATE_Y_QC_WARN).then(|| { format!( @@ -71,9 +77,9 @@ pub struct MtMutationsFact { pub examples: Vec, } -/// One IBD match, reduced to relationship band + sharing — **no identifying details** (the partner is -/// deliberately not named; this is the user's own workspace data described as a relationship, not a -/// person). +/// One IBD match, reduced to the relationship band and how much DNA the two share. It carries **no +/// detail that identifies anybody**. It does not name the partner, and that is deliberate. This is +/// the workspace data of the user, described as a relationship and not as a person. #[derive(Debug, Clone, PartialEq)] pub struct IbdMatchFact { pub relationship: String, @@ -88,9 +94,10 @@ pub struct IbdFact { pub closest: Option, } -/// The brief plus curated summaries of the other signals — the grounding context for the M4 chat. -/// Absent signals are `None` / empty and are simply omitted from the fact sheet (so the model can't -/// restate what is not there), exactly like the brief's own optional sections. +/// The brief, plus curated summaries of the other signals: the grounding context for the M4 chat. +/// A signal that is absent is `None` or empty, and the fact sheet leaves it out. The model can +/// then not restate what is not there. This is exactly what the brief does with its own optional +/// sections. #[derive(Debug, Clone, PartialEq)] pub struct ResultsContext { pub brief: SubjectBrief, @@ -101,8 +108,8 @@ pub struct ResultsContext { pub ibd: Option, } -/// Which result signal a per-tab "Explain this" narration (M5) targets — also the key used to pull a -/// single signal's section out of a [`ResultsContext`] via [`signal_section`]. +/// Which result signal an "Explain this" narration on one tab (M5) points at. It is also the key +/// that takes the section of one signal out of a [`ResultsContext`], through [`signal_section`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum SignalKind { Sex, @@ -129,11 +136,11 @@ impl SignalKind { } } -// --- Per-signal section builders ----------------------------------------------------------------- -// Each returns the labelled, summary-level block for one signal (leading `\n`, so they concatenate -// cleanly after the brief's fact sheet), or `None` when the subject has nothing for that signal. The -// blocks are shared verbatim between the M4 chat grounding ([`results_fact_sheet`]) and the M5 -// per-tab narration ([`signal_section`]). +// --- Section builders, one for each signal ------------------------------------------------------- +// Each returns the labelled, summary-level block for one signal, or `None` when the subject has +// nothing for that signal. Each block starts with `\n`, so the blocks join cleanly after the fact +// sheet of the brief. The M4 chat grounding ([`results_fact_sheet`]) and the M5 narration on one +// tab ([`signal_section`]) share these blocks verbatim. fn sex_section(sex: &Option) -> Option { let sex = sex.as_ref()?; @@ -214,10 +221,10 @@ fn ibd_section(ibd: &Option) -> Option { Some(s) } -/// Runs-of-homozygosity section, read from the brief's [`RohBrief`](crate::brief::RohBrief) (ROH is a -/// brief signal, unlike the others, so its source is `ctx.brief.roh`). Used for the M5 per-tab -/// "Explain this" on the ROH card; the M4 chat already carries ROH via the brief's own fact sheet -/// (see [`results_fact_sheet`]), so it is deliberately *not* re-appended there. +/// Runs-of-homozygosity section, from the [`RohBrief`](crate::brief::RohBrief) of the brief. ROH is +/// a brief signal, and the others are not, so its source is `ctx.brief.roh`. The M5 "Explain this" +/// on the ROH card uses it. The M4 chat already carries ROH through the fact sheet of the brief +/// (see [`results_fact_sheet`]). So this does *not* add it there again, and that is deliberate. fn roh_section(brief: &SubjectBrief) -> Option { let r = brief.roh.as_ref()?; let mut s = String::from("\nShared ancestry (runs of homozygosity):\n"); @@ -260,8 +267,9 @@ fn archaic_section(brief: &SubjectBrief) -> Option { _ => s.push_str("- no population comparison: the test covers too few marker sites to rank\n"), } s.push_str(&format!("- pattern: {}\n", a.pattern)); - // The model must not turn a count into a percentage or a Denisovan claim; both are explicitly - // out of scope for this signal (design S1/S7) and the narration is grounded only in this text. + // The model must not turn a count into a percentage, and must not make a Denisovan claim. The + // design puts both out of scope for this signal (S1 and S7), and this text is the only + // grounding the narration has. s.push_str( "- note: this is a COUNT of marker copies, not a percentage of the genome, and it is specific \ to this panel — do not compare it to another company's number, and do not restate it as a \ @@ -274,8 +282,8 @@ fn archaic_section(brief: &SubjectBrief) -> Option { Some(s) } -/// The labelled section for a single signal, or `None` when the subject has nothing for it — the -/// grounding for an M5 per-tab "Explain this" narration of just that signal. +/// The labelled section for one signal, or `None` when the subject has nothing for it. This is the +/// grounding for an M5 "Explain this" narration of that one signal. pub fn signal_section(ctx: &ResultsContext, kind: SignalKind) -> Option { match kind { SignalKind::Sex => sex_section(&ctx.sex), @@ -288,9 +296,10 @@ pub fn signal_section(ctx: &ResultsContext, kind: SignalKind) -> Option } } -/// Build the chat's grounding fact sheet: the brief's fact sheet (unchanged from narration) plus a -/// labelled, summary-level block per present signal. Pure and unit-tested — this is the exact text -/// that goes in the chat system message as "your only source of facts". +/// Build the grounding fact sheet of the chat. It is the fact sheet of the brief, which narration +/// does not change, plus a labelled, summary-level block for each signal that is there. Pure, and +/// unit tested. This is the exact text that goes into the chat system message as "your only source +/// of facts". pub fn results_fact_sheet(ctx: &ResultsContext) -> String { let mut s = narrate_fact_sheet(&ctx.brief); for section in [ @@ -299,8 +308,9 @@ pub fn results_fact_sheet(ctx: &ResultsContext) -> String { private_y_section(&ctx.private_y), mt_section(&ctx.mt_mutations), ibd_section(&ctx.ibd), - // ROH already reaches the sheet via narrate_fact_sheet (it lives on the brief); the archaic - // block does not, so add it explicitly or the chat can not answer about it. + // ROH already reaches the sheet through narrate_fact_sheet, because it lives on the + // brief. The archaic block does not, so add it here, or the chat can not answer about + // it. archaic_section(&ctx.brief), ] .into_iter() @@ -439,20 +449,20 @@ mod tests { longest_mb: 7.1, }); - // M4 chat: the fact sheet carries the ROH facts (via the brief) with the run counts. + // M4 chat: the fact sheet carries the ROH facts, through the brief, with the run counts. let sheet = results_fact_sheet(&ctx); assert!(sheet.contains("Shared ancestry (runs of homozygosity)")); assert!(sheet.contains("pattern: Outbred")); assert!(sheet.contains("F_ROH: 0.0080")); assert!(sheet.contains("6 run(s)")); - // M5 per-tab: the focused section renders and stays in the ancestry lane (no health language). + // M5 on one tab: the focused section draws, and stays on ancestry (no health language). let section = signal_section(&ctx, SignalKind::Roh).expect("roh section"); assert!(section.contains("F_ROH: 0.0080")); assert!(section.contains("longest 7 Mb")); assert!(!mentions_health(§ion), "ROH must not read as a health result"); - // Absent when ROH has not been computed. + // Absent until something computes ROH. ctx.brief.roh = None; assert!(signal_section(&ctx, SignalKind::Roh).is_none()); } @@ -475,8 +485,8 @@ mod tests { assert!(section.contains("12126 archaic-allele copies out of 599864")); assert!(section.contains("more than 12% of EUR")); - // 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. + // The grounding must push the model off the two forms the design forbids. It must not + // restate a count as a percent-Neanderthal, and it must not report a Denisovan result. assert!( section.contains("not a percentage"), "must warn against percent framing" @@ -490,7 +500,7 @@ mod tests { // The chat fact sheet carries it too. assert!(results_fact_sheet(&ctx).contains("Neanderthal (archaic) markers")); - // Absent until the count has been computed. + // Absent until something computes the count. ctx.brief.archaic = None; assert!(signal_section(&ctx, SignalKind::Archaic).is_none()); } @@ -557,7 +567,7 @@ mod tests { let ctx = full_context(); let ystr = signal_section(&ctx, SignalKind::YStr).unwrap(); assert!(ystr.contains("Y-111 (111 markers)")); - // It is just that signal — not the private-Y or sex blocks. + // It is only that signal, and not the private-Y block or the sex block. assert!(!ystr.contains("Private Y variants")); assert!(!ystr.contains("Genetic sex")); diff --git a/crates/navigator-domain/src/roh.rs b/crates/navigator-domain/src/roh.rs index f172f633..6dd34692 100644 --- a/crates/navigator-domain/src/roh.rs +++ b/crates/navigator-domain/src/roh.rs @@ -1,19 +1,21 @@ //! Runs-of-homozygosity domain types. //! -//! The pattern read is a *classification*, not a rendering: it is computed once by -//! `navigator_analysis::roh` (which re-exports this enum) and then consumed by both the Advanced ROH -//! chart and the Simple-mode brief. It lives here, below the analysis engine, so the brief builder -//! in [`crate::brief`] can switch on the canonical verdict instead of re-deriving its own. +//! The pattern read is a *classification*, and not a way to draw it. `navigator_analysis::roh` +//! computes it one time, and re-exports this enum. Both the Advanced ROH chart and the Simple-mode +//! brief then read it. It lives here, below the analysis engine. The brief builder in +//! [`crate::brief`] can then switch on the canonical verdict, and does not derive its own. -/// Coarse pattern read from the ROH length distribution. Heuristic — for narration, not diagnosis. +/// Coarse pattern read from the ROH length distribution. It is a heuristic, for narration and not +/// for diagnosis. #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] pub enum RohPattern { - /// Little total ROH — outbred. + /// Little total ROH: outbred. Outbred, - /// ROH mass dominated by short segments — background relatedness / endogamous population. + /// Short segments hold most of the ROH mass: background relatedness, or an endogamous + /// population. Endogamy, - /// ROH mass dominated by long segments — recent consanguinity in the pedigree. + /// Long segments hold most of the ROH mass: recent consanguinity in the pedigree. RecentConsanguinity, - /// Substantial ROH across all classes. + /// Large ROH across all classes. Mixed, } diff --git a/crates/navigator-domain/src/seq.rs b/crates/navigator-domain/src/seq.rs index 671d4583..98c7e3c8 100644 --- a/crates/navigator-domain/src/seq.rs +++ b/crates/navigator-domain/src/seq.rs @@ -1,6 +1,6 @@ -//! Tiny shared sequence helpers used across the desktop crates (navigator-domain, -analysis, -app -//! all depend on navigator-domain, so this is their common home — no extra dependencies). Keep it to -//! pure, allocation-free base math. +//! Tiny shared sequence helpers for the desktop crates. The `navigator-domain`, +//! `navigator-analysis` and `navigator-app` crates all depend on `navigator-domain`, so this is +//! their common home, and it adds no dependency. Keep it to pure, allocation-free base math. /// Watson–Crick complement of a single base (`char`); non-ACGT (incl. lowercase non-matches and `N`) /// passes through unchanged. Used for strand reconciliation against a reference/tree. @@ -14,7 +14,7 @@ pub fn complement_base(b: char) -> char { } } -/// Byte (`u8`) variant of [`complement_base`] for callers working on raw allele bytes. +/// Byte (`u8`) variant of [`complement_base`], for a caller that works on raw allele bytes. pub fn complement_base_u8(b: u8) -> u8 { match b.to_ascii_uppercase() { b'A' => b'T', diff --git a/crates/navigator-domain/src/strchart.rs b/crates/navigator-domain/src/strchart.rs index 61a81000..5e699f12 100644 --- a/crates/navigator-domain/src/strchart.rs +++ b/crates/navigator-domain/src/strchart.rs @@ -1,10 +1,11 @@ -//! Aggregation for the FTDNA-style project "Y-DNA Results Overview" chart: per-subgroup, per-marker -//! MIN / MAX / MODE statistics and per-cell deviation from the modal value (the colour coding). +//! Aggregation for the FTDNA-style project "Y-DNA Results Overview" chart. It gives MIN, MAX and +//! MODE statistics for each subgroup and each marker. For each cell it also gives the deviation +//! from the modal value, which is the colour code. //! -//! Marker values are kept as text (a multi-copy marker like DYS385 reports "11-15", DYS464 reports -//! "14-15-16-17", CDY "37-37"). For ordering we parse a value into its sorted allele tuple and -//! compare tuples; for the modal value we count the canonical (sorted) string. Non-numeric or null -//! values ("-", "") are ignored. +//! This module holds marker values as text (a multi-copy marker like DYS385 reports "11-15", +//! DYS464 reports "14-15-16-17", CDY "37-37"). For ordering we parse a value into its sorted +//! allele tuple and compare tuples. For the modal value we count the canonical (sorted) string. +//! It drops a value that is not numeric, and a null value ("-", ""). /// Parse an STR marker value into its sorted allele tuple, e.g. "11-15" → [11, 15], "13" → [13]. /// Returns `None` for null/non-numeric values so callers can skip them. @@ -42,8 +43,8 @@ pub struct MarkerStats { pub mode: Option, } -/// Summarise one marker column over a set of member values: numeric MIN/MAX (by sorted-tuple order) -/// and the MODE (most frequent canonical value, ties → smallest tuple). +/// Summarise one marker column over a set of member values. Gives the numeric MIN and MAX (by +/// sorted-tuple order) and the MODE (most frequent canonical value, ties → smallest tuple). pub fn marker_stats<'a, I>(values: I) -> MarkerStats where I: IntoIterator, @@ -75,7 +76,7 @@ fn tuple_str(t: &[i32]) -> String { t.iter().map(|n| n.to_string()).collect::>().join("-") } -/// How a cell's value relates to its subgroup's modal value — drives the colour coding. +/// How a cell's value relates to the modal value of its subgroup. This drives the colour code. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Deviation { /// Equal to (or order-equivalent to) the mode, or no mode / unparseable. diff --git a/crates/navigator-domain/src/strpanel.rs b/crates/navigator-domain/src/strpanel.rs index be8b25e0..20da4227 100644 --- a/crates/navigator-domain/src/strpanel.rs +++ b/crates/navigator-domain/src/strpanel.rs @@ -1,12 +1,15 @@ -//! Y-STR panel taxonomy + classification — a port of the Scala `str-panels.conf` + -//! `StrPanelService`. Static data: the FTDNA tiers (Y-12 ⊂ Y-25 ⊂ Y-37 ⊂ Y-67 ⊂ Y-111) and the -//! YSEQ panels (Alpha/Beta/Delta/Gamma + the exclusive/Kittler detection sets), used to classify a -//! profile into its highest reached tier, group markers into tiers for the FTDNA-style report, and -//! detect the provider from exclusive markers. +//! Y-STR panel taxonomy and classification: a port of the Scala `str-panels.conf` and +//! `StrPanelService`. //! -//! FTDNA Big-Y bonus tiers (Y-500/Y-700, ~700 markers) are intentionally **not** enumerated here; -//! markers outside the defined tiers land in the [`EXTENDED`] group in [`assign_markers_to_panels`] -//! and still count toward the total. Full Y-500/700 enumeration is a follow-up. +//! Static data. It holds the FTDNA tiers (Y-12 ⊂ Y-25 ⊂ Y-37 ⊂ Y-67 ⊂ Y-111) and the YSEQ panels +//! (Alpha, Beta, Delta, Gamma, and the exclusive and Kittler detection sets). Three jobs use it. +//! They classify a profile into its highest reached tier, group markers into tiers for the +//! FTDNA-style report, and detect the provider from exclusive markers. +//! +//! This does **not** list the FTDNA Big-Y bonus tiers (Y-500/Y-700, ~700 markers), and that is +//! deliberate. A marker outside the tiers here lands in the [`EXTENDED`] group in +//! [`assign_markers_to_panels`], and still counts toward the total. A full list of Y-500 and Y-700 +//! is a follow-up. use std::collections::{HashMap, HashSet}; @@ -14,7 +17,8 @@ use crate::strprofile::StrMarker; /// One panel tier: the markers **new** to this tier (FTDNA panels are cumulative, so each lists only /// its additions). `marketing_count` is the vendor-advertised size; `actual_count` is the distinct -/// marker-key count (smaller, because multi-value markers like DYS464 count as several values). +/// marker-key count (smaller, because a multi-value marker like DYS464 counts as more than one +/// value). pub struct StrPanelDef { pub id: &'static str, pub name: &'static str, @@ -284,7 +288,8 @@ static PANELS: &[StrPanelDef] = &[ order: 4, markers: YSEQ_GAMMA, }, - // Detection-only panels (order >= 100): used for grouping/assignment, excluded from tier badges. + // Detection-only panels (order >= 100). They group and assign markers, but no tier badge uses + // them. StrPanelDef { id: "YSEQ_EXCLUSIVE", name: "YSEQ-Exclusive", @@ -368,7 +373,7 @@ pub fn detect_provider(markers: &HashSet) -> Option<&'static str> { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PanelClassification { - /// Highest tier reached, e.g. "Y-37" — `None` if no tier threshold was met. + /// Highest tier reached, for example "Y-37". `None` if the profile met no tier threshold. pub panel_name: Option, pub provider: &'static str, pub marker_count: usize, @@ -376,8 +381,8 @@ pub struct PanelClassification { } /// Classify a profile into its highest reached tier for `provider` (auto-detected if `None`). -/// Mirrors `StrPanelService`: cumulative providers require ≥90% of a tier's `actual_count` markers; -/// non-cumulative providers take the highest-order tier with ≥80% overlap. +/// Mirrors `StrPanelService`. A cumulative provider needs ≥90% of the `actual_count` markers of a +/// tier. A provider that is not cumulative takes the highest-order tier with ≥80% overlap. pub fn classify_panel(markers: &HashSet, provider: Option<&str>) -> PanelClassification { let prov = provider .map(canonical_provider) @@ -425,9 +430,9 @@ pub fn classify_panel(markers: &HashSet, provider: Option<&str>) -> Pane } } -/// Per-tier "reached" flags for the summary badges: `(tier name, filled)` where a tier is filled -/// when the profile's distinct-marker count meets its threshold. Detection-only panels (order ≥100) -/// are excluded. +/// A "reached" flag for each tier, for the summary badges: `(tier name, filled)`. A tier is full +/// when the distinct-marker count of the profile meets its threshold. This drops detection-only +/// panels (order ≥100). pub fn tier_badges(provider: &str, marker_count: usize) -> Vec<(String, bool)> { let prov = canonical_provider(provider); let mut tiers: Vec<&StrPanelDef> = PANELS.iter().filter(|p| p.provider == prov && p.order < 100).collect(); @@ -438,16 +443,16 @@ pub fn tier_badges(provider: &str, marker_count: usize) -> Vec<(String, bool)> { .collect() } -/// Group a profile's markers into tiers (FTDNA-style report layout): each marker is assigned to the -/// first tier (by order) that lists it; leftovers go to [`EXTENDED`]. Returns `(tier name, markers)` -/// ordered by tier, then the Extended group last (each non-empty). Marker order within a tier -/// follows the profile's order. +/// Group the markers of a profile into tiers (FTDNA-style report layout). Each marker goes to the +/// first tier (by order) that lists it, and the rest go to [`EXTENDED`]. Returns +/// `(tier name, markers)` in tier order, with the Extended group last, and each group not empty. +/// Marker order inside a tier follows the order of the profile. pub fn assign_markers_to_panels<'a>(markers: &'a [StrMarker], provider: &str) -> Vec<(String, Vec<&'a StrMarker>)> { let prov = canonical_provider(provider); let mut tiers: Vec<&StrPanelDef> = PANELS.iter().filter(|p| p.provider == prov).collect(); tiers.sort_by_key(|p| p.order); - // marker -> owning tier name (first/lowest-order tier listing it). + // marker -> the name of the tier that owns it (the first tier by order that lists it). let mut owner: HashMap = HashMap::new(); for p in &tiers { for m in p.markers { @@ -519,7 +524,7 @@ mod tests { #[test] fn detects_yseq_from_exclusive_marker() { - // A set containing a YSEQ-exclusive marker auto-detects provider YSEQ. + // A set that holds a YSEQ-exclusive marker auto-detects provider YSEQ. let mut names = FTDNA_Y12.to_vec(); names.push("DYS728"); // YSEQ exclusive let set = normalized_set(&marks(&names)); diff --git a/crates/navigator-domain/src/strprofile.rs b/crates/navigator-domain/src/strprofile.rs index 1ad4afa4..d73868dc 100644 --- a/crates/navigator-domain/src/strprofile.rs +++ b/crates/navigator-domain/src/strprofile.rs @@ -1,14 +1,14 @@ -//! Y-STR profiles — a subject's short-tandem-repeat marker calls (e.g. DYS393=13), -//! grouped by the panel/test that produced them (Y-37, Big Y-700 STRs, …). A pragmatic -//! port of the Scala `StrProfile`: marker values + panel provenance, without the AT-URI/ -//! sync/derivation metadata (added later if needed). Types are pure; [`parse_csv`] turns -//! exported marker tables (FTDNA/YSEQ-style) into markers without touching the filesystem. +//! Y-STR profiles: the short-tandem-repeat marker calls of a subject (for example DYS393=13), +//! grouped by the panel or test that made them (Y-37, Big Y-700 STRs, …). A pragmatic port of the +//! Scala `StrProfile`: marker values and panel provenance, with no AT-URI, sync or derivation +//! metadata (that can come later). The types are pure. [`parse_csv`] turns an exported marker +//! table (FTDNA or YSEQ style) into markers, and never reads the filesystem. use du_domain::ids::SampleGuid; use serde::{Deserialize, Serialize}; -/// One STR marker call. `value` is kept as text because multi-copy markers report several -/// alleles (e.g. "16-17" for DYS385) and palindromic markers can carry "-"/null. +/// One STR marker call. This keeps `value` as text, because a multi-copy marker reports more than +/// one allele (for example "16-17" for DYS385). A palindromic marker can also carry "-" or null. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StrMarker { pub marker: String, @@ -23,13 +23,14 @@ pub struct ConsensusStrMarker { pub value: String, /// Panels that reported a (non-null) value for this marker. pub panels: usize, - /// True when panels reported differing values (a conflict to surface). + /// True when the panels reported values that do not agree (a conflict to show). pub conflict: bool, } -/// Merge a subject's STR panels into one **donor consensus** profile: per marker, the modal -/// (most common) value across panels, flagged when panels disagree. Null/palindromic-null -/// values are skipped. Markers keep their first-seen order. +/// Merge the STR panels of a subject into one **donor consensus** profile. For each marker it +/// takes the modal (most common) value over the panels, and flags it when the panels disagree. It +/// drops a null value and a palindromic null. Markers keep the order in which they first +/// appeared. pub fn consensus_markers(profiles: &[StrProfile]) -> Vec { use std::collections::HashMap; let mut order: Vec = Vec::new(); @@ -78,14 +79,14 @@ pub struct StrProfile { pub biosample_guid: SampleGuid, /// Panel name (one of [`KNOWN_PANELS`] or custom), e.g. "Y-37". pub panel_name: String, - /// Testing company / source (one of [`KNOWN_PROVIDERS`]). + /// Test company or source (one of [`KNOWN_PROVIDERS`]). pub provider: Option, - /// How the STRs were obtained (one of [`KNOWN_SOURCES`]). + /// Where the STRs came from (one of [`KNOWN_SOURCES`]). pub source: Option, pub markers: Vec, } -/// Fields for creating an STR profile (the store assigns the id). +/// Fields to make an STR profile (the store assigns the id). #[derive(Debug, Clone, PartialEq, Eq)] pub struct NewStrProfile { pub biosample_guid: SampleGuid, @@ -108,10 +109,10 @@ pub const KNOWN_PANELS: &[&str] = &[ "CUSTOM", ]; -/// Known testing companies / sources. +/// Known test companies and sources. pub const KNOWN_PROVIDERS: &[&str] = &["FTDNA", "YSEQ", "NEBULA", "DANTE", "WGS_DERIVED", "OTHER"]; -/// How a profile's STRs were obtained. +/// Where the STRs of a profile came from. pub const KNOWN_SOURCES: &[&str] = &[ "DIRECT_TEST", "WGS_DERIVED", @@ -120,8 +121,8 @@ pub const KNOWN_SOURCES: &[&str] = &[ "MANUAL_ENTRY", ]; -/// Trim whitespace and one layer of surrounding double-quotes from a cell (FTDNA/YSEQ pad -/// values like `" 13"`), then trim again. +/// Trim whitespace and one layer of double-quotes from around a cell (FTDNA and YSEQ pad values +/// like `" 13"`), then trim again. fn clean_cell(s: &str) -> &str { s.trim().trim_matches('"').trim() } @@ -162,15 +163,15 @@ fn looks_like_values(cells: &[&str]) -> bool { numeric * 10 >= non_empty.len() * 8 // ≥80% } -/// Parse an exported STR marker table into markers. Two layouts are accepted: +/// Parse an exported STR marker table into markers. It accepts two layouts: /// -/// * **Tall**: one `marker,value` (a.k.a. `locus`/`allele`/`result`) row per line, with or -/// without a header row. -/// * **Wide**: the FTDNA / YSEQ export shape — a single header row of marker names and a -/// single parallel row of values (often quoted and space-padded, e.g. `" 13"`). +/// * **Tall**: one `marker,value` row on each line (also `locus`, `allele` or `result`), with a +/// header row or without one. +/// * **Wide**: the FTDNA or YSEQ export shape. This is one header row of marker names, and one +/// parallel row of values (often quoted and space-padded, for example `" 13"`). /// -/// Comma- or tab-separated; blank lines and leading `#` comments are ignored; markers with an -/// empty/`-` value are skipped. Errors only if no usable markers are found. +/// Cells are comma-separated or tab-separated. This drops a blank line, a `#` comment at the start +/// of a line, and a marker whose value is empty or `-`. Errors only if it finds no usable marker. pub fn parse_csv(text: &str) -> Result, String> { let content: Vec<&str> = text .lines() @@ -178,9 +179,10 @@ pub fn parse_csv(text: &str) -> Result, String> { .filter(|l| !l.is_empty() && !l.starts_with('#')) .collect(); - // Wide layout: exactly two rows — a header of marker names and a parallel row of values. - // Guard against a 2-row tall file by requiring several columns and confirming the first - // row reads as names (mostly letters) and the second as values (mostly digits/dashes). + // Wide layout: exactly two rows, a header of marker names and a parallel row of values. + // To guard against a tall file of two rows, this needs more than a few columns. It also checks + // that the first row reads as names (mostly letters), and the second as values (mostly digits + // and dashes). if content.len() == 2 { let sep = if content[0].contains('\t') { '\t' } else { ',' }; let names: Vec<&str> = content[0].split(sep).map(clean_cell).collect(); @@ -201,7 +203,7 @@ pub fn parse_csv(text: &str) -> Result, String> { } } - // Tall layout: one marker per row. + // Tall layout: one marker on each row. let mut markers = Vec::new(); let mut header_checked = false; for line in content { @@ -235,9 +237,9 @@ pub fn parse_csv(text: &str) -> Result, String> { Ok(markers) } -/// Y-STR distance between two profiles: differing marker values over markers present in -/// both. Returns (differing, compared). Distance 0 over many shared markers is consistent -/// with a shared paternal line (identity corroboration). +/// Y-STR distance between two profiles: the count of marker values that do not agree, over the +/// markers both profiles hold. Returns (count that differ, count compared). A distance of 0 over +/// many shared markers agrees with a shared paternal line (identity corroboration). pub fn str_distance(a: &[StrMarker], b: &[StrMarker]) -> (i64, i64) { let mut differing = 0; let mut compared = 0; @@ -256,14 +258,14 @@ pub fn str_distance(a: &[StrMarker], b: &[StrMarker]) -> (i64, i64) { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarkerConflict { pub marker: String, - /// `(provider, value)` per provider, provider order as first seen. + /// `(provider, value)` for each provider, in the order the providers first appeared. pub by_provider: Vec<(String, String)>, } /// Cross-provider comparison of a subject's STR profiles (e.g. FTDNA vs YSEQ). #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct StrComparison { - /// Markers reported by ≥2 providers with disagreeing values. + /// Markers that ≥2 providers report with values that do not agree. pub conflicts: Vec, /// Markers reported by ≥2 providers that agree. pub agreement_count: usize, @@ -271,8 +273,9 @@ pub struct StrComparison { pub providers: Vec, } -/// Whether two STR values represent the same allele(s). Multi-copy values (`"16-15"`) are compared -/// order-independently (`"16-15"` ≡ `"15-16"`); everything else is a trimmed string match. +/// True when two STR values are the same allele or alleles. A multi-copy value (`"16-15"`) +/// compares without regard to order, so `"16-15"` ≡ `"15-16"`. Everything else is a trimmed string +/// match. pub fn values_match(a: &str, b: &str) -> bool { let norm = |s: &str| { let mut parts: Vec<&str> = s.split('-').map(|p| p.trim()).filter(|p| !p.is_empty()).collect(); @@ -282,12 +285,13 @@ pub fn values_match(a: &str, b: &str) -> bool { norm(a) == norm(b) } -/// Compare a subject's STR profiles across providers: flag markers where providers disagree, count -/// agreements, and list the providers. Mirrors the Scala `StrMarkerComparator.compare`. A profile's -/// provider defaults to `"UNKNOWN"` when unset; multiple profiles from the same provider collapse to -/// that provider's first-seen value for a marker. +/// Compare the STR profiles of a subject across providers. It flags a marker where the providers +/// disagree, counts the agreements, and lists the providers. Mirrors the Scala +/// `StrMarkerComparator.compare`. The provider of a profile defaults to `"UNKNOWN"` when nothing +/// sets it. More than one profile from the same provider collapses to the first value that provider +/// gave for a marker. pub fn compare_profiles(profiles: &[StrProfile]) -> StrComparison { - // Normalized marker -> ordered list of (provider, value), one entry per provider. + // Normalized marker -> ordered list of (provider, value), one entry for each provider. let mut by_marker: Vec<(String, Vec<(String, String)>)> = Vec::new(); let mut index: std::collections::HashMap = std::collections::HashMap::new(); let mut providers: Vec = Vec::new(); @@ -385,8 +389,8 @@ mod tests { #[test] fn parses_wide_ftdna_layout_with_quotes_and_padding() { - // FTDNA/YSEQ shape: a row of marker names + a parallel row of quoted, space-padded - // values; multi-copy markers stay dash-joined; empty cells are skipped. + // FTDNA and YSEQ shape: a row of marker names, and a parallel row of quoted, space-padded + // values. A multi-copy marker stays dash-joined, and this drops an empty cell. let csv = "DYS393,DYS390,DYS385,DYS459,DYS464\n\" 13\",\" 24\",\" 11-15\",\" \",\" 14-15-17-17\"\n"; let m = parse_csv(csv).unwrap(); assert_eq!(m.len(), 4); // DYS459 (blank) skipped @@ -415,7 +419,8 @@ mod tests { #[test] fn two_row_tall_file_is_not_mistaken_for_wide() { - // Two data rows, two columns each — still tall (the wide path needs ≥5 name columns). + // Two data rows, two columns each. This is still tall: the wide path needs ≥5 name + // columns. let csv = "DYS393,13\nDYS390,24\n"; let m = parse_csv(csv).unwrap(); assert_eq!(m.len(), 2); @@ -468,7 +473,7 @@ mod tests { // Disagreement: flagged conflict. assert!(by("DYS390").unwrap().conflict); - // Null ("-") is skipped; single-panel marker still appears. + // This drops a null ("-"). A marker from one panel only still appears. assert!(by("DYS385").is_none()); assert_eq!(by("DYS19").unwrap().panels, 1); } diff --git a/crates/navigator-domain/src/testtype.rs b/crates/navigator-domain/src/testtype.rs index c9a43a39..91c64d72 100644 --- a/crates/navigator-domain/src/testtype.rs +++ b/crates/navigator-domain/src/testtype.rs @@ -1,4 +1,4 @@ -//! The DNA-test catalog — the kinds of test a subject can have (a `SequenceRun.test_type` +//! The DNA-test catalog: the kinds of test a subject can have (a `SequenceRun.test_type` //! holds one of these codes). Ported from the Scala `test_types.conf` defaults: code, //! display name, and the genomic region the test targets (which downstream gates Y/mt/ //! autosomal analysis). Static here; can move to a config file later as the Scala app does. @@ -92,8 +92,9 @@ pub const CATALOG: &[TestType] = &[ display_name: "YSEQ Y Prime", target: YChromosome, }, - // Targeted tests recognized by coverage shape when the vendor can't be pinned down (see - // `navigator-analysis::testtype::infer_test_type`) — honest generics, not a guessed product. + // Targeted tests that coverage shape recognizes, when nothing can name the vendor (see + // `navigator-analysis::testtype::infer_test_type`). These are honest generics, and not a + // guessed product. TestType { code: "TARGETED_Y", display_name: "Targeted Y (vendor unknown)", @@ -176,12 +177,15 @@ pub fn by_code(code: &str) -> Option<&'static TestType> { CATALOG.iter().find(|t| t.code == code) } -/// Classify a stored `test_type` into its [`TargetType`] — tolerant of values that are not a -/// canonical [`by_code`] code. A bulk import or a `--test-type` override may store a human label -/// like `"Big Y"` rather than `BIG_Y_500`/`BIG_Y_700`; without recognizing it the targeted-Y -/// scoping is lost and coverage walks the whole genome (slow on a targeted multi-reference CRAM). -/// Matches, in order: exact code, exact display name, then a small set of well-known vendor labels. -/// Returns `None` when nothing matches (caller treats that as whole-genome/unknown). +/// Classify a stored `test_type` into its [`TargetType`]. It accepts values that are not a +/// canonical [`by_code`] code. A bulk import, or a `--test-type` override, can store a human label +/// like `"Big Y"` and not `BIG_Y_500` or `BIG_Y_700`. If this function does not recognize that +/// label, the targeted-Y scope is lost, and coverage walks the whole genome. That is slow on a +/// targeted multi-reference CRAM. +/// +/// Matches, in order: exact code, exact display name, then a small set of well-known vendor +/// labels. Returns `None` when nothing matches, and the caller treats that as whole-genome or +/// unknown. pub fn target_of(test_type: &str) -> Option { if let Some(t) = by_code(test_type) { return Some(t.target); @@ -218,7 +222,7 @@ pub fn target_of(test_type: &str) -> Option { } } -/// The display name for a code, falling back to the code itself if unknown. +/// The display name for a code. If the code is unknown, this returns the code itself. pub fn display_name(code: &str) -> &str { by_code(code).map(|t| t.display_name).unwrap_or(code) } diff --git a/crates/navigator-domain/src/variants.rs b/crates/navigator-domain/src/variants.rs index d254a782..0bff82cc 100644 --- a/crates/navigator-domain/src/variants.rs +++ b/crates/navigator-domain/src/variants.rs @@ -1,12 +1,12 @@ -//! A subject's SNP variant calls, imported from a VCF or a CSV/TSV table and grouped into -//! a named [`VariantSet`] (Scala's `DataType.Variants`). Types are pure; [`parse_csv`] turns a -//! marker table into calls with no IO. +//! The SNP variant calls of a subject, from a VCF or a CSV/TSV table, grouped into a named +//! [`VariantSet`] (Scala's `DataType.Variants`). The types are pure. [`parse_csv`] turns a marker +//! table into calls with no IO. //! -//! A call optionally carries the source's own [`CallEvidence`] — QUAL/FILTER/DP/GQ/AD. Early -//! imports dropped all of it, which left downstream analysis with nothing to gate on: a private-Y -//! engine over imported VCFs could not tell a 40× hom-alt call from a 2-read artefact. Sets record -//! which schema they were imported under ([`CALL_SCHEMA_EVIDENCE`]) so a consumer can require -//! evidence rather than silently treat "absent" as "unknown but fine". +//! A call can also carry the [`CallEvidence`] of the source: QUAL, FILTER, DP, GQ and AD. Early +//! imports dropped all of it, and that left downstream analysis with nothing to gate on. A +//! private-Y engine over imported VCFs could not tell a 40× hom-alt call from a 2-read artefact. +//! Each set records the schema of its import ([`CALL_SCHEMA_EVIDENCE`]). A consumer can then ask +//! for evidence, and does not read "absent" as "unknown but fine" with no warning. use du_domain::ids::SampleGuid; use serde::{Deserialize, Serialize}; @@ -16,35 +16,36 @@ pub const CALL_SCHEMA_BASIC: i64 = 1; /// Imports that also capture [`CallEvidence`] from the source VCF. pub const CALL_SCHEMA_EVIDENCE: i64 = 2; -/// Per-call evidence carried over from the source VCF. Every field is optional — a sites-only VCF -/// has no FORMAT column, and vendors vary in what they emit — so absence means "the source did not -/// say", never "zero". +/// Evidence for one call, carried over from the source VCF. Every field is optional, because a +/// sites-only VCF has no FORMAT column, and vendors differ in what they emit. So absence means +/// "the source did not say", and never "zero". #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CallEvidence { - /// VCF `QUAL` — Phred confidence that a variant exists here at all. + /// VCF `QUAL`: Phred confidence that a variant exists here at all. pub qual: Option, - /// VCF `FILTER`, when it is neither `.` nor `PASS` (a passing call carries `None`, so the - /// column stays empty for the overwhelming majority of rows). + /// VCF `FILTER`, when it is not `.` and not `PASS`. A call that passes carries `None`, so the + /// column stays empty for almost every row. pub filter: Option, - /// FORMAT `DP` — read depth at the site. + /// FORMAT `DP`: read depth at the site. pub dp: Option, - /// FORMAT `GQ` — Phred confidence in the genotype call. + /// FORMAT `GQ`: Phred confidence in the genotype call. pub gq: Option, /// FORMAT `AD` for the reference allele. pub ad_ref: Option, - /// FORMAT `AD` for the *called* alternate allele (the one `genotype` selected, not simply the - /// first ALT — on a multi-allelic row those differ). + /// FORMAT `AD` for the *called* alternate allele. That is the one `genotype` selected, and + /// not the first ALT. On a multi-allelic row the two differ. pub ad_alt: Option, } impl CallEvidence { - /// True when nothing was captured — used to store `NULL`s rather than a row of empties. + /// True when the import captured nothing. The store then writes `NULL` values, and not a row + /// of empties. pub fn is_empty(&self) -> bool { *self == Self::default() } - /// Fraction of reads supporting the called alternate, when both AD values are present. - /// `None` rather than a guess when the source gave no allele depths. + /// Fraction of the reads that carry the called alternate, when both AD values are there. + /// `None`, and not a guess, when the source gave no allele depths. pub fn allele_fraction(&self) -> Option { let (r, a) = (self.ad_ref?, self.ad_alt?); let total = r + a; @@ -75,9 +76,9 @@ pub struct VariantCall { pub evidence: CallEvidence, } -/// The kind of source a variant set came from — carries the SNP-concordance weight used -/// when reconciling across sources (Scala `YProfileSourceType`). Sanger is the gold -/// standard (1.0); a low-confidence manual entry is 0.3. +/// The kind of source a variant set came from. It carries the SNP-concordance weight that +/// reconciliation over sources uses (Scala `YProfileSourceType`). Sanger is the reference standard +/// (1.0), and a low-confidence manual entry is 0.3. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SourceType { Sanger, @@ -148,20 +149,22 @@ pub struct VariantSet { /// A label for the source (typically the file name). pub source_label: String, pub source_type: SourceType, - /// Reference build the call positions are on (`"hs1"`, `"GRCh38"`, …), when known. `None` - /// for sources of unknown build (a generic VCF/CSV import). Lets build-specific consumers - /// (e.g. Y-SNP-panel placement) read the build directly instead of re-deriving it. + /// Reference build of the call positions (`"hs1"`, `"GRCh38"`, …), when the source names one. + /// `None` for a source of unknown build (a generic VCF or CSV import). A consumer that needs the build + /// (for example Y-SNP-panel placement) can read it here, and does not have to derive it + /// again. pub reference_build: Option, pub calls: Vec, - /// Which call schema this set was stored under — [`CALL_SCHEMA_BASIC`] or - /// [`CALL_SCHEMA_EVIDENCE`]. Derived from what was captured, not from the importer version, so - /// it never promises evidence the source did not supply. Check it before applying a quality gate: - /// a `BASIC` set can't satisfy one, and treating its absent DP/GQ as zero would silently reject - /// every call. + /// The call schema of this set: [`CALL_SCHEMA_BASIC`] or [`CALL_SCHEMA_EVIDENCE`]. It comes + /// from what the import captured, and not from the importer version, so it never promises + /// evidence the source did not give. Check it before a quality gate. A `BASIC` set can not + /// satisfy one. A gate that reads its absent DP and GQ as zero would reject every call, with + /// no message. pub call_schema: i64, - /// Where this set was imported from, when it came from a file. Kept so the source can be - /// **re-read** to genotype at tree positions — the role `alignment.bam_path` plays for the - /// BAM/CRAM path. `None` for hand entry and for sets imported before this was recorded. + /// Where this set came from, when a file was the source. It stays so that the code can + /// **read the source again** and genotype at tree positions. That is the role + /// `alignment.bam_path` has for the BAM and CRAM path. `None` for hand entry, and for a set + /// that an import made before this field existed. pub source_path: Option, } @@ -172,7 +175,7 @@ impl VariantSet { } } -/// Fields for creating a variant set (the store assigns the id). +/// Fields to make a variant set (the store assigns the id). #[derive(Debug, Clone, PartialEq)] pub struct NewVariantSet { pub biosample_guid: SampleGuid, @@ -185,12 +188,12 @@ pub struct NewVariantSet { pub source_path: Option, } -/// True for a one-base A/C/G/T allele (case-insensitive) — used to keep SNP rows only. +/// True for a one-base A/C/G/T allele (case-insensitive). It keeps SNP rows only. fn is_snp_allele(a: &str) -> bool { a.len() == 1 && matches!(a.as_bytes()[0].to_ascii_uppercase(), b'A' | b'C' | b'G' | b'T') } -/// Build a SNP `VariantCall`, returning `None` for indels/symbolic alleles. +/// Build a SNP `VariantCall`. Returns `None` for an indel or a symbolic allele. pub fn snp_call( contig: &str, position: i64, @@ -210,9 +213,9 @@ pub fn snp_call( ) } -/// [`snp_call`] carrying the source's [`CallEvidence`]. Separate rather than a seventh parameter on -/// `snp_call` because most call sites (CSV tables, chip exports, hand entry) have no evidence to -/// give and should not have to say so. +/// [`snp_call`] with the [`CallEvidence`] of the source. This is separate, and not a seventh +/// parameter on `snp_call`. Most call sites (CSV tables, chip exports, hand entry) have no evidence +/// to give, and must not have to say so. pub fn snp_call_with_evidence( contig: &str, position: i64, @@ -279,10 +282,11 @@ impl Layout { } } -/// Parse a CSV/TSV variant table into SNP calls. The first non-comment row is treated as a -/// header when it names known columns (contig/pos/ref/alt[/rsid/genotype], any order), -/// otherwise columns are read positionally as contig,position,ref,alt[,rsid][,genotype]. -/// Non-SNP rows and rows with an unparseable position are skipped. Errors if none parse. +/// Parse a CSV or TSV variant table into SNP calls. The first row that is not a comment is the +/// header when it names known columns (contig/pos/ref/alt[/rsid/genotype], in any order). If it +/// does not, this reads the columns by position, as contig,position,ref,alt[,rsid][,genotype]. It +/// drops a row that is not a SNP, and a row whose position does not parse. Errors if no row +/// parses. pub fn parse_csv(text: &str) -> Result, String> { let mut rows = text .lines() @@ -298,7 +302,7 @@ pub fn parse_csv(text: &str) -> Result, String> { let first_cols: Vec<&str> = first.split(sep).map(str::trim).collect(); let layout = Layout::from_header(&first_cols); let mut calls = Vec::new(); - // If the first row was not a header, it is data — parse it positionally too. + // If the first row was not a header, it is data, so parse it by position too. let header_layout = match layout { Some(l) => l, None => { @@ -351,7 +355,7 @@ mod tests { alternate: "G".into(), rs_id: Some("rs1".into()), genotype: None, - // A CSV marker table carries no per-call evidence. + // A CSV marker table carries no evidence for a call. evidence: CallEvidence::default(), } ); diff --git a/crates/navigator-domain/src/vendorvcf.rs b/crates/navigator-domain/src/vendorvcf.rs index 2bf15869..d60abff0 100644 --- a/crates/navigator-domain/src/vendorvcf.rs +++ b/crates/navigator-domain/src/vendorvcf.rs @@ -1,11 +1,11 @@ -//! Vendor-VCF classification — recognize FTDNA Big Y, Full Genomes Y Elite, YSEQ, etc. from a -//! `.vcf` so the import can tag it (vendor label + a meaningful `SourceType`) instead of treating -//! every VCF as a generic `IMPORTED` set. +//! Vendor-VCF classification: recognize FTDNA Big Y, Full Genomes Y Elite, YSEQ, and others from +//! a `.vcf`. The import can then tag it with a vendor label and a real `SourceType`, instead of a +//! generic `IMPORTED` set for every VCF. //! -//! Signals (from real exports): the `##source` meta line — FTDNA Big Y stamps `##source=aengine` -//! (its Arpeggi caller) — plus the contig set (chrY-only ⇒ Y-targeted, chrM-only ⇒ mtDNA), the file -//! name, and the sibling `readme.txt` FTDNA ships ("…BigY raw data…"). Mirrors the Scala -//! `VcfCache.VcfVendor`. +//! The signals come from real exports. The first is the `##source` meta line: FTDNA Big Y stamps +//! `##source=aengine`, which is its Arpeggi caller. The others are the contig set (chrY-only ⇒ +//! Y-targeted, chrM-only ⇒ mtDNA), the file name, and the sibling `readme.txt` that FTDNA ships +//! ("…BigY raw data…"). This mirrors the Scala `VcfCache.VcfVendor`. use crate::variants::SourceType; @@ -35,8 +35,9 @@ impl VendorVcf { } } - /// Concordance weighting for the calls: vendor-grade targeted Y/mt sequencing is `TargetedNgs`; - /// consumer WGS vendors are short-read WGS; an unrecognized VCF stays generic `Imported`. + /// The concordance weight for the calls. Vendor-grade targeted Y or mt sequencing is + /// `TargetedNgs`. A consumer WGS vendor is short-read WGS. An unrecognized VCF stays generic + /// `Imported`. pub fn source_type(self) -> SourceType { match self { VendorVcf::FtdnaBigY | VendorVcf::FtdnaMtFull | VendorVcf::Yseq | VendorVcf::FullGenomes => { @@ -52,8 +53,9 @@ impl VendorVcf { } } -/// Classify a VCF from its header `meta` (the `##` lines, lower-casing handled here), the set of -/// contig names it declares, its `filename`, and an optional sibling `readme` text. +/// Classify a VCF from its header `meta` (the `##` lines, which this function puts into lower +/// case). The other inputs are the set of contig names it declares, its `filename`, and an +/// optional sibling `readme` text. pub fn classify(meta: &str, contigs: &[String], filename: &str, readme: Option<&str>) -> VendorVcf { let hay = format!("{} {} {}", meta, filename, readme.unwrap_or("")).to_lowercase(); let only = |pred: fn(&str) -> bool| !contigs.is_empty() && contigs.iter().all(|c| pred(c)); diff --git a/crates/navigator-domain/src/workspace.rs b/crates/navigator-domain/src/workspace.rs index 0caa1bb4..5411a86c 100644 --- a/crates/navigator-domain/src/workspace.rs +++ b/crates/navigator-domain/src/workspace.rs @@ -1,15 +1,15 @@ -//! The desktop workspace aggregate: Project → Biosample → SequenceRun → Alignment, -//! plus analysis artifacts. A reference-linked graph (the legacy Scala model used -//! string `atUri`/ref fields); here links are typed foreign keys. +//! The desktop workspace aggregate: Project → Biosample → SequenceRun → Alignment, plus analysis +//! artifacts. It is a graph of links. The legacy Scala model used string `atUri` and ref fields; +//! here a link is a typed foreign key. //! -//! Read metrics live as flat fields (not a 22-tuple JSONB blob), per plan §3. Each -//! entity has a `New*` form without the DB-assigned id for inserts. +//! Read metrics live as flat fields, and not as a 22-tuple JSONB blob, as plan §3 says. Each entity +//! has a `New*` form for inserts, without the id that the DB assigns. use chrono::{DateTime, Utc}; use du_domain::ids::SampleGuid; use serde::{Deserialize, Serialize}; -/// A research project grouping samples. +/// A research project that groups samples. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Project { pub id: i64, @@ -38,13 +38,14 @@ pub struct Biosample { } impl Biosample { - /// A biosample with only its identity set — everything descriptive left unpopulated. + /// A biosample with only its identity set, and every descriptive field empty. /// /// This and its siblings ([`SequenceRun::new`], [`NewSequenceRun::new`], [`Alignment::new`], - /// [`NewAlignment::new`]) take exactly the fields that have no sensible empty value. They exist - /// so the callers that genuinely only know the identity — fixtures, and imports that fill the - /// rest in later — stop restating a column of `None`s. Callers that do know more should say so - /// with functional-update syntax: `Biosample { sex: Some("M".into()), ..Biosample::new(g, id) }`. + /// [`NewAlignment::new`]) take exactly the fields that have no sensible empty value. Some + /// callers truly know only the identity: fixtures, and imports that fill the rest in later. + /// These constructors stop those callers from a column of `None` values. A caller that does + /// know more must say so with functional-update syntax: + /// `Biosample { sex: Some("M".into()), ..Biosample::new(g, id) }`. pub fn new(guid: SampleGuid, donor_identifier: impl Into) -> Self { Biosample { guid, @@ -60,11 +61,12 @@ impl Biosample { /// A sequencing run for a biosample, with summary read metrics as flat fields. /// -/// The lab/instrument identity block (`instrument_id`/`sample_name`/`library_id`/`platform_unit`/ -/// `flowcell_id`) is inferred from the alignment at import (read-name scan + `@RG` tags) and is the -/// crowd-source input for resolving the sequencing facility. `sequencing_facility` is the lab -/// (FGC/FTDNA/YSEQ/Dante/Nebula…) — set manually for now, resolved from `instrument_id` once the -/// AppView lookup endpoint ships (roadmap D8). All `None` until populated. +/// The lab and instrument identity block is `instrument_id`, `sample_name`, `library_id`, +/// `platform_unit` and `flowcell_id`. The import infers it from the alignment, with a read-name +/// scan and the `@RG` tags. It is the crowd-source input that resolves the sequencing facility. +/// `sequencing_facility` is the lab (FGC/FTDNA/YSEQ/Dante/Nebula…). A person sets it by hand for +/// now, and it will come from `instrument_id` when the AppView lookup endpoint ships (roadmap D8). +/// All of these are `None` until something fills them. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SequenceRun { pub id: i64, @@ -77,29 +79,30 @@ pub struct SequenceRun { pub pf_reads_aligned: Option, pub mean_read_length: Option, pub mean_insert_size: Option, - /// Exact total sequenced yield in base pairs (Σ read_length_histogram) — the "Gbases" figure of - /// the standardized test label. Populated post-analysis; `None` until a read-metrics pass runs. + /// Exact total sequenced yield in base pairs (Σ read_length_histogram). This is the "Gbases" + /// figure of the standardized test label. It is `None` until a read-metrics pass runs. pub total_bases: Option, - /// Read chemistry/mode inferred at import (`SHORT`/`HIFI`/`CLR`/`ONT_SIMPLEX`/`ONT_DUPLEX`) — the - /// long-read arm of the standardized test label. `None` until a library-stats scan runs. + /// Read chemistry or mode that the import inferred + /// (`SHORT`/`HIFI`/`CLR`/`ONT_SIMPLEX`/`ONT_DUPLEX`). This is the long-read arm of the + /// standardized test label. `None` until a library-stats scan runs. pub read_type: Option, /// The sequencing laboratory (a [`crate::labs`] display name), e.g. "YSEQ", "Dante Labs". pub sequencing_facility: Option, /// Most-frequent instrument serial from the read names / `@RG` (e.g. `A00123`, `m84…`). pub instrument_id: Option, - /// `@RG SM` — sample name as tagged in the alignment (may differ from the biosample). + /// `@RG SM`: the sample name in the alignment tags (it can differ from the biosample). pub sample_name: Option, - /// `@RG LB` — library id (stable across re-alignments). + /// `@RG LB`: library id (stable across realignments). pub library_id: Option, - /// `@RG PU` — platform unit (flowcell.lane.barcode). + /// `@RG PU`: platform unit (flowcell.lane.barcode). pub platform_unit: Option, /// Most-frequent flowcell id from the read names. pub flowcell_id: Option, } impl SequenceRun { - /// A run with only the fields the database requires — the whole metrics and lab-identity block - /// left `None`, which is exactly its state until an analysis pass fills it in. See + /// A run with only the fields the database needs. The whole metrics block and lab-identity + /// block stay `None`, which is exactly their state until an analysis pass fills them. See /// [`Biosample::new`] for why these constructors exist. pub fn new( id: i64, @@ -129,9 +132,9 @@ impl SequenceRun { } } - /// The standardized, vendor-neutral test label (`WGS150 45Gbases`, `HiFi 90Gbases`, `BigY-700`), - /// or `None` when this is not a yield/product test we standardize (chips, panels) — the caller - /// falls back to the raw `test_type`. See [`du_domain::testprofile`]. + /// The standardized, vendor-neutral test label (`WGS150 45Gbases`, `HiFi 90Gbases`, + /// `BigY-700`). `None` when this is not a yield or product test we standardize (chips, panels), + /// and the caller then falls back to the raw `test_type`. See [`du_domain::testprofile`]. pub fn standardized_label(&self) -> Option { du_domain::testprofile::standardized_label(&du_domain::testprofile::RunProfile { test_type: Some(self.test_type.as_str()), @@ -184,17 +187,18 @@ pub struct Alignment { pub variant_caller: Option, pub bam_path: Option, pub reference_path: Option, - /// SHA-256 of the alignment file's content (hex), computed at import (lazily on first - /// analysis for batch-imported files). The file's content identity — used to invalidate - /// cached analyses only when the file actually changes. `None` until computed. + /// SHA-256 of the content of the alignment file (hex). The import computes it, or the first + /// analysis does for a file that came from a batch import. It is the content identity of the + /// file, and it invalidates a cached analysis only when the file itself changes. `None` until + /// something computes it. pub content_sha256: Option, - /// The alignment this one was produced from, for a row Navigator derived rather than imported. - /// `None` means this is an original — a vendor's alignment, or anything imported directly. + /// The alignment this one came from, for a row Navigator derived and did not import. `None` + /// means this is an original: the alignment of a vendor, or anything a direct import made. /// - /// Set by realignment, which re-maps a vendor alignment's reads to another reference and - /// registers the result under the same `sequence_run_id`: the same physical library, mapped - /// differently. Without this a subject with both builds present has two alignments and no way - /// to tell which came from which. + /// Realignment sets it. Realignment maps the reads of a vendor alignment to another reference, + /// and registers the result under the same `sequence_run_id`. That is the same physical + /// library, mapped a different way. Without this field, a subject that has both builds has two + /// alignments, and no way to tell which came from which. pub derived_from_alignment_id: Option, /// How it was derived, as `realign:-` (e.g. `realign:minimap2-sr`). `None` /// alongside a `None` parent. [`Alignment::aligner`] still carries the mapper alone. @@ -202,9 +206,8 @@ pub struct Alignment { } impl Alignment { - /// An alignment with only the fields the database requires — no file paths, no caller, and no - /// derivation, i.e. an original rather than something Navigator produced. See - /// [`Biosample::new`]. + /// An alignment with only the fields the database needs: no file paths, no caller, and no + /// derivation. That is an original, and not something Navigator made. See [`Biosample::new`]. pub fn new(id: i64, sequence_run_id: i64, reference_build: impl Into, aligner: impl Into) -> Self { Alignment { id, @@ -220,11 +223,11 @@ impl Alignment { } } - /// Whether Navigator produced this alignment from another one, rather than importing it. + /// True when Navigator made this alignment from another one, and did not import it. /// - /// The distinction is user-facing: a derived alignment can be deleted and rebuilt from its - /// source, and the UI has to say where it came from rather than presenting it as something the - /// vendor supplied. + /// The user sees this distinction. A derived alignment can go, and the code can build it again + /// from its source. The UI must also say where it came from, and must not show it as something + /// the vendor supplied. pub fn is_derived(&self) -> bool { self.derived_from_alignment_id.is_some() } @@ -240,7 +243,7 @@ pub struct NewAlignment { pub reference_path: Option, /// Content SHA-256 if already known at creation (else `None`; filled in lazily). pub content_sha256: Option, - /// The alignment this was derived from; see [`Alignment::derived_from_alignment_id`]. + /// The alignment this one came from; see [`Alignment::derived_from_alignment_id`]. pub derived_from_alignment_id: Option, /// How it was derived; see [`Alignment::derivation`]. pub derivation: Option, @@ -263,9 +266,9 @@ impl NewAlignment { } } -/// A persisted analysis result, keyed by `(alignment, kind, algorithm_version)`. The -/// version is part of the key so a cache entry is invalidated when the algorithm -/// changes (plan §6 cache-versioning fix). `payload` is JSON of the result type. +/// A persisted analysis result, with the key `(alignment, kind, algorithm_version)`. The version +/// is part of the key, so a change of the algorithm invalidates a cache entry (plan §6, the cache +/// version fix). `payload` is JSON of the result type. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AnalysisArtifact { pub id: i64, @@ -274,14 +277,14 @@ pub struct AnalysisArtifact { pub algorithm_version: String, pub created_at: DateTime, pub payload: String, - /// How this result was produced: `navigator-walk` (CRAM walk) or `pipeline-sidecar` - /// (fast-path ingest). `None` for pre-provenance rows → treated as `navigator-walk`. + /// What made this result: `navigator-walk` (CRAM walk) or `pipeline-sidecar` (fast-path + /// ingest). `None` for a row from before this field → the code reads it as `navigator-walk`. pub source: Option, /// `full` or `partial` (e.g. lite coverage from sidecars, upgradeable by the deep pass). /// `None` → treated as `full`. pub completeness: Option, - /// The source file's signature (`mtime:size`) when this artifact was computed, for staleness - /// checks — a changed BAM/CRAM invalidates it. `None` for pre-feature rows / non-file sources - /// (treated as fresh). + /// The signature (`mtime:size`) of the source file when the code computed this artifact, for + /// a staleness check. A BAM or CRAM that changed invalidates it. `None` for a row from before + /// this field, and for a source that is not a file. The code reads those as fresh. pub source_sig: Option, } diff --git a/crates/navigator-domain/src/ymatch.rs b/crates/navigator-domain/src/ymatch.rs index d0abd756..20791461 100644 --- a/crates/navigator-domain/src/ymatch.rs +++ b/crates/navigator-domain/src/ymatch.rs @@ -1,13 +1,15 @@ -//! Cross-subject Y-chromosome matching — the *between-subjects* layer on top of the single-subject -//! Y profile. Given one subject, rank every other by Y relatedness (the FTDNA "Big Y match list" -//! idea): shared derived SNPs, shared private/novel variants, the divergence haplogroup, Y-STR -//! genetic distance, and rough SNP- and STR-based TMRCA estimates. +//! Cross-subject Y-chromosome matching: the *between-subjects* layer on top of the single-subject +//! Y profile. From one subject, it ranks every other subject by Y relatedness. This is the FTDNA +//! "Big Y match list" idea. Four kinds of evidence count: derived SNPs in common, private or novel +//! variants in common, the divergence haplogroup, and Y-STR genetic distance. It also gives rough +//! TMRCA estimates from SNPs and from STRs. //! -//! This module is pure (no I/O): the app assembles a [`YMatchProfile`] per subject from cached data -//! — the consensus Y-variant set, the placement-tree lineage, and the imported STR markers — and -//! calls [`rank`]. SNP comparison is keyed by **variant name** (build-independent), matching the -//! consensus engine ([`crate::consensus`]); STR distance reuses [`crate::strprofile::values_match`] -//! so multi-copy markers compare order-independently. +//! This module is pure, with no I/O. The app assembles a [`YMatchProfile`] for each subject from +//! cached data: the consensus Y-variant set, the placement-tree lineage, and the imported STR +//! markers. It then calls [`rank`]. The **variant name** is the key for SNP comparison, which is +//! independent of the build, and matches the consensus engine ([`crate::consensus`]). STR distance +//! reuses [`crate::strprofile::values_match`], so multi-copy markers compare without regard to +//! order. use std::collections::HashSet; @@ -16,13 +18,14 @@ use serde::{Deserialize, Serialize}; use crate::strprofile::{values_match, StrMarker}; use du_domain::ids::SampleGuid; -/// Big-Y-700 convention: ~1 SNP accumulates per this many years on the callable region (FTDNA cites -/// an average ≈ 83 yr/SNP). Used only for the **rough** SNP TMRCA — wide confidence interval. +/// Big-Y-700 convention: about 1 SNP appears in this many years on the callable region (FTDNA +/// cites an average ≈ 83 yr/SNP). Used only for the **rough** SNP TMRCA, which has a wide +/// confidence interval. pub const YEARS_PER_SNP: f64 = 83.0; -/// Years per generation, for converting a year estimate to generations. +/// Years in one generation, to change a year estimate into generations. pub const YEARS_PER_GEN: f64 = 32.0; -/// Average per-marker, per-generation Y-STR mutation rate (FTDNA-panel order of magnitude). Used only -/// for the **rough** STR TMRCA — wide confidence interval. +/// Average Y-STR mutation rate for one marker in one generation (FTDNA-panel order of magnitude). +/// Used only for the **rough** STR TMRCA, which has a wide confidence interval. pub const MU_PER_MARKER_GEN: f64 = 0.0025; /// Which evidence backed a pairwise comparison. @@ -39,7 +42,7 @@ pub enum YSignal { } impl YSignal { - /// Ranking tier — SNP-backed first, then STR-only, then nothing. + /// Rank tier: SNP-backed first, then STR-only, then nothing. fn tier(self) -> u8 { match self { YSignal::SnpStr | YSignal::Snp => 0, @@ -56,7 +59,8 @@ pub struct Tmrca { pub years: f64, } -/// A lightweight per-subject snapshot, assembled by the app from cached data and fed to [`compare_y`]. +/// A lightweight snapshot of one subject. The app assembles it from cached data for +/// [`compare_y`]. #[derive(Debug, Clone)] pub struct YMatchProfile { pub guid: SampleGuid, @@ -74,8 +78,8 @@ pub struct YMatchProfile { } impl YMatchProfile { - /// Whether the subject has Y-SNP calls to compare (independent of the tree/lineage being present — - /// lineage only adds the divergence haplogroup). + /// True when the subject has Y-SNP calls to compare. This does not depend on a tree or a + /// lineage, which only add the divergence haplogroup. fn has_snp(&self) -> bool { !self.derived.is_empty() || !self.novel.is_empty() } @@ -91,7 +95,7 @@ pub struct YMatch { pub shared_derived: usize, /// Count of **private/novel** SNPs both carry (shared off-tree variants = candidate sub-branch). pub shared_novel: usize, - /// The deepest haplogroup the two lineages share (their LCA), if both are placed. + /// The deepest haplogroup the two lineages share (their LCA), when placement reached both. pub divergence: Option, /// Y-STR genetic distance over markers present in both (None when not comparable). pub str_gd: Option, @@ -104,8 +108,9 @@ pub struct YMatch { pub signal: YSignal, } -/// The deepest haplogroup two lineages share — the longest common prefix of the two root→terminal -/// paths — and its depth (number of shared steps). Returns `(None, 0)` if either lineage is empty. +/// The deepest haplogroup two lineages share, and its depth in shared steps. That haplogroup is +/// the longest common prefix of the two root→terminal paths. Returns `(None, 0)` if either lineage +/// is empty. fn divergence(a: &[String], b: &[String]) -> (Option, usize) { let mut last = None; let mut depth = 0; @@ -136,9 +141,9 @@ fn str_gd(a: &[StrMarker], b: &[StrMarker]) -> (i64, i64) { (differing, compared) } -/// Rough SNP TMRCA: each lineage accumulates its private (non-shared) variants since divergence at -/// ~[`YEARS_PER_SNP`]; TMRCA years ≈ average private count × yr/SNP. Approximate — depends on equal -/// callable coverage between the two subjects. +/// Rough SNP TMRCA. Each lineage collects its own private variants after the divergence, at about +/// one in [`YEARS_PER_SNP`]. TMRCA years ≈ average private count × yr/SNP. This is approximate, and +/// it depends on equal callable coverage between the two subjects. fn snp_tmrca(private_a: usize, private_b: usize) -> Tmrca { let years = ((private_a + private_b) as f64 / 2.0) * YEARS_PER_SNP; Tmrca { @@ -147,9 +152,9 @@ fn snp_tmrca(private_a: usize, private_b: usize) -> Tmrca { } } -/// Rough STR TMRCA via a stepwise model: expected differences over two lineages ≈ 2·markers·μ·g, so -/// generations to MRCA ≈ gd / (2·markers·μ). Approximate — single average mutation rate, no per-marker -/// rates or TiP-grade modelling. +/// Rough STR TMRCA from a stepwise model: expected differences over two lineages ≈ 2·markers·μ·g, +/// so generations to MRCA ≈ gd / (2·markers·μ). This is approximate. It uses one average mutation +/// rate, with no rate for each marker, and no TiP-grade model. fn str_tmrca(gd: i64, markers: i64) -> Option { if markers <= 0 { return None; @@ -211,9 +216,10 @@ pub fn compare_y(query: &YMatchProfile, cand: &YMatchProfile) -> YMatch { } } -/// Rank candidates against the query, best match first. SNP-primary: SNP-backed matches first (more -/// shared derived SNPs, then deeper divergence), then STR-only by ascending genetic distance. -/// Candidates with no comparable evidence (`YSignal::None`) are dropped. The query itself is skipped. +/// Rank candidates against the query, best match first. SNP-primary: a SNP-backed match comes +/// first (more shared derived SNPs, then a deeper divergence), then an STR-only match, by genetic +/// distance from small to large. This drops a candidate with no comparable evidence +/// (`YSignal::None`), and it steps over the query itself. pub fn rank(query: &YMatchProfile, candidates: &[YMatchProfile]) -> Vec { let mut out: Vec = candidates .iter() @@ -244,7 +250,8 @@ mod tests { } } - /// Deterministic distinct guid per donor name (so self-skip works without a real UUID source). + /// A deterministic distinct guid for each donor name, so self-skip works with no real UUID + /// source. fn guid_for(donor: &str) -> SampleGuid { let mut h: u128 = 0xcbf2_9ce4_8422_2325; for b in donor.bytes() { @@ -343,7 +350,7 @@ mod tests { ); // Distant SNP match (shares only the backbone). let distant = prof("Distant", &["R", "R-M269"], &["M269"], &[], &[("DYS393", "14")]); - // STR-only (no lineage) — must rank below any SNP-backed match. + // STR-only (no lineage). It must rank below any SNP-backed match. let stronly = prof("StrOnly", &[], &[], &[], &[("DYS393", "13")]); let ranked = rank(&q, &[distant.clone(), stronly.clone(), close.clone()]); assert_eq!(ranked.len(), 3); @@ -356,7 +363,7 @@ mod tests { #[test] fn no_common_evidence_is_dropped_and_self_skipped() { let q = prof("Q", &["R", "R-M269"], &["M269"], &[], &[("DYS393", "13")]); - // No lineage and no overlapping STR markers → nothing comparable. + // No lineage, and no STR markers in common → nothing comparable. let nothing = prof("Nothing", &[], &[], &[], &[("DYS999", "10")]); let ranked = rank(&q, &[q.clone(), nothing]); assert!(ranked.is_empty()); diff --git a/crates/navigator-domain/src/yprofile.rs b/crates/navigator-domain/src/yprofile.rs index 592afd9e..75ee16e3 100644 --- a/crates/navigator-domain/src/yprofile.rs +++ b/crates/navigator-domain/src/yprofile.rs @@ -1,14 +1,18 @@ -//! Y-variant profile — the **Y-DNA adapter** over the generic [`crate::consensus`] engine. +//! Y-variant profile: the **Y-DNA adapter** over the generic [`crate::consensus`] engine. //! -//! The reconciliation machinery (quality-weighted voting, status taxonomy, summary) is DNA-type -//! agnostic and lives in [`crate::consensus`]; this module is the Y-DNA view of it. The app gathers -//! each Y-bearing source's per-SNP calls (a WGS alignment's haplogroup placement, the chip/BISDNA -//! placement, the private-Y bucket), groups them **by SNP name** (build-independent — M269 is M269 -//! whether the source aligned to GRCh37 or GRCh38) via [`reconcile_y`], and classifies each SNP as -//! confirmed / novel / conflict / single-source. The mtDNA (variants vs rCRS) and autosomal consumers -//! reuse the same engine through their own thin adapters. +//! The reconciliation code is not specific to one DNA type. It holds a vote weighted by quality, a +//! status taxonomy, and a summary, and it lives in [`crate::consensus`]. This module is the Y-DNA +//! view of it. //! -//! The Y-flavored aliases below keep call sites read as Y-specific while sharing one implementation. +//! The app collects the calls at each SNP from every source that carries Y. Those sources are the +//! haplogroup placement of a WGS alignment, the chip or BISDNA placement, and the private-Y +//! bucket. It then groups them **by SNP name** through [`reconcile_y`]. The name is independent of +//! the build, because M269 is M269 whether the source aligned to GRCh37 or to GRCh38. It +//! classifies each SNP as confirmed, novel, conflict, or single-source. +//! +//! The mtDNA consumer (variants vs rCRS) and the autosomal consumer reuse the same engine through +//! their own thin adapters. The Y-flavored aliases below keep call sites Y-specific, and there is +//! still one implementation. pub use crate::consensus::{ interpret, obs_weight, reconcile as reconcile_y, summarize, to_observed, CallableState as YCallableState, diff --git a/crates/navigator-domain/src/ysnp_dict.rs b/crates/navigator-domain/src/ysnp_dict.rs index 58ce4044..c7087257 100644 --- a/crates/navigator-domain/src/ysnp_dict.rs +++ b/crates/navigator-domain/src/ysnp_dict.rs @@ -1,14 +1,14 @@ -//! The Y-SNP name → locus dictionary that gives a BISDNA (or any name-only Y panel) export -//! its missing coordinates. A SNP name like `CTS10003` resolves to a position plus its -//! ancestral/derived alleles — **per reference build**, so the codebase stays build-agnostic: -//! `coordinates` is keyed by build label (`"GRCh38"`, `"GRCh37"`, `"hs1"`, …), exactly the -//! convention the DecodingUs Y-tree uses. The importer is handed the build it is placing -//! against and reads that coordinate; nothing here is CHM13-specific. +//! The Y-SNP name → locus dictionary that gives a BISDNA export, or any name-only Y panel, its +//! missing coordinates. A SNP name like `CTS10003` resolves to a position, plus its ancestral and +//! derived alleles, **for each reference build**. The codebase then stays build-agnostic. The +//! build label is the key of `coordinates` (`"GRCh38"`, `"GRCh37"`, `"hs1"`, …), which is exactly +//! the convention the DecodingUs Y-tree uses. The caller gives the importer the build it places +//! against, and the importer reads that coordinate. Nothing here is specific to CHM13. //! -//! The bulk data is a generated asset (built from YBrowse + liftover by -//! `scripts/ysnp-dictionary/`); a small checked-in chromo2 panel manifest uses the same -//! format. This module is pure over already-loaded text — [`YsnpDictionary::from_text`] — with -//! a thin [`YsnpDictionary::load`] IO boundary that reads the asset files. See +//! The bulk data is a generated asset, which `scripts/ysnp-dictionary/` builds from YBrowse and a +//! liftover. A small checked-in chromo2 panel manifest uses the same format. This module is pure +//! over text that is already in memory ([`YsnpDictionary::from_text`]), with a thin +//! [`YsnpDictionary::load`] IO boundary that reads the asset files. See //! `documents/design/bisdna-import.md`. use std::collections::HashMap; @@ -16,9 +16,9 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -/// One SNP's locus on a specific reference build. Alleles are on that build's + strand, so a -/// strand-flipping liftover stores its own (complemented) alleles — they are per-coordinate, -/// not per-SNP. +/// The locus of one SNP on a specific reference build. The alleles are on the + strand of that +/// build, so a liftover that flips the strand stores its own complemented alleles. The alleles +/// belong to a coordinate, and not to a SNP. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Coord { pub chrom: String, @@ -45,8 +45,8 @@ pub struct ResolvedSnp<'a> { pub coord: &'a Coord, } -/// The loaded dictionary: canonical entries plus an alias → canonical index. Lookups are -/// case-insensitive on the SNP name (build keys are matched verbatim). +/// The loaded dictionary: canonical entries, plus an alias → canonical index. A lookup ignores +/// the case of the SNP name, and matches a build key verbatim. #[derive(Debug, Clone, Default)] pub struct YsnpDictionary { /// lowercased canonical name → entry. @@ -67,7 +67,7 @@ pub fn asset_dir() -> PathBuf { crate::paths::decodingus_dir().join("ysnp") } -/// Split a TSV line into trimmed cells, ignoring a trailing empty cell from a final tab. +/// Split a TSV line into trimmed cells, and drop an empty last cell that a final tab makes. fn cells(line: &str) -> Vec<&str> { line.split('\t').map(str::trim).collect() } @@ -79,13 +79,16 @@ fn is_skippable(line: &str) -> bool { } impl YsnpDictionary { - /// Build from the two asset texts (no IO). `dictionary` rows are - /// `namebuildchrompositionstrandancestralderived`; `aliases` - /// (optional, may be empty) rows are `aliascanonical`. A leading header row whose - /// first cell is `name`/`alias` is ignored; `#` comments and blanks are skipped. Rows with - /// an unparseable position are dropped. The first coordinate seen for a (name, build) wins - /// — later duplicates are ignored (deterministic over a sorted asset). Errors only if no - /// usable entries result. + /// Build from the two asset texts (no IO). + /// + /// A `dictionary` row is + /// `namebuildchrompositionstrandancestralderived`. An `aliases` + /// row is `aliascanonical`, and that file is optional and can be empty. + /// + /// This drops a first header row whose first cell is `name` or `alias`. It also drops a `#` + /// comment, a blank line, and a row whose position does not parse. For one (name, build), the + /// first coordinate wins, and later duplicates go. That is deterministic over a sorted asset. + /// Errors only if no usable entry comes out. pub fn from_text(dictionary: &str, aliases: &str) -> Result { let mut by_name: HashMap = HashMap::new(); for line in dictionary.lines() { @@ -148,15 +151,16 @@ impl YsnpDictionary { }) } - /// Candidate dictionary filenames in `load` preference order: the full ~200 MB / ~2M-name - /// catalog first, then the small per-chip panel only as a fallback. The chromo2 chip panel is a - /// stale ~14k-name subset that would shadow current names present in the full catalog, so the - /// catalog wins whenever it is installed (it is the one downloaded on first use). + /// Candidate dictionary filenames, in the preference order of `load`. The full ~200 MB catalog + /// of ~2M names comes first, then the small panel of one chip as a fallback. The chromo2 chip + /// panel is a stale subset of ~14k names, and it would hide current names that the full + /// catalog holds. So the catalog wins whenever the machine has it, and it is the one the app + /// downloads on first use. pub const ASSET_FILENAMES: &'static [&'static str] = &["dictionary.tsv", "chromo2-panel.tsv"]; /// Read the asset from `dir`: the first of [`Self::ASSET_FILENAMES`] that exists, plus an - /// optional sibling `aliases.tsv`. Prefers the full catalog for the widest, current name - /// coverage; the chromo2 panel is only used when the catalog is not present. + /// optional sibling `aliases.tsv`. It prefers the full catalog, for the widest and most + /// current name coverage. It uses the chromo2 panel only when the catalog is absent. pub fn load(dir: &Path) -> Result { let dict_path = Self::ASSET_FILENAMES .iter() @@ -189,12 +193,15 @@ impl YsnpDictionary { }) } - /// Build a reverse index `position → canonical name` for one reference `build` (the inverse of - /// [`resolve`](Self::resolve)). Lets a caller annotate a position-only call (a novel/private Y - /// variant) with the catalogued Y-SNP name at that site, if one exists. The first name seen at a - /// position wins (deterministic over a sorted asset); positions absent on `build` are omitted. - /// All entries are chrY in practice, so the key is position alone — a caller resolving the - /// correct build avoids the (vanishingly unlikely) cross-build integer collision. + /// Build a reverse index `position → canonical name` for one reference `build`. This is the + /// inverse of [`resolve`](Self::resolve). A position-only call is a novel or private Y + /// variant. This index lets a caller add the catalogued Y-SNP name at that site, when such a + /// name exists. + /// + /// At one position the first name wins, which is deterministic over a sorted asset. This + /// leaves out a position that `build` does not have. In practice every entry is chrY, so the + /// key is the position alone. A caller that resolves the correct build avoids the cross-build + /// integer collision, which is in any case very improbable. pub fn position_index(&self, build: &str) -> HashMap { let mut idx = HashMap::new(); for entry in self.by_name.values() { @@ -295,7 +302,7 @@ M269\tCTS10003 #[test] fn alias_resolves_to_canonical() { let d = dict(); - // PF6517 is an alias of M269; resolve via the alias. + // PF6517 is an alias of M269, so resolve through the alias. let r = d.resolve("PF6517", "GRCh38").unwrap(); assert_eq!(r.canonical, "M269"); assert_eq!(r.coord.position, 22739367); @@ -304,7 +311,8 @@ M269\tCTS10003 #[test] fn alias_to_unknown_canonical_is_ignored() { let d = dict(); - // S163 -> NoSuchSnp (not a real entry): the alias is dropped, so S163 is unresolvable. + // S163 -> NoSuchSnp (not a real entry). The code drops the alias, so S163 does not + // resolve. assert!(d.resolve("S163", "GRCh38").is_none()); } diff --git a/crates/navigator-domain/src/ystr_cluster.rs b/crates/navigator-domain/src/ystr_cluster.rs index 9c54bb0f..e37566d8 100644 --- a/crates/navigator-domain/src/ystr_cluster.rs +++ b/crates/navigator-domain/src/ystr_cluster.rs @@ -1,12 +1,14 @@ -//! Y-STR autoclustering with SNP-branch propagation (FTDNA project-import follow-on). +//! Automatic Y-STR clustering with SNP-branch propagation (FTDNA project-import follow-on). //! -//! Given a project's members — each with a Y-STR haplotype and, for the SNP-placed ones, a branch -//! label — group them into clusters by Y-STR genetic distance, then **propagate** each cluster's -//! SNP branch onto its STR-only members as a *suggested* placement. Effectively: "this STR-only -//! haplotype most likely sits on SNP branch X." +//! Each member of a project has a Y-STR haplotype, and a member that SNP placement reached also +//! has a branch label. This module groups the members into clusters by Y-STR genetic distance. It +//! then **propagates** the SNP branch of each cluster onto the STR-only members of that cluster, +//! as a *suggested* placement. In effect: "this STR-only haplotype most probably sits on SNP +//! branch X." //! -//! Pure marker math, no IO. Genetic distance is order-independent for multi-copy markers and -//! normalized per 100 comparable markers so mixed panel sizes (Y-12 … Y-700) compare fairly. +//! Pure marker math, no IO. Genetic distance is independent of order for a multi-copy marker. The +//! code normalizes it to 100 comparable markers, so that mixed panel sizes (Y-12 … Y-700) compare +//! well. use std::collections::HashMap; @@ -26,12 +28,12 @@ pub struct ClusterMember { pub markers: Vec, } -/// Tuning for [`cluster_ystr`]. +/// Options for [`cluster_ystr`]. #[derive(Debug, Clone)] pub struct ClusterOpts { /// Minimum shared markers for two members to be comparable. pub min_markers: i64, - /// Max normalized genetic distance (mutations per 100 markers) to link two members. + /// Max normalized genetic distance (mutations in 100 markers) to link two members. pub link_gd_per_100: f32, } @@ -49,11 +51,13 @@ impl Default for ClusterOpts { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct BranchSuggestion { pub branch: String, - /// Genetic distance (differing markers) to the nearest placed member. + /// Genetic distance (the count of markers that are not the same) to the nearest placed + /// member. pub gd: i64, /// Markers compared with that nearest placed member. pub compared: i64, - /// 0..1 — higher = closer match + more agreement among the cluster's placed members. + /// 0..1. A higher value is a closer match, and more agreement among the placed members of the + /// cluster. pub confidence: f32, } @@ -266,7 +270,8 @@ fn build_cluster(members: &[ClusterMember], maps: &[HashMap], id }) .min_by(|a, b| a.1.cmp(&b.1).then(b.2.cmp(&a.2))); let suggestion = nearest.map(|(branch, gd, comp)| { - // Confidence: closeness (GD 0 → 1.0, decaying) × branch agreement in the cluster. + // Confidence: closeness (GD 0 → 1.0, then it decreases) × branch agreement in + // the cluster. let closeness = (1.0 - gd as f32 / 12.0).clamp(0.0, 1.0); let agreement = if total_placed > 0 { branch_counts.get(branch).copied().unwrap_or(0) as f32 / total_placed as f32 @@ -370,7 +375,7 @@ mod tests { ("DYS442", "12"), ("DYS438", "12"), ]; - // Bump the value of the first `tweak` markers by appending '9' to force a difference. + // Add '9' to the value of the first `tweak` markers, to force a difference. let mutated = ["99", "98", "97", "96", "95", "94", "93", "92", "91", "90", "89", "88"]; base.iter() .enumerate() diff --git a/crates/navigator-ui/src/charts.rs b/crates/navigator-ui/src/charts.rs index d6793e7c..4dcc0b29 100644 --- a/crates/navigator-ui/src/charts.rs +++ b/crates/navigator-ui/src/charts.rs @@ -1,6 +1,6 @@ -//! Pure ancestry / genome-region visualization helpers, extracted from the UI shell. Leaf drawing -//! functions over `egui` with no `App`/`self` state — easy to read, test, and reuse independently of -//! the view code that calls them. +//! Pure helpers that visualize ancestry and genome regions, taken out of the UI shell. They are +//! leaf functions over `egui` that draw, with no `App` or `self` state. They are easy to read, to +//! test, and to reuse apart from the view code that calls them. use eframe::egui; use navigator_app::{ @@ -24,10 +24,11 @@ fn chrom_sort_key(chr: &str) -> (u8, i64) { } } -/// Draw a per-chromosome **IBD-segment ideogram** (gap §8): one horizontal bar per chromosome that -/// carries a shared segment, scaled to the chromosome's true length when `regions` is available (else -/// to the segments' own span), each IBD segment painted as a teal block (brighter = longer in cM) with -/// per-segment hover details. Mirrors [`draw_chromosome_painting`]'s painter approach. +/// Draw an **IBD-segment ideogram** for each chromosome (gap §8). There is one horizontal bar for +/// each chromosome that carries a shared segment. The bar scales to the true length of the +/// chromosome when `regions` is there, and to the span of the segments when it is not. Each IBD +/// segment paints as a teal block, and a brighter block is longer in cM. Each segment has its own +/// hover details. This mirrors the painter approach of [`draw_chromosome_painting`]. pub(crate) fn draw_ibd_segments(ui: &mut egui::Ui, segments: &[IbdSegment], regions: Option<&GenomeRegions>) { use std::collections::BTreeMap; let mut by_chr: BTreeMap> = BTreeMap::new(); @@ -111,14 +112,15 @@ fn roh_pattern_label(p: RohPattern) -> &'static str { } } -/// Draw the runs-of-homozygosity view: a genome-wide summary line (F_ROH, pattern, length-class -/// counts) followed by a per-chromosome ideogram of ROH blocks coloured by length class. Mirrors -/// [`draw_ibd_segments`]. `regions` scales each bar to the chromosome's true length when available. +/// Draw the runs-of-homozygosity view. First a genome-wide summary line (F_ROH, pattern, counts +/// for each length class), then an ideogram for each chromosome of ROH blocks, coloured by class. +/// Mirrors [`draw_ibd_segments`]. `regions` scales each bar to the true length of the chromosome, +/// when it is there. pub(crate) fn draw_roh(ui: &mut egui::Ui, result: &RohResult, regions: Option<&GenomeRegions>) { use std::collections::BTreeMap; let s = &result.summary; - // Summary line: F_ROH is the headline inbreeding coefficient. + // Summary line: F_ROH is the main inbreeding coefficient. ui.horizontal(|ui| { ui.label(egui::RichText::new(format!("F_ROH {:.3}", s.f_roh)).strong()); ui.separator(); @@ -195,9 +197,10 @@ pub(crate) fn draw_roh(ui: &mut egui::Ui, result: &RohResult, regions: Option<&G } } -/// Fill color for a painted segment: the super-population color, tinted (deterministically by the -/// fine-population code) so distinct fine populations within one continent are distinguishable while -/// still reading as that continent. Un-resolved segments keep the flat super-population color. +/// Fill color for a painted segment: the super-population color, with a tint. The fine-population +/// code sets that tint deterministically, so that two fine populations inside one continent look +/// different, and both still read as that continent. A segment with no fine population keeps the +/// flat super-population color. fn segment_color(s: &AncestrySegment) -> egui::Color32 { let base = parse_hex_color(&population_color(&s.population_code)); match &s.fine_population_code { @@ -206,8 +209,9 @@ fn segment_color(s: &AncestrySegment) -> egui::Color32 { } } -/// Shift a color's lightness by a small deterministic amount keyed on `key` (±~0.18), keeping it in -/// a visible range — used to separate fine populations that share a super-population base color. +/// Shift the lightness of a color by a small deterministic amount, which `key` sets (±~0.18). It +/// keeps the color in a visible range. This separates fine populations that share one +/// super-population base color. fn tint_color(c: egui::Color32, key: &str) -> egui::Color32 { let h = key.bytes().fold(0u32, |a, b| a.wrapping_mul(31).wrapping_add(b as u32)); let f = ((h % 100) as f32 / 100.0 - 0.5) * 0.36; // -0.18..0.18 @@ -215,8 +219,9 @@ fn tint_color(c: egui::Color32, key: &str) -> egui::Color32 { egui::Color32::from_rgb(adj(c.r()), adj(c.g()), adj(c.b())) } -/// The names of the `k` populations covering the most base pairs on `side` (fine where resolved, -/// else super-population), most-covered first — the per-side summary for the Simple view. +/// The names of the `k` populations that cover the most base pairs on `side`, with the largest +/// first. It gives the fine population where one resolved, and the super-population where none did. +/// This is the summary of each side for the Simple view. pub(crate) fn top_populations_for_side(segments: &[AncestrySegment], side: u8, k: usize) -> Vec { use std::collections::HashMap; let mut bp: HashMap = HashMap::new(); @@ -232,13 +237,14 @@ pub(crate) fn top_populations_for_side(segments: &[AncestrySegment], side: u8, k v.into_iter().take(k).map(|(c, _)| population_name(&c)).collect() } -/// Draw the per-chromosome local-ancestry painting: one horizontal bar per autosome (each normalized -/// to full width) with two stacked side tracks (top = `side_labels[0]`, bottom = `side_labels[1]`), -/// segments colored by ancestry (fine populations sub-shaded), per-segment hover, and a legend. +/// Draw the local-ancestry painting for each chromosome. There is one horizontal bar for each +/// autosome, and each bar normalizes to the full width. Each bar holds two stacked side tracks: the +/// top is `side_labels[0]`, and the bottom is `side_labels[1]`. Ancestry colors the segments, and a +/// fine population gets a sub-shade. Each segment has its own hover, and there is a legend. pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySegment], side_labels: &[String; 2]) { use std::collections::BTreeMap; - // Group by autosome number → the two sides' segments. Non-autosomes (X/Y/M / the chr99 fallback) - // are skipped — this is autosomal local ancestry. + // Group by autosome number → the segments of the two sides. This drops anything that is not an + // autosome (X, Y, M, and the chr99 fallback), because this is autosomal local ancestry. let mut by_chr: BTreeMap; 2]> = BTreeMap::new(); for s in segments { let Ok(n) = navigator_domain::contig::bare(&s.contig).parse::() else { @@ -254,9 +260,10 @@ pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySe let copy_h = 7.0; // each of the two side tracks let gap = 2.0; - // Cross-highlight: the population hovered last frame (legend entry or segment). Segments of that - // population stay full-color while the rest dim — so a single fine population reads clearly out of - // the shades of blue. Carried across frames in egui temp memory; recomputed into `next_hovered`. + // Cross-highlight: the population the pointer was over on the last frame, either a legend entry + // or a segment. The segments of that population stay at full color, and the rest go dim. One + // fine population then reads clearly out of the shades of blue. The temp memory of egui carries + // this across frames, and the code calculates it again into `next_hovered`. let hover_id = egui::Id::new("chrom_paint_pop_hover"); let hovered: Option = ui.data(|d| d.get_temp(hover_id)); let mut next_hovered: Option = None; @@ -312,7 +319,8 @@ pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySe painter.rect_filled(seg_rect, 0.0, col); } } - // Per-segment hover: highlight that population + show side / population / Mb-range tooltip. + // Hover on a segment: highlight that population, and show a tooltip with the side, + // the population and the Mb range. if let Some(pos) = response.hover_pos() { let c = if pos.y < rect.top() + copy_h + gap * 0.5 { 0usize @@ -337,8 +345,9 @@ pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySe } }); } - // Legend: distinct populations present (fine where resolved), each with its tinted swatch. Hovering - // an entry highlights its segments above (and vice-versa). + // Legend: the distinct populations that are here (fine where one resolved), each with its + // tinted swatch. The pointer over an entry highlights its segments above, and the pointer over + // a segment highlights the entry. let mut seen: Vec<(String, egui::Color32)> = Vec::new(); for s in segments { let code = seg_code(s); @@ -399,9 +408,10 @@ fn arc_points(c: egui::Pos2, r: f32, a0: f32, a1: f32, steps: usize) -> Vec= 0.999 { painter.circle_filled(c, r, *color); break; @@ -434,8 +444,8 @@ pub(crate) fn draw_pie(ui: &mut egui::Ui, size: f32, slices: &[(f64, egui::Color } } -/// Pie chart of the super-population proportions (one slice per super-population, colored by -/// continent). +/// Pie chart of the super-population proportions: one slice for each super-population, coloured by +/// continent. pub(crate) fn draw_ancestry_donut(ui: &mut egui::Ui, summary: &[SuperPopulationSummary]) { let slices: Vec<(f64, egui::Color32)> = summary .iter() @@ -447,22 +457,21 @@ pub(crate) fn draw_ancestry_donut(ui: &mut egui::Ui, summary: &[SuperPopulationS draw_pie(ui, 120.0, &slices); } -/// A generic donut from pre-colored `(percentage, color)` slices (used by the Simple-mode brief for -/// the ancient-ancestry pie, whose components carry their own palette colors). Optionally labels the -/// hole with the largest slice's share. -/// Pie chart from explicit `(percentage, color)` slices (the ancient-component report, which carries -/// its own colors). `_center_pct` is kept for call-site compatibility but no longer rendered — a +/// A pie from explicit `(percentage, color)` slices that already carry their colors. The +/// Simple-mode brief uses it for the ancient-ancestry pie, and the ancient-component report uses it +/// too. `_center_pct` stays for compatibility at the call sites, and nothing draws it, because a /// solid pie has no centre to label. pub(crate) fn draw_color_donut(ui: &mut egui::Ui, slices: &[(f64, egui::Color32)], _center_pct: Option) { draw_pie(ui, 120.0, slices); } -/// Draw a detailed ancestry breakdown (the fine-population or ancient-component report): the -/// estimate's `components`, sorted by share, as a name/percentage grid with a proportion bar, plus a -/// provenance line (method + SNP count). `id_salt` keeps each report's grid distinct. +/// Draw a detailed ancestry breakdown: the fine-population report, or the ancient-component report. +/// It takes the `components` of the estimate, sorts them by share, and draws a grid of name and +/// percentage with a proportion bar. It adds a provenance line with the method and the SNP count. +/// `id_salt` keeps the grid of each report distinct. pub(crate) fn draw_population_components(ui: &mut egui::Ui, result: &AncestryResult, _id_salt: &str, top_n: usize) { - // (friendly name, code, percentage) — the component carries `population_name` (e.g. EEF → "Early - // European Farmer"), so the legend reads in plain language rather than codes. + // (friendly name, code, percentage). The component carries `population_name`, for example EEF → + // "Early European Farmer", so the legend reads in plain language and not in codes. let mut comps: Vec<(&str, &str, f64)> = result .components .iter() @@ -480,8 +489,9 @@ pub(crate) fn draw_population_components(ui: &mut egui::Ui, result: &AncestryRes .map(|(_, code, pct)| (*pct, parse_hex_color(&population_color(code)))) .collect(); - // Horizontal: pie on the left, a colour-swatch legend (friendly name + %) on the right — compact - // vertically, so the modern + ancient panels can sit side by side in the Advanced view. + // Horizontal: the pie on the left, and a colour-swatch legend (friendly name and %) on the + // right. It is compact from top to bottom, so the modern panel and the ancient panel can sit + // side by side in the Advanced view. ui.horizontal_top(|ui| { draw_pie(ui, 108.0, &slices); ui.add_space(12.0); @@ -529,7 +539,7 @@ pub(crate) fn draw_composition_bar(ui: &mut egui::Ui, summary: &[SuperPopulation } } -/// Parse a `#RRGGBB` hex color, falling back to grey on a malformed string. +/// Parse a `#RRGGBB` hex color. A malformed string gives grey. pub(crate) fn parse_hex_color(hex: &str) -> egui::Color32 { let h = hex.trim_start_matches('#'); if h.len() == 6 { @@ -546,9 +556,11 @@ pub(crate) fn parse_hex_color(hex: &str) -> egui::Color32 { /// Marks for [`asset_status_line`]: verified · present-but-unverified · absent. /// -/// Named constants rather than literals so the glyph test can assert them. A character with no -/// glyph in egui's Proportional family renders as an empty box that no other test and no compiler -/// can see — which is how this line shipped with `✓` and `✗`, neither of which egui can draw. +/// These constants have names, and they are not literals, so that the glyph test can assert them. +/// A character +/// with no glyph in the Proportional family of egui draws as an empty box, which no other test and +/// no compiler can see. That is how this line went out with `✓` and `✗`, and egui can draw +/// neither. pub(crate) const MARK_VERIFIED: &str = "✔"; pub(crate) const MARK_PRESENT: &str = "•"; pub(crate) const MARK_ABSENT: &str = "✖"; @@ -593,7 +605,8 @@ pub(crate) struct VariantMark { pub state: &'static str, } -/// A shaded background region on a variant track (chrY PAR/heterochromatin, chrM HVR/coding). +/// A shaded background region on a variant track (chrY PAR or heterochromatin, chrM HVR or +/// coding). pub(crate) struct TrackRegion { pub start: i64, pub end: i64, @@ -601,11 +614,11 @@ pub(crate) struct TrackRegion { pub label: String, } -/// Draw a single-chromosome **variant track**: one horizontal bar scaled to `length`, optional -/// shaded background regions, and a vertical tick per variant colored by its consensus state. Hover -/// over the bar surfaces the nearest variant (`name · pos · state`) and any region under the cursor. -/// Replaces the genome-wide karyotype ideogram for the Y/mt variant views. Mirrors the -/// [`draw_ibd_segments`] painter approach (no `egui_plot`). +/// Draw a **variant track** for one chromosome. It has one horizontal bar that scales to `length`, +/// optional shaded background regions, and one vertical tick for each variant, coloured by its +/// consensus state. The pointer over the bar shows the nearest variant (`name · pos · state`), and +/// any region under the cursor. This replaces the genome-wide karyotype ideogram for the Y and mt +/// variant views. It mirrors the painter approach of [`draw_ibd_segments`], with no `egui_plot`. pub(crate) fn draw_variant_track( ui: &mut egui::Ui, chrom_label: &str, @@ -625,7 +638,7 @@ pub(crate) fn draw_variant_track( let painter = ui.painter_at(rect); painter.rect_filled(rect, 3.0, egui::Color32::from_gray(28)); - // Background region shading. + // The shade behind each region. for r in regions { let x0 = rect.left() + (r.start.max(0) as f32 / len).clamp(0.0, 1.0) * rect.width(); let x1 = rect.left() + (r.end.max(0) as f32 / len).clamp(0.0, 1.0) * rect.width(); @@ -724,7 +737,7 @@ pub(crate) fn draw_pca_scatter(ui: &mut egui::Ui, sample: Option<(f64, f64)>, re ); } }); - // Compact super-population legend (matches the dot colors via a representative member). + // Compact super-population legend. It matches the dot colors through a representative member. let mut seen: Vec<&str> = Vec::new(); let mut legend: Vec<(String, egui::Color32)> = Vec::new(); for (code, _, _) in reference { @@ -746,7 +759,7 @@ pub(crate) fn draw_pca_scatter(ui: &mut egui::Ui, sample: Option<(f64, f64)>, re } } -/// egui_plot bar chart. Shared by the whole-genome and per-contig coverage views. +/// egui_plot bar chart. The whole-genome coverage view and the view for each contig share it. pub(crate) fn coverage_histogram_chart(ui: &mut egui::Ui, hist: &[u64], title: &str) { use egui_plot::{Bar, BarChart, Plot}; ui.label(format!("Depth histogram — {title} (depth ≥1; x = depth, y = bases)")); @@ -761,8 +774,9 @@ pub(crate) fn coverage_histogram_chart(ui: &mut egui::Ui, hist: &[u64], title: & let max_depth = hist.len().max(2) as f64; let max_count = hist.iter().skip(1).copied().max().unwrap_or(1) as f64; let chart = BarChart::new(bars).name("bases"); - // Fixed, non-interactive view: lock pan/zoom/scroll and pin the bounds to the data so the - // axes can't drift into negative space or be dragged off-screen. + // A fixed view that the user can not move. Lock pan, zoom and scroll, and pin the bounds to + // the data. The axes can then not move into negative space, and the user can not drag them off + // the screen. Plot::new(format!("coverage_histogram_{title}")) .height(180.0) .allow_drag(false) @@ -778,15 +792,17 @@ pub(crate) fn coverage_histogram_chart(ui: &mut egui::Ui, hist: &[u64], title: & .show(ui, |plot_ui| plot_ui.bar_chart(chart)); } -/// Draw archaic (Tier B) segments as a per-chromosome track. +/// Draw archaic (Tier B) segments as a track for each chromosome. /// -/// Deliberately one colour: lineage attribution is gated off (design §7 — a Denisovan split for a -/// European would be manufactured), so colouring by `ArchaicSource` would imply a distinction the -/// data does not support. When attribution lands, colour by source here. +/// One colour, on purpose. The code gates lineage attribution off, because a Denisovan split for a +/// European would be an invention (design §7). A colour from `ArchaicSource` would suggest a +/// difference the data does not support. When attribution lands, take the colour from the source +/// here. pub(crate) fn draw_archaic_segments(ui: &mut egui::Ui, result: &navigator_app::ArchaicSegmentResult) { use std::collections::BTreeMap; - // Order rows NUMERICALLY, not lexicographically: keying a BTreeMap on the contig string gives - // chr1, chr10, chr11 … chr19, chr2, chr20 — which reads as a bug to anyone scanning the track. + // Order rows NUMERICALLY, and not lexicographically. A BTreeMap with the contig string as the + // key gives chr1, chr10, chr11 … chr19, chr2, chr20. To anybody who reads the track, that looks + // like a fault. 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 diff --git a/crates/navigator-ui/src/cli.rs b/crates/navigator-ui/src/cli.rs index 2e7a2cbe..1e777ad9 100644 --- a/crates/navigator-ui/src/cli.rs +++ b/crates/navigator-ui/src/cli.rs @@ -1,10 +1,10 @@ -//! Headless command-line interface for the Navigator workbench. The same binary launches -//! the egui GUI when run with no subcommand; with a subcommand it opens the *same* workspace -//! database and runs scripted ingestion or read-only probes, then exits. +//! Headless command-line interface for the Navigator workbench. The same binary starts the egui +//! GUI when it runs with no subcommand. With a subcommand it opens the *same* workspace database, +//! runs scripted ingestion or read-only probes, and then exits. //! -//! This makes the workbench scriptable: bulk-load an assortment of files into a subject via -//! the unified auto-detect importer (`app.add_data`), then query the resulting rows for -//! verification (`subjects` / `show` / `projects`, with `--json` for machine consumption). +//! This makes the workbench scriptable. Bulk-load a mixed set of files into a subject through the +//! unified auto-detect importer (`app.add_data`). Then query the rows that come out, to check them: +//! `subjects`, `show` or `projects`, with `--json` for a machine to read. //! //! navigator ingest --subject "James Kane" --project mine /Volumes/nas/Genomics/mine/* //! navigator show --subject "James Kane" --json @@ -18,11 +18,11 @@ use navigator_domain::workspace::NewProject; /// The exit status a failed CLI step ends its command with. /// -/// Each `navigator ` runs as a function returning the process exit code, so `?` is not -/// available and every fallible step needs its error turned into a code. Two kinds of error reach -/// that point and they differ only in whether the message has been printed yet: the helpers in this -/// module print their own and hand back the code, while [`App`] returns an `AppError` nobody has -/// shown the user. This is the seam between them, so [`cli_try!`] needs only one arm. +/// Each `navigator ` is a function that returns the process exit code. So `?` is not +/// available, and every step that can fail needs its error turned into a code. Two kinds of error +/// reach that point, and they differ only in whether anything printed the message yet. The helpers +/// in this module print their own and hand back the code. [`App`] returns an `AppError` that nobody +/// showed the user. This is the seam between them, so [`cli_try!`] needs only one arm. trait ExitCode { fn exit_code(self) -> i32; } @@ -42,9 +42,9 @@ impl ExitCode for navigator_app::AppError { /// Unwrap a CLI step, or end the command with the exit status its failure implies. /// -/// This is `?` for a function returning `i32` instead of `Result`. It replaced ~50 hand-written -/// four-line `match` blocks, which between them were most of what stood in the way of reading a -/// command as the short sequence of steps it actually is. +/// This is `?` for a function that returns `i32` instead of `Result`. It replaced ~50 hand-written +/// four-line `match` blocks. Together those were most of what stood in the way of a command that +/// reads as the short sequence of steps it is. macro_rules! cli_try { ($e:expr) => { match $e { @@ -67,7 +67,8 @@ pub struct Cli { #[derive(Subcommand)] pub enum Command { - /// Ingest files/directories into a subject via auto-detection (BAM/CRAM, VCF, chip, STR, mtDNA FASTA). + /// Ingest files and directories into a subject, with auto-detection (BAM, CRAM, VCF, chip, STR, + /// mtDNA FASTA). Ingest(IngestArgs), /// List every subject with its data-source counts. Subjects(ProbeArgs), @@ -75,46 +76,55 @@ pub enum Command { Show(ShowArgs), /// Diagnostic: trace the genome-consensus Y placement for one subject (candidates + lineage tally). DebugPlace(ShowArgs), - /// Diagnostic: dump the Y descent SNP-by-SNP — state + observed base vs the tree's per-build polarity. + /// Diagnostic: dump the Y descent one SNP at a time. It gives the state and the observed base, + /// against the polarity of the tree on each build. DebugDescent(ShowArgs), /// Diagnostic: dump the raw read pileup (ref + A/C/G/T) behind each lineage call for one alignment. DebugCalls(DebugCallsArgs), - /// Diagnostic: the filtered private-Y bucket for an alignment — DISPLAY vs PUBLISH counts. + /// Diagnostic: the filtered private-Y bucket for an alignment, with the DISPLAY and PUBLISH + /// counts. PrivateY(PrivateYArgs), /// Publish the ancestral origins (MDKA surname / place / dates) of subjects this workspace may /// publish for. Dry-run unless `--apply`. PublishOrigins(PublishOriginsArgs), - /// Diagnostic: deep (ancient) ancestry fitted over each view of a subject — the pooled - /// consensus, each source alone, and thinned site sets. The stability gate: a 30x WGS and a - /// consumer chip must agree, or the estimate is tracking the assay, not the donor. + /// Diagnostic: deep (ancient) ancestry fitted over each view of a subject. Those views are the + /// pooled consensus, each source alone, and thinned site sets. This is the stability gate: a + /// 30x WGS and a consumer chip must agree, or the estimate follows the assay, and not the + /// donor. DebugAncient(ShowArgs), - /// Deep (ancient) ancestry via qpAdm: fit WHG/EEF/Steppe (Patterson-2022 config) over the - /// subject's pooled autosomal consensus (all WGS — any reference — plus chips). Requires the - /// consensus to be built first (see the Autosomal tab / `ingest`), then persists. See + /// Deep (ancient) ancestry through qpAdm: fit WHG, EEF and Steppe (the Patterson-2022 config) + /// over the pooled autosomal consensus of the subject. That consensus covers all WGS, on any + /// reference, plus the chips. The consensus must exist first (see the Autosomal tab, or + /// `ingest`), and this then persists the result. See /// `navigator_app::App::estimate_deep_ancestry`. DeepAncestry(ShowArgs), - /// Archaic (Neanderthal / Denisovan) Tier-A marker count from the subject's pooled autosomal - /// consensus. Reports copies carried of copies assayed — a count over the sites the subject's - /// data actually covered, not a "% Neanderthal". Requires the consensus to be built first. - /// See `navigator_app::App::estimate_archaic_from_consensus`. + /// Archaic (Neanderthal, Denisovan) Tier-A marker count, from the pooled autosomal consensus of + /// the subject. It reports the copies carried over the copies assayed. That is a count over the + /// sites the data of the subject covered, and never a "% Neanderthal". The consensus must exist + /// first. See `navigator_app::App::estimate_archaic_from_consensus`. Archaic(ArchaicArgs), - /// Tier B: call archaic SEGMENTS from the subject's cached genome-wide de-novo diploid calls. - /// Run `call` first — this reads the cache rather than starting a whole-genome pass. + /// Tier B: call archaic SEGMENTS from the cached genome-wide de-novo diploid calls of the + /// subject. Run `call` first, because this reads the cache, and does not start a whole-genome + /// pass. ArchaicSegments(ShowArgs), - /// Panel batch-process mode (progressive-consensus): genotype the subject's CHM13 alignment(s) at - /// the full-1240k panel, caching the dosages and refreshing the autosomal consensus so ancestry is - /// ready without a later lazy build. Heavy (a whole-genome decode per alignment). + /// Panel batch-process mode (progressive consensus). It genotypes the CHM13 alignments of the + /// subject at the full 1240k panel, caches the dosages, and refreshes the autosomal consensus. + /// Ancestry is then ready with no later lazy build. It is heavy: one whole-genome decode for + /// each alignment. GenotypePanel(ShowArgs), - /// Per-marker branch report: the sample's genotype at every defining marker of a Y/mtDNA tree - /// node's descendant subtree (observed base + derived/ancestral status + evidence). For - /// spot-checking placement and exchanging observations. Table by default; `--tsv` / `--json`. + /// A branch report for each marker. It gives the genotype of the sample at every marker that + /// defines a node in the descendant subtree of a Y or mtDNA tree node. Each row has the + /// observed base, the derived or ancestral status, and the evidence. Use it to spot-check a + /// placement, and to exchange observations. It gives a table by default, and `--tsv` or + /// `--json` on request. BranchReport(BranchReportArgs), - /// Diagnostic: explain why an alignment can not be read. Probes the BAM/CRAM, its coordinate - /// index, the reference FASTA and that FASTA's `.fai` **separately**, so a failure names the - /// file actually at fault instead of whichever path the failing call happened to be handed — - /// and reports the raw errno, which on macOS is the only thing distinguishing a privacy (TCC) - /// denial from a Unix permission denial. Prints a report meant for pasting into a bug report. - /// Use `--file` to check a file that was never imported. Exits non-zero if a check failed. + /// Diagnostic: explain why nothing can read an alignment. It probes the BAM or CRAM, its + /// coordinate index, the reference FASTA, and the `.fai` of that FASTA, each one **on its own**. + /// A failure then names the file at fault, and not whatever path the call that failed received. + /// It also reports the raw errno, which on macOS is the only thing that separates a privacy + /// (TCC) denial from a Unix permission denial. It prints a report to paste into a bug report, + /// and `--file` checks a file that no import brought in. It exits non-zero when a check + /// failed. Doctor(DoctorArgs), /// List projects with their subject counts. Projects(ProbeArgs), @@ -122,82 +132,91 @@ pub enum Command { Call(CallArgs), /// Lift a VCF from one reference build to another (chain-based; the GATK LiftoverVcf replacement). LiftVcf(LiftVcfArgs), - /// Run the per-alignment analysis steps with per-step wall-clock timing (the GUI Full Analysis - /// path), to profile where time goes. Mutates the workspace (caches results). + /// Run the analysis steps of each alignment, with the wall-clock time of each step. This is the + /// Full Analysis path of the GUI, and it profiles where the time goes. It changes the workspace, + /// because it caches the results. Analyze(AnalyzeArgs), - /// Rebuild the genome-consensus Y and mtDNA signatures (variant profile + descent) for subjects, - /// e.g. to re-place existing profiles on the current tree provider. Reuses cached genotypes, so it - /// is cheap for already-analyzed subjects. By default only subjects that already have a Y or mt - /// profile are rebuilt (`--all` rebuilds every subject). + /// Build the genome-consensus Y and mtDNA signatures again (the variant profile and the + /// descent) for a set of subjects. Use it to place existing profiles again on the current tree + /// provider. It reuses cached genotypes, so it costs little for a subject an analysis already + /// covered. By default it takes only subjects that already have a Y or mt profile, and `--all` + /// takes every subject. RebuildSignatures(RebuildArgs), - /// Re-run the sidecar fast path (external GATK4 Y/mt GVCFs) for subjects whose source directory - /// still carries them — restoring external calls that a pre-provenance build's internal walk had - /// overwritten. Cheap (reads the small GVCFs, never the CRAM); external calls land on their own - /// `:ext` keys and, with "prefer external caller" on, win the consensus. The operational fix for - /// a workspace (e.g. PRJEB37976) imported before external-caller precedence existed. + /// Run the sidecar fast path again (external GATK4 Y and mt GVCFs) for a subject whose source + /// directory still carries them. It restores the external calls that the internal walk of a + /// build from before provenance had written over. The cost is low: it reads the small GVCFs, + /// and never the CRAM. An external call lands on its own `:ext` key, and with "prefer external + /// caller" on, it wins the consensus. This is the operational fix for a workspace, for example + /// PRJEB37976, that an import brought in before external-caller precedence existed. ReingestExternal(ReingestArgs), - /// "Compare callers": for each of a subject's alignments, show the trusted external (imported - /// GVCF) Y/mtDNA terminal beside Navigator's own internal-caller terminal — **forcing** the - /// internal walk regardless of the "prefer external caller" setting. Surfaces GATK-vs-Navigator - /// divergence (e.g. ancient-DNA damage). Non-destructive to the external call: the internal walk + /// "Compare callers": for each alignment of a subject, it shows two terminals side by side. One + /// is the trusted external Y or mtDNA terminal, from the imported GVCF. The other is the + /// terminal of the internal caller of Navigator. It **forces** the internal walk, whatever the + /// "prefer external caller" setting says. It shows where GATK and Navigator differ, for example + /// on ancient-DNA damage. It does not damage the external call, because the internal walk /// records its own separate rows. CompareCallers(ShowArgs), - /// Backfill the standardized-test-label read-profile fields (`total_bases`, `read_type`) on runs - /// imported before those fields existed. `total_bases` is recovered for free from cached - /// read-metrics; `read_type` is inferred from platform/test-type, with `--rescan` reading a - /// bounded prefix of the alignment to tell HiFi from CLR on generic-WGS PacBio runs. + /// Backfill the read-profile fields of the standardized test label (`total_bases` and + /// `read_type`) on runs that came in before those fields existed. `total_bases` comes for free + /// from the cached read metrics. `read_type` comes from the platform and the test type. With + /// `--rescan` it reads a bounded prefix of the alignment, to tell HiFi from CLR on a + /// generic-WGS PacBio run. BackfillProfiles(BackfillArgs), - /// Delete orphaned alignment (coverage-summary) records from the signed-in account's PDS — the - /// duplicates left by the old create-race (two records for one alignment). Dry-run by default; - /// pass `--apply` to actually delete. Requires being signed in. + /// Delete orphaned alignment (coverage-summary) records from the PDS of the account that signed + /// in. Those are the duplicates the old create-race left, with two records for one alignment. + /// It is a dry run by default, and `--apply` does the delete. The user must sign in first. PruneOrphans(PruneArgs), - /// Sign in to a PDS account via OAuth (opens a browser, waits for the loopback callback) and - /// persist the session so the other subcommands (publish, prune-orphans) can authenticate. + /// Sign in to a PDS account through OAuth. It opens a browser, and waits for the loopback + /// callback. It then persists the session, so that the other subcommands (publish, + /// prune-orphans) can authenticate. Login(LoginArgs), /// Attach public-catalog external ids (IGSR/HGDP/INSDC) derivable from each subject's local /// provenance, so bulk-imported public datasets publish ids that match their AppView catalog /// rows. Dry-run by default; pass `--apply` to write. Local-only (no PDS writes). BackfillCatalogIds(CatalogArgs), - /// Resolve subjects against the AppView samples API and attach, in one pass, the catalog name id - /// (IGSR/HGDP) plus the authoritative INSDC accession it returns (`SAMN…`→BIOSAMPLE, `ERS…`→ENA, - /// `SRS…`→SRA), correcting the local placeholder. Dry-run by default; `--apply` to write. Only - /// queries recognizable catalog aliases unless `--all`. Local writes only (no PDS). + /// Resolve subjects against the samples API of the AppView. In one pass it attaches the catalog + /// name id (IGSR or HGDP). It also attaches the authoritative INSDC accession the API returns + /// (`SAMN…`→BIOSAMPLE, `ERS…`→ENA, `SRS…`→SRA), and corrects the local placeholder. It is a + /// dry run by default, and `--apply` writes. It queries only catalog aliases it recognizes, + /// unless `--all`. It writes locally only, and never to a PDS. BackfillAccessions(AccessionArgs), } #[derive(Args)] pub struct IngestArgs { - /// Subject donor identifier (found by exact match, or created if absent). Mutually exclusive - /// with --external-id; one of the two is required. + /// Subject donor identifier. An exact match finds it, and this makes it when there is none. It + /// excludes --external-id, and one of the two is mandatory. #[arg(long, short, required_unless_present = "external_id")] subject: Option, - /// Resolve the subject by a vendor id `(--id-source, ID)` instead of a donor identifier — e.g. - /// an FTDNA kit number. The subject must already exist (never created); pair with - /// --skip-unmatched to skip unknown ids quietly. + /// Resolve the subject by a vendor id `(--id-source, ID)`, and not by a donor identifier. An + /// FTDNA kit number is one example. The subject must already exist, and this never makes one. + /// Use it with --skip-unmatched to step over an unknown id with no message. #[arg(long, conflicts_with = "subject")] external_id: Option, /// Vendor source for --external-id (default FTDNA). #[arg(long, default_value = navigator_domain::identity::IdSource::FTDNA)] id_source: String, - /// With --external-id: if no subject matches the id, skip quietly (exit 0) instead of erroring. + /// With --external-id: when no subject matches the id, step over it with no message (exit 0), + /// and do not raise an error. #[arg(long)] skip_unmatched: bool, - /// Force the sequencing-run test type for alignment files (e.g. "Big Y") instead of inferring - /// it. Useful for bulk imports where the directory layout names the test; CRAMs have no `.bai` - /// for the coverage-shape detector, so they otherwise fall back to WGS. Ignored for non-BAM/CRAM. + /// Force the test type of the sequencing run for alignment files (for example "Big Y"), instead + /// of an inference. It helps a bulk import where the directory layout names the test. A CRAM has + /// no `.bai` for the coverage-shape detector, so it would otherwise fall back to WGS. This does + /// nothing for a file that is not a BAM or a CRAM. #[arg(long)] test_type: Option, /// Optional project name to assign the subject to (found or created). #[arg(long, short)] project: Option, - /// Sex recorded only when the subject is created (e.g. male / female). + /// The sex, which the store keeps only when this makes the subject (male or female). #[arg(long)] sex: Option, /// Workspace database path (defaults to the GUI's ~/.decodingus/navigator-rs.db). #[arg(long)] db: Option, - /// Files and/or directories to ingest. A directory is one staged sample (sidecar fast path); - /// a file is imported on its own. + /// The files and directories to ingest. A directory is one staged sample, and it takes the + /// sidecar fast path. A file comes in on its own. #[arg(required = true)] paths: Vec, } @@ -212,9 +231,9 @@ pub struct ProbeArgs { json: bool, } -/// `archaic` takes an optional alignment override so a specific build can be genotyped directly — -/// the app otherwise picks the subject's best-callable alignment, which makes the GRCh37/38 code -/// path unreachable on a subject that also has CHM13 data. +/// `archaic` takes an optional alignment override, so that a caller can genotype one specific build +/// directly. Without it the app picks the best-callable alignment of the subject. The GRCh37 and +/// GRCh38 code path is then out of reach on a subject that also has CHM13 data. #[derive(Args)] pub struct ArchaicArgs { /// Subject donor identifier. @@ -247,8 +266,8 @@ pub struct ShowArgs { #[derive(Args)] pub struct DebugCallsArgs { - /// Subject donor identifier (used to pick an alignment when `--alignment` is omitted — prefers a - /// CHM13/HiFi alignment, else the first). + /// Subject donor identifier. It picks an alignment when `--alignment` is absent, and it prefers + /// a CHM13 or HiFi alignment, or the first one. #[arg(long, short)] subject: Option, /// Alignment id to genotype (from `show --json`). Takes precedence over `--subject`. @@ -261,17 +280,17 @@ pub struct DebugCallsArgs { #[derive(Args)] pub struct PrivateYArgs { - /// Subject donor identifier (used to pick an alignment when `--alignment` is omitted — prefers a - /// CHM13/HiFi alignment, else the first). + /// Subject donor identifier. It picks an alignment when `--alignment` is absent, and it prefers + /// a CHM13 or HiFi alignment, or the first one. #[arg(long, short)] subject: Option, /// Alignment id to genotype (from `show --json`). Takes precedence over `--subject`. #[arg(long, short)] alignment: Option, /// **Batch**: compute and persist the private-Y bucket for every alignment of every member of - /// this project, instead of reporting one. Private-Y is what a cohort view needs to tell a - /// shared unnamed variant from a lone one, and it is cached per alignment — so a project has to - /// be walked once before anything cross-subject can use it. + /// this project, and do not report one. A cohort view needs private-Y to tell a shared unnamed + /// variant from a lone one. The cache holds it against each alignment, so one walk of a project + /// must come before anything cross-subject can use it. #[arg(long, short)] project: Option, /// Recompute buckets that are already cached (default: skip them, so a batch is resumable). @@ -287,8 +306,8 @@ pub struct PublishOriginsArgs { /// Lineage to publish: `y` (default) or `mt`. #[arg(long, default_value = "y")] lineage: String, - /// Actually enqueue the records. Without it nothing leaves the workspace and the command only - /// reports what would — genealogy is not something to publish by accident. + /// Put the records in the queue. Without this flag nothing leaves the workspace, and the command + /// only reports what would. Nobody must publish genealogy by accident. #[arg(long)] apply: bool, /// Workspace database path (defaults to the GUI's ~/.decodingus/navigator-rs.db). @@ -298,15 +317,15 @@ pub struct PublishOriginsArgs { #[derive(Args)] pub struct BranchReportArgs { - /// Subject donor identifier (used to pick a Y/mt alignment when `--alignment` is omitted — - /// prefers a CHM13/HiFi alignment, else the first). + /// Subject donor identifier. It picks a Y or mt alignment when `--alignment` is absent, and it + /// prefers a CHM13 or HiFi alignment, or the first one. #[arg(long, short)] subject: Option, /// Alignment id to genotype (from `show --json`). Takes precedence over `--subject`. #[arg(long, short)] alignment: Option, - /// Node to report: a haplogroup name (`R-FGC29071`) or a defining marker (`FGC29071`). The - /// report covers this node's descendant subtree. + /// The node to report: a haplogroup name (`R-FGC29071`), or a marker that defines one + /// (`FGC29071`). The report covers the descendant subtree of this node. #[arg(long, short)] node: String, /// Which tree to read: `y` or `mt`. @@ -315,7 +334,7 @@ pub struct BranchReportArgs { /// Limit descent to this many levels below the node (default: the whole subtree). #[arg(long)] depth: Option, - /// Write the TSV here instead of printing a table. + /// Write the TSV here, and do not print a table. #[arg(long)] tsv: Option, /// Emit JSON instead of a table. Mutually exclusive with `--tsv`. @@ -328,10 +347,11 @@ pub struct BranchReportArgs { #[derive(Args)] pub struct CallArgs { - /// Subject donor identifier (used to resolve the alignment when `--alignment` is omitted). + /// Subject donor identifier. It resolves the alignment when `--alignment` is absent. #[arg(long, short)] subject: Option, - /// Alignment id to call (from `show --json`). If omitted, the subject's sole alignment is used. + /// The alignment id to call, from `show --json`. Without it, this takes the one alignment of + /// the subject. #[arg(long, short)] alignment: Option, /// Restrict to a single contig (e.g. chrM, chr21). Default: every primary chromosome. @@ -350,13 +370,13 @@ pub struct AnalyzeArgs { /// Alignment id to analyze (from `show --json`). #[arg(long, short)] alignment: i64, - /// Also build the autosomal consensus profile and estimate ancestry from it — the two heaviest - /// steps, which the GUI folds in only for the one-click Simple flow. + /// Also build the autosomal consensus profile, and estimate ancestry from it. Those are the two + /// heaviest steps, and the GUI takes them in only for the one-click Simple flow. #[arg(long)] ancestry: bool, - /// Also call structural variants (experimental). Off by default: SV walks every read in the - /// file for its own sake, measured at 2–5 h per whole-genome sample, and nothing else consumes - /// the result. The GUI's equivalent is the Sources tab's "Call SV" button. + /// Also call structural variants (experimental). It is off by default. SV walks every read in + /// the file for its own sake, at a measured 2–5 h for one whole-genome sample, and nothing else + /// reads the result. The equivalent in the GUI is the "Call SV" button on the Sources tab. #[arg(long)] sv: bool, /// Workspace database path (defaults to the GUI's ~/.decodingus/navigator-rs.db). @@ -366,32 +386,35 @@ pub struct AnalyzeArgs { #[derive(Args)] pub struct RebuildArgs { - /// Rebuild every subject's signatures, including those without one yet (default: only subjects - /// that already have a Y or mt profile — the ones a placement change leaves stale). + /// Build the signatures of every subject again, and take the ones that have none yet too. The + /// default is only a subject that already has a Y or mt profile, because a placement change + /// leaves those stale. #[arg(long)] all: bool, /// Restrict to subjects in this project (by exact name). #[arg(long, short)] project: Option, - /// Restrict to the subjects named in this file — one donor identifier or subject guid per line - /// (`#` comments and blanks ignored). Placement cost is dominated by subjects that own a - /// BAM/CRAM without cached genotypes, since those re-walk the alignment; a project filter can't - /// separate those from the cheap VCF-only subjects sharing the project, so scope by subject when - /// only some of them changed. + /// Take only the subjects this file names: one donor identifier, or one subject guid, on each + /// line. It drops a `#` comment and a blank line. The cost of a placement comes mostly from a + /// subject that owns a BAM or CRAM with no cached genotypes. Those walk the alignment a second + /// time. A project filter can not separate those from the low-cost VCF-only subjects + /// beside them. So scope by subject when only some of them changed. #[arg(long, value_name = "FILE")] subjects_file: Option, - /// Only subjects placed against a **different haplotree** than the one now active — the sweep to - /// run when a new tree lands. Combines with the other filters (all of them must pass), and - /// implies `--all`: a subject can be due a re-placement without yet having a profile built. + /// Only the subjects that a placement put against a **different haplotree** from the one now + /// active. This is the sweep to run when a new tree lands. It combines with the other filters, + /// and all of them must pass. It also implies `--all`, because a subject can be due a new + /// placement before any profile exists for it. #[arg(long)] stale_tree: bool, - /// With `--stale-tree`, also take subjects whose calls carry **no tree fingerprint** — placed - /// before the field existed, so which tree they used is unknowable. That is a provenance - /// backfill, not a response to a tree change, and it is far larger: most such subjects own a - /// BAM/CRAM that re-placement re-walks. Off by default so the routine sweep stays runnable. + /// With `--stale-tree`, also take a subject whose calls carry **no tree fingerprint**. A + /// placement made those before the field existed, so nobody can know which tree they used. That + /// is a provenance backfill, and not an answer to a tree change, and it is far larger. Most such + /// subjects own a BAM or CRAM that a new placement walks again. It is off by default, so that + /// the routine sweep stays practical. #[arg(long)] include_unknown: bool, - /// With `--stale-tree`, list the affected subjects and exit without re-placing anything. + /// With `--stale-tree`, list the subjects it affects, then exit, and place nothing again. #[arg(long)] dry_run: bool, /// Workspace database path (defaults to the GUI's ~/.decodingus/navigator-rs.db). @@ -411,14 +434,15 @@ pub struct ReingestArgs { #[derive(Args)] pub struct BackfillArgs { - /// Also read a bounded prefix of the alignment file to resolve `read_type` on runs the cheap - /// platform/test-type inference can't (generic-`WGS` PacBio: HiFi vs CLR). Touches the files. + /// Also read a bounded prefix of the alignment file. That resolves `read_type` on a run the + /// low-cost inference can not reach. The example is generic-`WGS` PacBio, where HiFi and CLR + /// both appear. This touches the files. #[arg(long)] rescan: bool, /// Restrict to subjects in this project (by exact name). #[arg(long, short)] project: Option, - /// Emit the per-field counts as JSON. + /// Emit the count of each field as JSON. #[arg(long)] json: bool, /// Workspace database path (defaults to the GUI's ~/.decodingus/navigator-rs.db). @@ -428,8 +452,8 @@ pub struct BackfillArgs { #[derive(Args)] pub struct AccessionArgs { - /// Actually attach the accessions and correct the local `sample_accession`. Without this it is a - /// dry run (queries the API read-only, writes nothing). + /// Attach the accessions, and correct the local `sample_accession`. Without this flag it is a + /// dry run: it queries the API read-only, and writes nothing. #[arg(long)] apply: bool, /// Query every subject, not just those whose name is a recognizable catalog alias (IGSR/HGDP). @@ -438,7 +462,7 @@ pub struct AccessionArgs { /// Restrict to subjects in this project (by exact name). #[arg(long, short)] project: Option, - /// Cap how many subjects are queried (for a bounded test run). + /// A cap on how many subjects this queries, for a bounded test run. #[arg(long)] limit: Option, /// Emit the outcome as JSON. @@ -451,8 +475,8 @@ pub struct AccessionArgs { #[derive(Args)] pub struct CatalogArgs { - /// Actually write the derived ids. Without this flag the command is a dry run (reports counts, - /// writes nothing). + /// Write the derived ids. Without this flag the command is a dry run: it reports counts, and + /// writes nothing. #[arg(long)] apply: bool, /// Restrict to subjects in this project (by exact name). @@ -478,8 +502,8 @@ pub struct LoginArgs { #[derive(Args)] pub struct PruneArgs { - /// Actually delete the orphans. Without this flag the command is a dry run (lists what it would - /// remove and touches nothing) — a PDS delete is irreversible, so it is opt-in. + /// Delete the orphans. Without this flag the command is a dry run: it lists what it would + /// remove, and touches nothing. Nobody can undo a PDS delete, so it is opt-in. #[arg(long)] apply: bool, /// Emit the outcome as JSON. @@ -504,7 +528,7 @@ pub struct LiftVcfArgs { /// Output VCF path (`.vcf` or `.vcf.gz`). #[arg(long, short)] out: PathBuf, - /// Drop variants landing in the target chrY PAR. + /// Drop a variant that lands in the target chrY PAR. #[arg(long)] filter_par: bool, /// Workspace database path (defaults to the GUI's ~/.decodingus/navigator-rs.db). @@ -512,12 +536,13 @@ pub struct LiftVcfArgs { db: Option, } -/// Run a CLI subcommand to completion, returning a process exit code. Spins its own tokio -/// runtime so `main` (which must keep the GUI on the main thread) stays sync. +/// Run a CLI subcommand to the end, and return a process exit code. It starts its own tokio +/// runtime, so that `main` stays sync, because `main` must keep the GUI on the main thread. pub fn run(command: Command) -> i32 { - // 64 MiB stacks: Y/mt tree parse + placement recurse to the haplotree depth, and noodles' CRAM - // decoder recurses on `spawn_blocking` decode paths (deepest on CRAM 3.1) — either overflows - // tokio's default 2 MiB stack and aborts the process (matches the GUI worker runtime). See + // 64 MiB stacks. The Y and mt tree parse, and the placement, both recurse to the depth of the + // haplotree. The CRAM decoder of noodles also recurses on the `spawn_blocking` decode paths, + // and it goes deepest on CRAM 3.1. Either one overflows the default 2 MiB stack of tokio, and + // aborts the process. This matches the worker runtime of the GUI. See // `NAVIGATOR_DECODE_STACK_MB`. let rt = match tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -627,7 +652,8 @@ async fn backfill_catalog_ids(args: CatalogArgs) -> i32 { 0 } -/// Sign in via OAuth (browser + loopback callback) and persist the session for later subcommands. +/// Sign in through OAuth, with a browser and a loopback callback, and persist the session for a +/// later subcommand. async fn login(args: LoginArgs) -> i32 { let app = cli_try!(open(args.db).await); eprintln!("Opening browser to sign in as {}…", args.handle); @@ -704,13 +730,13 @@ async fn backfill_profiles(args: BackfillArgs) -> i32 { 0 } -/// Rebuild the genome-consensus Y **and** mtDNA signatures for a set of subjects. Used to re-place -/// existing profiles after a placement/tree-provider change (e.g. the FTDNA→DecodingUs switch): the -/// batch analyzer only *creates* a signature when one is missing, so profiles built on an older tree -/// stay stale until rebuilt here. +/// Build the genome-consensus Y **and** mtDNA signatures again, for a set of subjects. Use it to +/// place existing profiles again after a change to the placement or the tree provider, for example +/// the FTDNA→DecodingUs switch. The batch analyzer only *makes* a signature when one is missing, so +/// a profile built on an older tree stays stale until this rebuilds it. async fn rebuild_signatures(args: RebuildArgs) -> i32 { use std::time::Instant; - // Reject the invalid combinations before opening the database or reading anything. + // Refuse an invalid combination before this opens the database, or reads anything. if (args.dry_run || args.include_unknown) && !args.stale_tree { eprintln!("error: --dry-run and --include-unknown only apply with --stale-tree"); return 2; @@ -721,13 +747,14 @@ async fn rebuild_signatures(args: RebuildArgs) -> i32 { let bios = cli_try!(app.list_all_biosamples().await); - // The staleness selector: which subjects were placed against a tree other than today's. Held as - // guids rather than folded into `wanted` because that set is matched against donor identifiers - // too, and a donor id that happened to look like a guid would cross-match. + // The staleness selector: which subjects a placement put against a tree other than the one + // today. It holds guids, and does not fold into `wanted`, because that set also matches against + // donor identifiers. A donor id that looked like a guid would then cross-match. let stale: Option> = if args.stale_tree { - // Two independent symptoms of the same thing, unioned: a *source call* stamped with another - // tree, and a *derived consensus* naming a branch this tree does not carry. The second can - // be true while every call beneath it is current, so neither selector subsumes the other. + // Two independent symptoms of the same thing, in a union. The first is a *source call* + // with another tree stamped on it. The second is a *derived consensus* that names a branch + // this tree does not carry. The second can be true while every call under it is current, so + // neither selector covers the other. let by_fingerprint = cli_try!(app.subjects_placed_against_another_tree(args.include_unknown).await); let off_tree = cli_try!(app.subjects_labelled_off_tree().await); let mut set: std::collections::HashSet = by_fingerprint.iter().copied().collect(); @@ -744,8 +771,8 @@ async fn rebuild_signatures(args: RebuildArgs) -> i32 { None }; - // Accepts either identifier so a caller can feed whichever it has to hand — a report keyed by - // guid, or a list of donor ids. + // This accepts either identifier, so that a caller can give whichever it has. That is a report + // with the guid as its key, or a list of donor ids. let wanted: Option> = match &args.subjects_file { Some(path) => match std::fs::read_to_string(path) { Ok(text) => Some( @@ -789,10 +816,10 @@ async fn rebuild_signatures(args: RebuildArgs) -> i32 { rebuilt += 1; continue; } - // Default: only refresh subjects that already carry a Y or mt profile (the stale ones). With - // --all, build for every subject (those with no evidence just yield an empty profile). - // --stale-tree already names exactly who is due, and a subject can be due without yet having - // a profile, so it selects on its own. + // The default refreshes only a subject that already carries a Y or mt profile, which are + // the stale ones. With --all, it builds for every subject, and a subject with no evidence + // gives an empty profile. --stale-tree already names exactly who is due. A subject can be + // due before any profile exists, so it selects on its own. if !args.all && stale.is_none() { let has_y = matches!(app.cached_y_profile(b.guid).await, Ok(Some(_))); let has_mt = matches!(app.cached_mt_profile(b.guid).await, Ok(Some(_))); @@ -801,15 +828,17 @@ async fn rebuild_signatures(args: RebuildArgs) -> i32 { continue; } } - // Re-place the per-alignment calls *and* rebuild the signatures built from them — the same - // `replace_against_current_tree` the GUI chore runs, so the two surfaces can not drift. - // Rebuilding only the profiles (what this did) left every `haplogroup_call` row on the tree - // it was placed against, which is both the "sources diverge" conflicts on the Y card and the - // reason `--stale-tree` re-selected the same subjects forever: it selects *by* those call - // fingerprints, so a subject it had just "re-placed" was still due on the next run. + // Place the calls of each alignment again, *and* build the signatures that come from them + // again. This is the same `replace_against_current_tree` that the GUI chore runs, so the two + // surfaces can not drift. // - // Still cheap for an already-analyzed subject: each call is fingerprint-guarded, so an - // unchanged file and tree cost a comparison rather than a walk. + // A rebuild of the profiles alone, which is what this did, left every `haplogroup_call` row + // on the tree that placed it. That is both the "sources diverge" conflicts on the Y card, + // and the reason `--stale-tree` selected the same subjects for ever. It selects *by* those + // call fingerprints, so a subject it had just placed again was still due on the next run. + // + // The cost is still low for a subject an analysis already covered. A fingerprint guards each + // call, so an unchanged file and tree cost a comparison, and not a walk. let t = Instant::now(); match app.replace_against_current_tree(b.guid).await { Err(e) => { @@ -913,8 +942,8 @@ async fn compare_callers(args: ShowArgs) -> i32 { for c in &cmps { let ext = c.external.as_deref().unwrap_or("(none)"); let nav = c.navigator.as_deref().unwrap_or("(none)"); - // Only a real disagreement (both present, different) is flagged — a missing side - // is just "the other caller did not produce a call here". + // Only a real disagreement gets a flag, which means both are there and they + // differ. A missing side is only "the other caller made no call here". let differ = c.external.is_some() && c.navigator.is_some() && !c.agree(); if differ { diverged += 1; @@ -935,15 +964,17 @@ async fn compare_callers(args: ShowArgs) -> i32 { 0 } -/// Time the per-alignment analysis steps (the GUI Full Analysis path) to profile where time goes. +/// Time the analysis steps of each alignment (the Full Analysis path of the GUI), to profile where +/// the time goes. async fn analyze(args: AnalyzeArgs) -> i32 { use std::time::Instant; let app = cli_try!(open(args.db).await); let id = args.alignment; - // The step list comes from `App::plan_full_analysis` — the same one the GUI's Full Analysis - // uses, so the two can not drift again. In particular this is what stops a `navigator analyze` - // from re-genotyping Y over a trusted external call the user asked to prefer. + // The step list comes from `App::plan_full_analysis`, which is the same one the Full Analysis + // of the GUI uses, so the two can not drift again. Above all, this is what stops a + // `navigator analyze` from genotyping Y again over a trusted external call the user asked to + // prefer. let mut steps = match app.plan_full_analysis(id, args.ancestry, args.sv, None).await { Ok(s) => s, Err(e) => { @@ -965,8 +996,9 @@ async fn analyze(args: AnalyzeArgs) -> i32 { let n = step_no; let total = steps.len(); let t = Instant::now(); - // Each arm renders its own one-line summary; `Err` is reported and the run continues, since - // a failed step (e.g. SV below the depth threshold) does not invalidate the rest. + // Each arm draws its own one-line summary. This reports an `Err` and the run continues, + // because a step that failed, for example SV below the depth threshold, does not invalidate + // the rest. let outcome: Result = match &step { AnalysisStep::QualityMetrics => match app.run_unified_metrics(id).await { Ok(r) => { @@ -1022,7 +1054,7 @@ async fn analyze(args: AnalyzeArgs) -> i32 { .map(|p| format!("{} site(s)", p.variants.len())), AnalysisStep::Ancestry { biosample_guid } => { app.estimate_ancestry_from_consensus(*biosample_guid).await.map(|r| { - // The top super-population is the headline the brief shows. + // The top super-population is the main line the brief shows. r.super_population_summary .first() .map(|p| format!("{} {:.0}%", p.super_population, p.percentage)) @@ -1058,9 +1090,9 @@ async fn open(db: Option) -> Result { }) } -/// Resolve an optional `--project NAME` filter to its id. `Ok(None)` means "no filter"; `Err(code)` -/// means the name matched nothing — the message is already printed, so the caller just returns the -/// code as its exit status. +/// Resolve an optional `--project NAME` filter to its id. `Ok(None)` means "no filter". `Err(code)` +/// means the name matched nothing, and the message is already on the screen, so the caller returns +/// the code as its exit status. async fn resolve_project_filter(app: &App, name: Option<&String>) -> Result, i32> { let Some(name) = name else { return Ok(None) }; let overview = app.project_overview().await.unwrap_or_default(); @@ -1073,15 +1105,15 @@ async fn resolve_project_filter(app: &App, name: Option<&String>) -> Result Result, i32> { let all = app.list_all_biosamples().await.map_err(report)?; Ok(all.into_iter().find(|b| b.donor_identifier == donor).map(|b| b.guid)) } -/// The subject with this exact donor identifier — the opening move of every `--subject` command. -/// A missing subject is a plain user error, so the message is printed here and the caller just +/// The subject with this exact donor identifier: the first move of every `--subject` command. A +/// missing subject is a plain user error, so this prints the message, and the caller only /// propagates the code. async fn require_subject(app: &App, donor: &str) -> Result { match find_subject(app, donor).await? { @@ -1110,9 +1142,9 @@ async fn find_or_create_project(app: &App, name: &str) -> Result { Ok(p.id) } -/// Print an [`App`] error and yield the failure exit code. Kept as a free function for the -/// `.map_err(report)?` sites inside the `Result`-returning helpers; steps in a command body use -/// [`cli_try!`] instead. +/// Print an [`App`] error, and give back the failure exit code. It stays a free function for the +/// `.map_err(report)?` sites inside the helpers that return a `Result`. A step in a command body +/// uses [`cli_try!`] instead. fn report(e: navigator_app::AppError) -> i32 { e.exit_code() } @@ -1147,7 +1179,7 @@ async fn ingest(args: IngestArgs) -> i32 { Err(e) => return report(e), } } else { - // --subject is required-unless-present(external_id), so it is Some here. + // clap marks --subject required-unless-present(external_id), so it holds a value here. let subject = args.subject.clone().unwrap_or_default(); match find_subject(&app, &subject).await { Ok(Some(g)) => (g, subject), @@ -1172,11 +1204,14 @@ async fn ingest(args: IngestArgs) -> i32 { } } - // Partition the top-level inputs. A **directory** is one staged sample: it takes the sidecar - // fast path (Y/mt haplogroup from the GVCF + sex/read-metrics/coverage from text sidecars, no - // CRAM decode) via `add_sample_dir`, which groups the sidecars to their alignment — something - // per-file `add_data` can't do (and it would mis-route a `*.g.vcf.gz` through the plain-VCF - // reader). Individual **files** keep the per-file detect-and-import path. + // Split the top-level inputs. A **directory** is one staged sample. It takes the sidecar fast + // path through `add_sample_dir`, with no CRAM decode. The Y and mt haplogroup come from the + // GVCF. The sex, the read metrics and the coverage come from text sidecars. + // + // That function groups the sidecars to their alignment, and `add_data` on one file at a time can + // not. `add_data` would also send a `*.g.vcf.gz` through the plain-VCF reader. An individual + // **file** keeps the path + // that detects and imports one file. let mut sample_dirs: Vec = Vec::new(); let mut files: Vec = Vec::new(); for p in &args.paths { @@ -1243,7 +1278,7 @@ async fn ingest(args: IngestArgs) -> i32 { } } - // Files: per-file detect + import. + // Files: detect and import one file at a time. for path in &files { match app.add_data_with_test_type(guid, path, args.test_type.as_deref()).await { Ok(detected) => { @@ -1264,8 +1299,8 @@ async fn ingest(args: IngestArgs) -> i32 { } println!("\ningested {ok} item(s), {failed} failed, into subject \"{label}\""); - // A Y-SNP panel (BISDNA) was imported — place a Y haplogroup from its derived calls and - // report the terminal (the call is recorded for the donor consensus). + // An import brought in a Y-SNP panel (BISDNA). Place a Y haplogroup from its derived calls, + // and report the terminal. The store keeps the call for the donor consensus. if ysnp_panels > 0 { match app.assign_y_bisdna(guid, None).await { Ok(a) => match a.ranked.first() { @@ -1279,8 +1314,9 @@ async fn ingest(args: IngestArgs) -> i32 { } } - // FTDNA Big Y CSV variant report(s) imported — the importer placed Y from the named (on-tree) - // calls; report the donor's reconciled Y terminal so the admin sees it land. + // An import brought in one or more FTDNA Big Y CSV variant reports. The importer placed Y from + // the named calls, which are on the tree. Report the reconciled Y terminal of the donor, so + // that the admin sees it land. if ftdna_csv > 0 { match app.haplogroup_consensus(guid, DnaType::Y).await { Ok(Some(c)) => println!("Y-DNA (FTDNA CSV): {}", c.haplogroup), @@ -1429,8 +1465,8 @@ async fn debug_calls(args: DebugCallsArgs) -> i32 { } } -/// Compute + persist private-Y for every alignment in a project. One process, so the multi-MB -/// haplotree is fetched and parsed once rather than per subject. +/// Compute and persist private-Y for every alignment in a project. It is one process, so it reads +/// and parses the multi-MB haplotree one time, and not one time for each subject. async fn private_y_batch(app: &App, project: &str, force: bool) -> i32 { let Ok(Some(pid)) = resolve_project_filter(app, Some(&project.to_string())).await else { return 1; @@ -1442,9 +1478,9 @@ async fn private_y_batch(app: &App, project: &str, force: bool) -> i32 { for (i, b) in members.iter().enumerate() { let alns = app.list_alignments_for_biosample(b.guid).await.unwrap_or_default(); if alns.is_empty() { - // No alignment — but a vendor Y-VCF carries the same evidence, and until the VCF-backed - // engine existed these subjects (the large majority of a Y project) had no private-Y at - // all. Classify their call sets instead. + // No alignment. But a vendor Y-VCF carries the same evidence, and until the engine + // over VCFs existed, these subjects had no private-Y at all. They are the large + // majority of a Y project. Classify their call sets instead. let sets = app.list_variant_sets(b.guid).await.unwrap_or_default(); let mut any = false; for set in sets.iter().filter(|s| s.source_type != navigator_app::SourceType::Chip) { @@ -1475,8 +1511,9 @@ async fn private_y_batch(app: &App, project: &str, force: bool) -> i32 { continue; } for a in &alns { - // A row whose file is gone (e.g. a superseded vendor download) is not a computation - // failure — reporting it as one buries the real errors and sets a misleading exit code. + // A row whose file is gone, for example a vendor download that something replaced, is + // not a failure of the computation. To report it as one hides the real errors, and sets + // an exit code that misleads. if !a.bam_path.as_deref().is_some_and(|p| std::path::Path::new(p).exists()) { missing += 1; continue; @@ -1522,10 +1559,10 @@ async fn private_y_batch(app: &App, project: &str, force: bool) -> i32 { /// Publish ancestral origins for the subjects the consent predicate allows. /// -/// Two gates stand between an MDKA row and the wire, and this command reports both: the consent -/// predicate (the workspace holds the subject's primary data, and the tester has not opted out of -/// public sharing) decides `considered`; the field gates (surname only, birth year at or before -/// 1900, country-only without one, coarsened coordinates) decide `refused`. +/// Two gates stand between an MDKA row and the wire, and this command reports both. The consent +/// predicate decides `considered`: the workspace holds the primary data of the subject, and the +/// tester has not opted out of the public feed. The field gates decide `refused`: surname only, a +/// birth year at or before 1900, country alone when there is no year, and coarse coordinates. async fn publish_origins(args: PublishOriginsArgs) -> i32 { let lineage = match args.lineage.to_lowercase().as_str() { "y" => navigator_app::Lineage::Y, @@ -1626,7 +1663,7 @@ async fn private_y(args: PrivateYArgs) -> i32 { if let Some(warn) = bucket.qc_banner() { println!(" {warn}"); } - // Per-variant detail (pos class region depth altDepth af publishable) for diagnosis. + // The detail of each variant (pos class region depth altDepth af publishable), for diagnosis. println!(" pos\tclass\tregion\tdepth\talt\taf\tpublish"); for v in &bucket.variants { let class = match &v.class { @@ -1648,7 +1685,8 @@ async fn private_y(args: PrivateYArgs) -> i32 { 0 } -/// Per-marker branch report over a Y/mtDNA node's descendant subtree — table / `--tsv` / `--json`. +/// A branch report for each marker, over the descendant subtree of a Y or mtDNA node. It gives a +/// table, `--tsv`, or `--json`. async fn branch_report(args: BranchReportArgs) -> i32 { let app = cli_try!(open(args.db).await); let dna = match args.tree.to_ascii_lowercase().as_str() { @@ -1667,7 +1705,7 @@ async fn branch_report(args: BranchReportArgs) -> i32 { return 2; }; let guid = cli_try!(require_subject(&app, subject).await); - // Y and mt want different alignments — a Big-Y run carries no chrM reads. + // Y and mt want different alignments, because a Big-Y run carries no chrM reads. match app.pick_alignment_for(guid, dna).await { Ok(Some(id)) => id, Ok(None) => { @@ -1773,7 +1811,7 @@ async fn branch_report(args: BranchReportArgs) -> i32 { 0 } -/// Deep-ancestry stability report — see [`navigator_app::App::ancient_ancestry_stability`]. +/// Deep-ancestry stability report. See [`navigator_app::App::ancient_ancestry_stability`]. async fn debug_ancient(args: ShowArgs) -> i32 { let app = cli_try!(open(args.db).await); let guid = cli_try!(require_subject(&app, &args.subject).await); @@ -1810,7 +1848,7 @@ async fn debug_ancient(args: ShowArgs) -> i32 { 0 } -/// Tier B archaic segments — see [`navigator_app::App::call_archaic_segments_for_subject`]. +/// Tier B archaic segments. See [`navigator_app::App::call_archaic_segments_for_subject`]. async fn archaic_segments(args: ShowArgs) -> i32 { let app = cli_try!(open(args.db).await); let guid = cli_try!(require_subject(&app, &args.subject).await); @@ -1829,7 +1867,7 @@ async fn archaic_segments(args: ShowArgs) -> i32 { 0 } -/// Archaic (Neanderthal / Denisovan) Tier-A marker count — see +/// Archaic (Neanderthal, Denisovan) Tier-A marker count. See /// [`navigator_app::App::estimate_archaic_from_consensus`]. async fn archaic(args: ArchaicArgs) -> i32 { let app = cli_try!(open(args.db).await); @@ -1867,7 +1905,7 @@ async fn archaic(args: ArchaicArgs) -> i32 { 0 } -/// Deep (ancient) ancestry via qpAdm — see [`navigator_app::App::estimate_deep_ancestry`]. +/// Deep (ancient) ancestry through qpAdm. See [`navigator_app::App::estimate_deep_ancestry`]. async fn deep_ancestry(args: ShowArgs) -> i32 { let app = cli_try!(open(args.db).await); let guid = cli_try!(require_subject(&app, &args.subject).await); @@ -1898,11 +1936,12 @@ async fn deep_ancestry(args: ShowArgs) -> i32 { } } -/// Panel batch-process mode — see [`navigator_app::App::genotype_panel_for_alignment`]. +/// Panel batch-process mode. See [`navigator_app::App::genotype_panel_for_alignment`]. async fn genotype_panel(args: ShowArgs) -> i32 { let app = cli_try!(open(args.db).await); let guid = cli_try!(require_subject(&app, &args.subject).await); - // Best-callable alignment + chips/VCFs (which fold in during the refresh) — one decode per subject. + // The best-callable alignment, plus the chips and VCFs, which come in during the refresh. One + // decode for each subject. match app.genotype_panel_for_subject(guid).await { Ok(Some((aln, sites))) => { println!("panel-genotyped best alignment #{aln} ({sites} sites); autosomal consensus refreshed."); @@ -2005,16 +2044,19 @@ async fn show(args: ShowArgs) -> i32 { 0 } -/// Resolve the alignment id to call: the explicit `--alignment`, else the subject's sole alignment. +/// Resolve the alignment id to call: the explicit `--alignment`, or the one alignment of the +/// subject. #[derive(Args)] pub struct DoctorArgs { /// Alignment id to diagnose. #[arg(long)] alignment: Option, - /// Subject donor identifier — used when `--alignment` is omitted and the subject has exactly one. + /// Subject donor identifier. It applies when `--alignment` is absent and the subject has + /// exactly one alignment. #[arg(long, short)] subject: Option, - /// Diagnose a BAM/CRAM path directly, bypassing the workspace (for a file that was never imported). + /// Diagnose a BAM or CRAM path directly, with no workspace, for a file that no import brought + /// in. #[arg(long)] file: Option, /// Reference FASTA to pair with `--file`. Required to decode a CRAM; ignored for a BAM. @@ -2031,9 +2073,9 @@ pub struct DoctorArgs { /// Run the alignment preflight and print it. Exits 1 when a check failed, so this is usable as a /// gate in a script and not just by eye. /// -/// `--file` deliberately skips opening the workspace: the file being undiagnosable is often *why* -/// the user can not import it, so requiring a workspace record first would make the diagnostic -/// unavailable in the case it exists for. +/// `--file` does not open the workspace, and that is deliberate. A file nobody can diagnose is +/// often *why* the user can not import it. To ask for a workspace record first would make the +/// diagnostic absent in exactly the case it exists for. async fn doctor(args: DoctorArgs) -> i32 { let diagnosis = if let Some(file) = args.file { let reference = args.reference; diff --git a/crates/navigator-ui/src/i18n.rs b/crates/navigator-ui/src/i18n.rs index b8123ede..12a7e113 100644 --- a/crates/navigator-ui/src/i18n.rs +++ b/crates/navigator-ui/src/i18n.rs @@ -1,8 +1,9 @@ -//! The UI's view of the shared i18n catalog, which lives in `navigator-domain` so that the layers -//! below the UI — the Subject Brief's prose, the HTML report export — can localize too. See -//! [`navigator_domain::i18n`]. Re-exported rather than wrapped so `crate::i18n::tr` and -//! `NavigatorApp::tr` keep working unchanged. +//! The view the UI has of the shared i18n catalog. That catalog lives in `navigator-domain`, so +//! that the layers below the UI can localize too. Those layers are the prose of the Subject Brief, +//! and the HTML report export. See [`navigator_domain::i18n`]. This re-exports the catalog, and does not wrap +//! it, so that `crate::i18n::tr` and `NavigatorApp::tr` do not change. -// `tr_fmt` (positional interpolation) is not re-exported: no UI string needs arguments yet, -// and an unused re-export is dead code in a binary crate. Add it here when one does. +// This does not re-export `tr_fmt` (positional interpolation). No UI string needs arguments yet, +// and a re-export that nothing uses is code with no purpose in a binary crate. Add it here when a +// string does need arguments. pub use navigator_domain::i18n::{load_lang, save_lang, tr, Lang}; diff --git a/crates/navigator-ui/src/main.rs b/crates/navigator-ui/src/main.rs index ee599cf7..715fe696 100644 --- a/crates/navigator-ui/src/main.rs +++ b/crates/navigator-ui/src/main.rs @@ -24,10 +24,11 @@ pub(crate) fn default_db_path() -> PathBuf { } fn main() -> eframe::Result<()> { - // Opt this process into the OS keychain — sessions and device keys must survive a restart. - // This is the *only* place it may be called: everything else (tests, CI, examples) keeps the - // in-memory default and so can never read or write the user's real credentials. Must run - // before any `App` is built, since `App::new` reloads the active account. + // Opt this process into the OS keychain, because sessions and device keys must survive a + // restart. This is the *only* place that may call it. Everything else (tests, CI, examples) + // keeps the in-memory default, and so can never read or write the real credentials of the + // user. This call must come before anything builds an `App`, because `App::new` loads the + // active account again. navigator_app::use_os_keychain(); // With a subcommand, run headless (ingest/probe) and exit; with none, launch the GUI. @@ -36,11 +37,11 @@ fn main() -> eframe::Result<()> { // First-run setup: seed the bundled ancestry/IBD assets, chrY masks, and HipSTR reference BEDs // shipped inside the installer image into ~/.decodingus/ if missing. No-op on later runs. // - // Headless seeds synchronously — the analysis starts at once, and there is no window whose - // appearance the copy could delay. The GUI seeds on a background thread instead, because the - // GRCh38 HipSTR BED alone is ~20 MB and that copy would otherwise sit in front of the first - // frame on exactly the run a new user is watching. `App::open` (on the worker thread) waits for - // it, so nothing can read a half-seeded cache. + // The headless path seeds in sequence. The analysis starts at once, and there is no window + // whose appearance the copy could delay. The GUI seeds on a background thread instead. The + // GRCh38 HipSTR BED alone is ~20 MB. That copy would otherwise come before the first frame, on + // exactly the run that a new user looks at. `App::open`, on the worker thread, waits + // for it, so nothing can read a cache that is only half seeded. if let Some(command) = parsed.command { let seeded = navigator_app::seed_bundled_all(); if seeded.copied > 0 { @@ -51,9 +52,10 @@ fn main() -> eframe::Result<()> { navigator_app::spawn_bundled_seed(); let db_path = default_db_path(); - // Open at the remembered size (falling back to a comfortable default), with a sane floor. This is - // only the initial hint — the builder sizes before the UI scale is applied, so `NavigatorApp` - // re-asserts the remembered size and fits it to the current screen on its first frames. + // Open at the size the app remembers, with a comfortable default if there is none, and a sane + // floor. This is only the first hint, because the builder sets the size before the UI scale + // applies. So `NavigatorApp` asserts the remembered size again, and fits it to the current + // screen, on its first frames. let initial_size = navigator_app::AppSettings::load() .window_size .unwrap_or(ui::DEFAULT_WINDOW); diff --git a/crates/navigator-ui/src/ui/blocktree.rs b/crates/navigator-ui/src/ui/blocktree.rs index 1c1596bc..daba092d 100644 --- a/crates/navigator-ui/src/ui/blocktree.rs +++ b/crates/navigator-ui/src/ui/blocktree.rs @@ -1,28 +1,29 @@ -//! The project **block tree** view (`impl NavigatorApp`) — the cohort haplotree for the open -//! project, drawn the way Alex Williamson's Big Tree draws it (the presentation FTDNA's Block Tree -//! borrowed): **top-down**, depth increasing downward, and each block showing *its equivalent SNPs* -//! rather than a count of them. +//! The project **block tree** view (`impl NavigatorApp`): the cohort haplotree for the open +//! project. It draws the way the Big Tree of Alex Williamson draws it, and the FTDNA Block Tree +//! took that same presentation. It is **top-down**, with depth that grows downward. Each block +//! shows *its equivalent SNPs*, and not a count of them. //! -//! That last point is the whole idea. A block is the run of phylogenetically equivalent mutations on -//! a branch — the order within it is unknowable — so the SNP list **is** the block, and printing -//! "17 SNPs" withholds exactly what the view exists to show. The members move out to a roster beside -//! the tree, as the Big Tree puts them in a table below it: the diagram carries the phylogeny, the -//! roster carries the men. +//! That last point is the whole idea. A block is the run of phylogenetically equivalent mutations +//! on a branch, and nobody can know the order inside it. So the SNP list **is** the block, and a +//! printed "17 SNPs" withholds exactly what the view exists to show. The members move out to a +//! roster beside the tree, as the Big Tree puts them in a table below it. The diagram carries the +//! phylogeny, and the roster carries the men. //! -//! The aggregate is built off the UI thread (`App::project_block_tree`, see -//! `documents/design/project-block-tree.md`); this module only lays it out and paints it. +//! The aggregate builds off the UI thread (`App::project_block_tree`, see +//! `documents/design/project-block-tree.md`). This module only lays it out and paints it. //! -//! The backbone above the cohort is a **breadcrumb, not a block**. The Big Tree's subclade pages do -//! the same: `R-P312/S116 > Z46577 > Z290 > L21/S145 > … > CTS4466/S1136` runs as a path across the -//! top, and the diagram starts at the clade in view. Without that, a cohort whose induced root folds -//! a thousand-SNP backbone opens on one absurd box that is all of the canvas and none of the cohort. +//! The backbone above the cohort is a **breadcrumb, and not a block**. The subclade pages of the +//! Big Tree do the same: `R-P312/S116 > Z46577 > Z290 > L21/S145 > … > CTS4466/S1136` runs as a +//! path across the top, and the diagram starts at the clade in view. Without that, a cohort whose +//! induced root folds a backbone of a thousand SNPs opens on one absurd box. That box is all of the +//! canvas, and none of the cohort. //! //! Two performance rules, because a group project can hold thousands of members: //! -//! - **Layout is computed once per (tree, expansion, zoom)**, not rebuilt per frame. [`layout`] is a -//! pure function over `&[Block]`, so it is testable without a canvas. -//! - **Drawing is culled to `clip_rect`.** Only blocks actually on screen are painted, so a tree -//! with thousands of blocks costs the same per frame as one with a dozen. +//! - **Layout runs one time for each (tree, expansion, zoom)**, and not again on each frame. +//! [`layout`] is a pure function over `&[Block]`, so a test can check it with no canvas. +//! - **The draw culls to `clip_rect`.** It paints only the blocks on the screen. A tree with +//! thousands of blocks then costs the same on each frame as one with a dozen. use std::collections::HashMap; @@ -37,43 +38,45 @@ use super::*; const BOX_W: f32 = 84.0; const ROW_H: f32 = 12.0; // one line of SNP text inside a block const H_GAP: f32 = 6.0; // horizontal gap between sibling subtrees -/// Vertical gap between a block and its children — **zero**. In the Big Tree a parent block spans -/// the full width of its descendants and they sit flush against its underside, so *containment* -/// carries the parent/child relation and no connector is drawn between levels. A gap here would also -/// corrupt the vertical scale, which is meant to read as accumulated mutations and nothing else. +/// The vertical gap between a block and its children is **zero**. In the Big Tree a parent block +/// spans the full width of its descendants, and they sit flush against its underside. So +/// *containment* carries the parent-child relation, and the view draws no connector between levels. +/// A gap here would also corrupt the vertical scale, which must read as accumulated mutations and +/// nothing else. const V_GAP: f32 = 0.0; /// Stem length from the last block down to the band of biosample boxes. const STEM: f32 = 26.0; /// Ruler tick interval, in SNPs. const TICK_SNPS: usize = 5; -/// Width of the left gutter carrying the SNP ruler. +/// Width of the left gutter that holds the SNP ruler. const GUTTER_W: f32 = 30.0; const PAD: f32 = 4.0; /// A man's box, and the width of a private-variant block. Wide enough that "Private variants" sets /// on one line and a long kit name (`GMWOF5428705`) is not cropped. const MEMBER_W: f32 = 84.0; -// No SNP cap: a block's height **is** its elapsed time. Mutations accumulate at a roughly steady -// rate, so the number of phylogenetically equivalent SNPs on a branch is how long that branch ran -// unbroken — and eliding any of them shortens the box, which is to say it misreports the time. A -// line may still carry several *names* (synonyms for one mutation, as `BY30547 Y43043` is one SNP -// with two names); it never carries two mutations. +// No SNP cap: the height of a block **is** the time it covers. Mutations accumulate at an almost +// steady rate, so the count of phylogenetically equivalent SNPs on a branch is how long that branch +// ran unbroken. To leave any of them out shortens the box, and that misreports the time. One line +// can still carry more than one *name*, because a mutation can have synonyms, as `BY30547 Y43043` +// is one SNP with two names. It never carries two mutations. // -// This is affordable because the one pathological case is handled elsewhere: the backbone above the -// cohort is a breadcrumb, not a block (see `upstream_breadcrumb`). +// The cost is acceptable, because another place controls the one pathological case. The backbone +// above the cohort is a breadcrumb, and not a block (see `upstream_breadcrumb`). // Muted, close to the Big Tree's tan-on-parchment but keyed for a dark theme. const BLOCK_BG: egui::Color32 = egui::Color32::from_rgb(44, 46, 51); const BLOCK_BG_PLACED: egui::Color32 = egui::Color32::from_rgb(48, 61, 52); // carries members -/// A candidate branch reads as *provisional*: amber, not the green of a published branch. It is an -/// inference from shared private variants, and must never be mistaken for a named haplogroup. +/// A candidate branch reads as *provisional*: amber, and not the green of a published branch. It +/// comes from an inference over shared private variants, and nobody must read it as a named +/// haplogroup. const BLOCK_BG_CANDIDATE: egui::Color32 = egui::Color32::from_rgb(66, 57, 38); const CANDIDATE_STROKE: egui::Color32 = egui::Color32::from_rgb(190, 148, 70); const BLOCK_STROKE: egui::Color32 = egui::Color32::from_rgb(78, 84, 94); const SELECTED_STROKE: egui::Color32 = egui::Color32::from_rgb(120, 170, 220); const EDGE: egui::Color32 = egui::Color32::from_rgb(72, 78, 88); const SNP_FG: egui::Color32 = egui::Color32::from_rgb(176, 182, 192); -/// Men are grey against the tree's colour, as the Big Tree draws them — they are the evidence the -/// phylogeny is built from, not part of the phylogeny. +/// The men are grey against the colour of the tree, as the Big Tree draws them. They are the +/// evidence behind the phylogeny, and not a part of it. const MEMBER_BG: egui::Color32 = egui::Color32::from_rgb(58, 60, 66); const MEMBER_FG: egui::Color32 = egui::Color32::from_rgb(198, 202, 210); /// Private variants get their own colour because they are a different *kind* of claim: unnamed @@ -90,7 +93,7 @@ pub(crate) struct Placed { pub rect: egui::Rect, } -/// One laid-out man, hanging off the bottom of the block he is placed on. +/// One man in the layout. He hangs off the bottom of the block that holds him. #[derive(Debug, Clone, PartialEq)] pub(crate) struct PlacedMember { /// Index into the `blocks` slice. @@ -100,9 +103,9 @@ pub(crate) struct PlacedMember { pub rect: egui::Rect, } -/// Render a mean over a handful of men: whole numbers plain, otherwise one decimal. Rounding 4.5 to -/// 5 would hide that a branch sits half a mutation from its neighbour; printing `4.0` for an exact 4 -/// is just noise. +/// Draw a mean over a few men: a whole number plain, and anything else with one decimal. To round +/// 4.5 to 5 would hide that a branch sits half a mutation from its neighbour. To write `4.0` for an +/// exact 4 is only noise. fn fmt_average(v: f32) -> String { if (v - v.round()).abs() < 0.05 { format!("{}", v.round() as i64) @@ -113,13 +116,13 @@ fn fmt_average(v: f32) -> String { /// The private-variant block below a branch: the mutations its men carry that no branch names yet. /// -/// The mean is over the men whose terminal **is** this block — not its subtree. A branch that both -/// splits and holds men counts only the men standing on it, which is what FTDNA reports too -/// (`R-FGC29071` averages over 2 participants while 7 more sit on branches below it). +/// The mean is over the men whose terminal **is** this block, and not over its subtree. A branch +/// that both splits and holds men counts only the men on it. FTDNA reports it the same way: +/// `R-FGC29071` averages over 2 participants, while 7 more sit on branches below it. /// -/// It is drawn **on the same vertical scale as the blocks**, because it measures the same thing — -/// mutations accrued since the named branch above it, which is the time between that branch and the -/// present. That is what makes it belong in the diagram rather than in a tooltip: the ruler reads +/// The view draws it **on the same vertical scale as the blocks**, because it measures the same +/// thing. That is the mutations after the named branch above it, which is the time between that +/// branch and the present. So it belongs in the diagram, and not in a tooltip, and the ruler reads /// straight through it. #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct PlacedPrivate { @@ -127,11 +130,12 @@ pub(crate) struct PlacedPrivate { pub block: usize, /// Mean private-variant count across the men here that have one. pub average: f32, - /// How many men that mean is over — `private_novel` is `None` when never computed, which is not - /// zero, so an average over 2 of 30 men must not read as the branch's. + /// How many men that mean covers. `private_novel` is `None` when nothing computed it, and that + /// is not zero. So an average over 2 of 30 men must not read as the average of the branch. pub counted: usize, - /// Men dropped as implausible (see [`PRIVATE_Y_QC_WARN`]). Never silently: the block is marked - /// and the hover says how many, because "we excluded a third of this branch" is a finding. + /// Men that the code dropped as implausible (see [`PRIVATE_Y_QC_WARN`]). It never does that + /// with no message: the block carries a mark, and the hover says how many. "We left out a third + /// of this branch" is a result. pub suppressed: usize, pub rect: egui::Rect, } @@ -154,16 +158,18 @@ pub(crate) struct Layout { pub size: egui::Vec2, } -/// Graduations for the SNP ruler, walking the **deepest lineage** — the one that accrued the most -/// mutations, and so the one that reaches furthest down the canvas. +/// Graduations for the SNP ruler. It walks the **deepest lineage**, which is the one that took the +/// most mutations, and so the one that reaches furthest down the canvas. /// -/// The ticks are computed rather than spaced evenly, because evenly spaced would be wrong: each -/// block spends one row on its name, so a fixed pixels-per-SNP scale drifts by a row per generation. -/// Walking the lineage and placing each graduation inside the block that contains it keeps the axis -/// honest — the ticks come out *nearly* regular, and where they do not, the irregularity is real. +/// The code calculates the ticks, and does not space them evenly, because an even space would be +/// wrong. Each block spends one row on its name, so a fixed scale of pixels for each SNP drifts by +/// one row in each generation. A walk of the lineage, with each graduation inside the block that +/// holds it, keeps the axis honest. The ticks come out *almost* regular, and where they do not, the +/// irregularity is real. fn ruler_ticks(blocks: &[Block], placed: &[Placed], row_h: f32, pad: f32) -> Vec { - // Cumulative mutations to the bottom of each block, so "deepest" means most mutations, not most - // generations — a long slow branch outranks several short ones. + // Cumulative mutations down to the bottom of each block. So "deepest" means the most + // mutations, and not the most generations, and one long slow branch wins over some short + // ones. let index: HashMap = blocks.iter().enumerate().map(|(i, b)| (b.node_id, i)).collect(); let mut cum = vec![0usize; blocks.len()]; let mut best = (0usize, 0usize); // (mutations, block) @@ -189,7 +195,7 @@ fn ruler_ticks(blocks: &[Block], placed: &[Placed], row_h: f32, pad: f32) -> Vec let n = blocks[i].loci.len(); // The SNP rows start below the name row. let body_top = placed[i].rect.top() + pad + row_h; - // Every multiple of TICK_SNPS that falls inside this block's run of mutations. + // Every whole number of TICK_SNPS that falls inside the run of mutations of this block. let mut k = (seen / TICK_SNPS + 1) * TICK_SNPS; while k <= seen + n { ticks.push(Tick { @@ -203,49 +209,52 @@ fn ruler_ticks(blocks: &[Block], placed: &[Placed], row_h: f32, pad: f32) -> Vec ticks } -/// Lines a block's box needs: the branch name, one line per equivalent SNP, and the member count. +/// The lines the box of a block needs: the branch name, one line for each equivalent SNP, and the +/// member count. fn lines_for(b: &Block) -> usize { - // The name's row + one row per SNP. Every SNP, always — see the note above. The old member-count - // row is gone: the men are boxes in the band below, so counting them here was both redundant and - // a row of height that no mutation paid for. + // The row of the name, plus one row for each SNP. Every SNP, always: see the note above. The + // old member-count row is gone. The men are boxes in the band below, so a count here was + // redundant. It was also a row of height that no mutation paid for. 1 + b.loci.len() } -/// The folded backbone above the cohort, as a path rather than a box. +/// The folded backbone above the cohort, as a path and not as a box. /// -/// Returns `(path, snps)` when the induced root is a *collapsed run* — a chain of branches the -/// cohort descends through, folded into one block because no split within it separates any two -/// members. R1b-CTS4466Plus opens on `R-Z290`, which is 24 folded branches and 1,763 SNPs: 25 -/// branch-lengths of backbone that would be twelve times taller than the cohort hanging off it. +/// Returns `(path, snps)` when the induced root is a *collapsed run*. That is a chain of branches +/// the cohort descends through, folded into one block, because no split inside it separates any two +/// members. R1b-CTS4466Plus opens on `R-Z290`, which is 24 folded branches and 1,763 SNPs. That is +/// 25 branch-lengths of backbone, and it would be twelve times taller than the cohort below it. /// -/// The test is the *fold*, not whether men sit on it. A collapsed run is by construction more than -/// one branch, so its height is a sum across the tree above the cohort rather than one branch's -/// elapsed time — the one place where height-as-time does not hold. A root that was never collapsed -/// is a single genuine branch and stays in the canvas at full height like any other. +/// The test is the *fold*, and not whether men sit on it. A collapsed run is by construction more +/// than one branch. So its height is a sum across the tree above the cohort, and not the time of +/// one branch. It is the one place where height-as-time does not hold. A root that never collapsed +/// is one genuine branch, and it stays in the canvas at full height like any other. /// -/// Men parked on the backbone (shallow kits, typically) keep their roster: the breadcrumb selects -/// the block, so nothing is lost but the box. +/// Men parked on the backbone, usually shallow kits, keep their roster. The breadcrumb selects the +/// block, so nothing goes but the box. pub(crate) fn upstream_breadcrumb(blocks: &[Block]) -> Option<(String, usize)> { let root = blocks.iter().find(|b| b.parent.is_none())?; if root.collapsed.is_empty() { return None; } - // `collapsed` is root-most first and the surviving block keeps the deepest name, so appending it - // reads oldest → youngest, the direction the breadcrumb is travelled. + // `collapsed` has the root-most entry first, and the block that survives keeps the deepest + // name. So the name at the end reads oldest → youngest, which is the direction of the + // breadcrumb. let mut path = root.collapsed.clone(); path.push(root.name.clone()); Some((path.join(" › "), root.loci.len())) } /// Lay `blocks` (in pre-order, as [`ProjectBlockTree`] delivers them) onto a canvas: **depth → y**, -/// tidy-tree order → **x**, root at the top. A parent is centred over the horizontal extent of its -/// children, so a branch point sits above the lineages it splits into. +/// tidy-tree order → **x**, root at the top. A parent centres over the horizontal extent of its +/// children, so a branch point comes above the lineages it splits into. /// -/// A block hangs **directly beneath its parent**, not on a row shared with everything at its depth. -/// That makes vertical position cumulative: how far down a block sits is the mutations accumulated -/// along the path to it, so the y axis reads as elapsed time the same way a box's height does. -/// Aligning depths into rows would instead pad every short branch out to the tallest box beside it, -/// which is both a lot of empty canvas and a lie about when the branch happened. +/// A block hangs **directly under its parent**, and not on a row it shares with everything at its +/// depth. That makes the vertical position cumulative. How far down a block sits is the mutations +/// that accumulated along the path to it. So the y axis reads as elapsed time, the same way the +/// height of a box does. To put every depth on its own row would pad each short branch out to the +/// tallest box beside it. That is a lot of empty canvas, and it lies about when the branch +/// happened. /// /// Pure: no `Ui`, no state. Extents bottom-up, then positions top-down. pub(crate) fn layout(blocks: &[Block], zoom: f32) -> Layout { @@ -271,10 +280,12 @@ pub(crate) fn layout(blocks: &[Block], zoom: f32) -> Layout { let heights: Vec = blocks.iter().map(|b| lines_for(b) as f32 * row_h + 2.0 * pad).collect(); let mut member_slots: Vec<(usize, usize, f32)> = Vec::new(); - // Pass 1, bottom-up: the horizontal extent each subtree needs. `blocks` is pre-order, so - // iterating in reverse visits every child before its parent. - // A man occupies a slot beside his block's child subtrees: the Big Tree hangs him off the bottom - // of his terminal on a stem of his own, so he needs horizontal room like a subtree does. + // Pass 1, bottom-up: the horizontal extent each subtree needs. `blocks` is pre-order, so a walk + // in reverse visits every child before its parent. + // + // A man takes a slot beside the child subtrees of his block. The Big Tree hangs him off the + // bottom of his terminal, on a stem of his own. So he needs horizontal room, the same as a + // subtree does. let slots = |i: usize| children[i].len() + blocks[i].members.len(); let mut extent = vec![box_w; blocks.len()]; for i in (0..blocks.len()).rev() { @@ -298,11 +309,11 @@ pub(crate) fn layout(blocks: &[Block], zoom: f32) -> Layout { let mut deepest = 0.0f32; // Pre-order, so a parent's top is always settled before its children read it. let mut top = vec![0.0f32; blocks.len()]; - // Cumulative SNPs down to each block's top — the quantity the ruler measures. + // Cumulative SNPs down to the top of each block: the quantity the ruler measures. let mut snps_above = vec![0usize; blocks.len()]; for i in 0..blocks.len() { - // Icicle: the block spans its whole subtree. A parent therefore visibly *contains* the - // lineages it splits into, which is how the Big Tree shows descent — no elbow needed. + // Icicle: the block spans its whole subtree. So a parent visibly *contains* the lineages + // it splits into, which is how the Big Tree shows descent, and no elbow is necessary. let rect = egui::Rect::from_min_size(egui::pos2(left[i], top[i]), egui::vec2(extent[i], heights[i])); for &c in &children[i] { top[c] = rect.bottom() + v_gap; @@ -314,8 +325,9 @@ pub(crate) fn layout(blocks: &[Block], zoom: f32) -> Layout { let kids: f32 = children[i].iter().map(|&c| extent[c]).sum::(); let total = kids + blocks[i].members.len() as f32 * member_w + h_gap * (n - 1) as f32; let mut cx = left[i] + (extent[i] - total) / 2.0; - // Men take a slot to the left of the subclades, so a lineage that both splits and holds - // men makes room for both. Their boxes are positioned later, once the band is known. + // Men take a slot to the left of the subclades, so a lineage that both splits and + // holds men makes room for both. Their boxes take their positions later, after the + // code knows the band. for m in 0..blocks[i].members.len() { member_slots.push((i, m, cx)); cx += member_w + h_gap; @@ -329,16 +341,18 @@ pub(crate) fn layout(blocks: &[Block], zoom: f32) -> Layout { placed.push(Placed { idx: i, rect }); } - // Private variants, between a branch and its men — flush under the block, on the same scale, so - // the ruler measures straight through. The span is the men's, not the block's: these mutations - // belong to the men standing here, not to the subclades that branch off elsewhere under it. + // Private variants, between a branch and its men. They sit flush under the block, on the same + // scale, so the ruler measures straight through. The span is the span of the men, and not of + // the block. These mutations belong to the men here, and not to the subclades that branch off + // in another place under it. let mut privates: Vec = Vec::new(); for (i, b) in blocks.iter().enumerate() { - // A donor whose raw novel count trips the workspace's own plausibility threshold is dropped - // whole, not trimmed. `PRIVATE_Y_QC_WARN` already declares such a count "unusually high for - // one sample — check for contamination, low/uneven coverage, or a reference-build mismatch", - // which is a statement about the *sample*, so its gated count is not trustworthy either. One - // donor at 661 would otherwise set a branch's height single-handed. + // A donor whose raw novel count goes past the plausibility threshold of the workspace + // drops whole, and nothing trims it. `PRIVATE_Y_QC_WARN` already calls such a count + // "unusually high for one sample". It says to check for contamination, for low or uneven + // coverage, or for a mismatch of the reference build. That is a statement about the + // *sample*, so its gated count is not trustworthy either. One donor at 661 would otherwise + // set the height of a branch on its own. let plausible = |m: &&navigator_app::BlockMember| { !m.private_novel .is_some_and(|n| n >= navigator_domain::results_context::PRIVATE_Y_QC_WARN) @@ -365,10 +379,10 @@ pub(crate) fn layout(blocks: &[Block], zoom: f32) -> Layout { ) else { continue; }; - // **One column wide, always** — centred over the men it covers. The figure is a single - // branch-level statistic, so sizing the box to the number of men would imply it is a - // per-man quantity, and would make an identical average look different on two branches for - // no reason but headcount. + // **One column wide, always**, centred over the men it covers. The figure is one statistic + // at branch level. A box that grew with the number of men would suggest a quantity for + // each man. It would also make one average look different on two branches, for no reason + // but the headcount. let h = (average * row_h).max(row_h) + 2.0 * pad; let rect = egui::Rect::from_min_size( egui::pos2((lo + hi) / 2.0, placed[i].rect.bottom()), @@ -384,10 +398,10 @@ pub(crate) fn layout(blocks: &[Block], zoom: f32) -> Layout { }); } - // The men sit in one band beneath the whole diagram, as the Big Tree tables them below it, - // reached by a stem from their block. Sharing a baseline is what makes them scannable: hung from - // their own blocks they would step down the page in lockstep with the phylogeny, which says - // nothing about the men. + // The men sit in one band under the whole diagram, as the Big Tree tables them below it. A stem + // from their block reaches them. One shared baseline is what makes them easy to read. Hung + // from their own blocks, they would step down the page in lockstep with the phylogeny, and that + // says nothing about the men. let band = deepest + stem; let members: Vec = member_slots .into_iter() @@ -415,8 +429,8 @@ impl NavigatorApp { pub(crate) fn project_blocktree_section(&mut self, ui: &mut egui::Ui) { let Some(pid) = self.selected_project else { return }; - // Lazy load — the aggregate fetches and parses a multi-MB haplotree, so it is not built on - // project select like the STR chart is. + // A lazy load. The aggregate reads and parses a multi-MB haplotree, so a project select + // does not build it, as it builds the STR chart. if self.project_blocktree.is_none() && !self.project_blocktree_loading { self.project_blocktree_loading = true; let _ = self.tx.send(Command::LoadProjectBlockTree(pid)); @@ -436,8 +450,8 @@ impl NavigatorApp { return; }; - // Summary line: how much of the project the tree actually accounts for. `unplaced` is shown - // even when zero-length is impossible — a cohort with skew must not look complete. + // Summary line: how much of the project the tree covers. It shows `unplaced` even when a + // length of zero is impossible, because a cohort with skew must not look complete. let placed: usize = tree.blocks.iter().map(|b| b.members.len()).sum(); let unplaced = tree.unplaced.len(); let summary = format!( @@ -448,8 +462,8 @@ impl NavigatorApp { tree.blocks.len(), self.tr("blocktree.summary.blocks"), ); - // The coordinate space matters: node names are build-independent, the SNP positions are not, - // so the view says which tree and which build it is showing. + // The coordinate space matters. Node names are independent of the build, and the SNP + // positions are not, so the view says which tree and which build it draws. let coords = if tree.build_key.is_empty() { tree.provider.clone() } else { @@ -462,8 +476,8 @@ impl NavigatorApp { self.tr("blocktree.unplaced.hint") ) }); - // Candidate branches are the thing a published tree can't tell you, so they get their own - // line rather than being left for the user to notice in the canvas. + // A candidate branch is the thing a published tree can not tell you, so it gets its own + // line. The user does not have to see it in the canvas. let candidates = tree.blocks.iter().filter(|b| b.candidate).count(); let candidate_msg = (candidates > 0).then(|| { let mut s = format!("{candidates} {}", self.tr("blocktree.candidates")); @@ -483,8 +497,8 @@ impl NavigatorApp { } s }); - // Every label is resolved before the closure: `self.tr` borrows `self`, and the zoom slider - // needs `&mut` — the two can't coexist inside one closure. + // Take every label before the closure. `self.tr` borrows `self`, and the zoom slider needs + // `&mut`, and the two can not live inside one closure. let roster_empty = self.tr("blocktree.roster.empty").to_string(); let upstream_snps = self.tr("blocktree.upstream.snps").to_string(); let upstream_hint = self.tr("blocktree.upstream.hint").to_string(); @@ -527,8 +541,8 @@ impl NavigatorApp { self.blocktree_recentre = false; return; } - // The backbone above the cohort becomes a path across the top; the canvas draws what is - // left, which is the cohort itself. + // The backbone above the cohort becomes a path across the top. The canvas draws the + // remainder, which is the cohort itself. let upstream = upstream_breadcrumb(&tree.blocks); let drawn: Vec = if upstream.is_some() { let root = tree.blocks.iter().find(|b| b.parent.is_none()).map(|b| b.node_id); @@ -550,8 +564,8 @@ impl NavigatorApp { let mut select_upstream = false; if let Some((path, snps)) = &upstream { // The lineage the cohort descends through, and how many mutations sit on it. It is a - // path rather than a block because its height would dwarf everything the cohort is. - // Clickable, so the men parked on the backbone still reach the roster. + // path, and not a block, because its height would be far larger than the whole cohort. + // The user can click it, so the men parked on the backbone still reach the roster. ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; select_upstream |= ui @@ -567,8 +581,8 @@ impl NavigatorApp { } ui.add_space(4.0); - // Roster first, from the right, so the tree takes whatever is left — the Big Tree keeps the - // men in a table rather than in the diagram, and the diagram needs the room. + // The roster comes first, from the right, so the tree takes the remainder. The Big Tree + // keeps the men in a table, and not in the diagram, and the diagram needs the room. let roster = self .blocktree_selected .and_then(|id| tree.blocks.iter().find(|b| b.node_id == id)) @@ -613,8 +627,8 @@ impl NavigatorApp { rows.len(), |ui, range| { for (name, novel) in &rows[range] { - // `None` means private-Y was never computed — not the same as zero, - // so it shows nothing rather than "(0)". + // `None` means nothing computed private-Y. That is not the same as + // zero, so this shows nothing, and not "(0)". let text = match novel { Some(n) if *n > 0 => format!("{name} ({n})"), _ => name.clone(), @@ -627,7 +641,7 @@ impl NavigatorApp { } // Centre the first view on the root. The canvas is far wider than any viewport, and its - // left edge is empty space belonging to subtrees that hang further down. + // left edge is empty space that belongs to subtrees further down. let root_x = lay .placed .iter() @@ -649,10 +663,10 @@ impl NavigatorApp { let font = egui::FontId::proportional(11.0 * zoom); let small = egui::FontId::proportional(9.5 * zoom); - // The SNP ruler, in the left gutter: the scale that makes a block's height readable as a - // quantity rather than an impression. Graduated in mutations accumulated from the top of - // this view — not from the root of the tree, which is above the cohort and in the - // breadcrumb. + // The SNP ruler, in the left gutter. It is the scale that makes the height of a block + // a quantity, and not an impression. Its graduations are the mutations that + // accumulated from the top of this view. They do not start at the root of the tree, + // which is above the cohort and in the breadcrumb. { let g = egui::Rect::from_min_size( egui::pos2(canvas.left(), canvas.top()), @@ -682,11 +696,12 @@ impl NavigatorApp { let rect = p.rect.translate(origin); let b = &drawn[p.idx]; - // No connector to the parent: the block sits flush under it and inside its span, so - // containment shows the descent. An elbow here would be drawing what the geometry + // No connector to the parent. The block sits flush under it, and inside its span, + // so containment shows the descent. An elbow here would draw what the geometry // already says. - // Cull: everything below is per-block text layout, the expensive part. + // Cull here: everything below is the text layout of each block, which costs the + // most. if !clip.intersects(rect) { continue; } @@ -708,14 +723,14 @@ impl NavigatorApp { let row = ROW_H * zoom; let mut y = rect.top() + pad; let cx = rect.center().x; - // Clipped to the box: a label that outgrows its block is then cropped rather than - // spilling across the canvas, whatever the text turns out to be. + // Clipped to the box. A label larger than its block gets a crop, and it does not + // run across the canvas, whatever the text turns out to be. let inner = painter.with_clip_rect(rect.shrink(1.0)); let put = |text: String, color: egui::Color32, f: &egui::FontId, y: f32| { inner.text(egui::pos2(cx, y), egui::Align2::CENTER_TOP, text, f.clone(), color); }; - // A candidate has no published name — the view supplies the label, localized. + // A candidate has no published name, so the view gives the label, and localizes it. let (title, title_fg) = if b.candidate { (candidate_label.clone(), CANDIDATE_STROKE) } else { @@ -724,16 +739,16 @@ impl NavigatorApp { put(title, title_fg, &font, y); y += row; - // The equivalent SNPs themselves, one per line — the block's actual content, and the - // reason its height means something. Printing a count instead withholds both the - // mutations and the sense of time the box is carrying. + // The equivalent SNPs themselves, one on each line. They are the content of the + // block, and the reason its height has a value. A count instead of them withholds + // both the mutations and the sense of time the box carries. for l in &b.loci { put(l.name.clone(), SNP_FG, &small, y); y += row; } - // ONE interact per block. Two on the same rect meant the later one sat on top and - // swallowed the click: every candidate has members, so the double-click handler - // always existed for them and single-click never fired. + // ONE interact for each block. With two on the same rect, the later one sat on top + // and took the click. Every candidate has members, so the double-click handler + // always existed for them, and single-click never fired. let resp = ui.interact(rect, egui::Id::new(("blocktree", b.node_id)), egui::Sense::click()); if resp.double_clicked() { // Jump to a member's subject page. @@ -741,8 +756,8 @@ impl NavigatorApp { open_subject = Some(m.guid); } } else if resp.clicked() { - // A named block expands to show its members; a candidate is an inference, so - // clicking it opens the evidence instead of just more names. + // A named block expands to show its members. A candidate is an inference, so a + // click on it opens the evidence, and not only more names. if b.candidate { review = Some(b.node_id); } else { @@ -774,8 +789,8 @@ impl NavigatorApp { continue; } painter.rect_filled(rect, 2.0, PRIVATE_BG); - // A suppressed donor is a fact about the branch, so the box says so rather than - // quietly reporting a mean over whoever survived. + // A donor the code held back is a fact about the branch, so the box says so. It + // does not give a mean over the survivors with no message. let edge = if pv.suppressed > 0 { egui::Stroke::new(1.5_f32, CANDIDATE_STROKE) } else { @@ -783,11 +798,11 @@ impl NavigatorApp { }; painter.rect_stroke(rect, 2.0, edge); - // The box's height is the measurement, so the text has to fit *it* — never the - // other way round. A one-mutation block is one row tall, which holds one line, and - // the line that matters is the number: the title is a label, the value is the - // finding. So the title appears only when both fit, exactly as the Big Tree drops it - // from its thin blocks. + // The height of the box is the measurement, so the text has to fit *it*, and never + // the other way round. A one-mutation block is one row tall, and that holds one + // line. The line that matters is the number: the title is a label, and the value is + // the result. So the title appears only when both fit, exactly as the Big Tree + // drops it from its thin blocks. let inner = painter.with_clip_rect(rect.shrink(1.0)); let pad_z = PAD * zoom; let avail = rect.width() - 2.0 * pad_z; @@ -806,8 +821,8 @@ impl NavigatorApp { } else { value.size().y }; - // Centred in the box, as the reference centres it — with the text top-aligned once - // the box is shorter than the text, so what survives the clip is the start of it. + // Centred in the box, as the reference centres it. The text aligns to the top once + // the box is shorter than the text, so the start of it survives the clip. let mut y = rect.top() + ((rect.height() - used) / 2.0).max(pad_z); if both { inner.galley( @@ -830,9 +845,9 @@ impl NavigatorApp { ); if resp.hovered() { let b = &drawn[pv.block]; - // Name the denominator. It is the men whose terminal *is* this block — not the - // subtree — and among those, only the ones private-Y has actually been computed - // for, since `private_novel` is `None` until then and `None` is not zero. + // Name the denominator. It is the men whose terminal *is* this block, and not + // the subtree. Among those, it is only the ones that private-Y covered, because + // `private_novel` is `None` until then, and `None` is not zero. let mut tip = format!( "{}\nOn average {} publishable private variant(s) in {} of {} men placed here", b.name, @@ -858,15 +873,16 @@ impl NavigatorApp { } // Men, as the Big Tree draws them: a grey box on a stem below the block they sit on. - // They are the evidence the phylogeny rests on, so they belong in the diagram — the - // roster beside it stays for the private-variant counts and for scanning a long list. + // They are the evidence the phylogeny rests on, so they belong in the diagram. The + // roster beside it stays for the private-variant counts, and to read a long list. for pm in &lay.members { let rect = pm.rect.translate(origin); let b = &drawn[pm.block]; // A stem from the block down to the band. This is the one connector the diagram - // still draws, because a man's box is the one thing not positioned by containment. - // Start the stem below the private-variant block when there is one, so the man hangs - // off his own unnamed mutations rather than appearing to hang off the named branch. + // still draws, because the box of a man is the one thing containment does not + // place. Start the stem below the private-variant block when there is one. The man + // then hangs off his own unnamed mutations, and does not look as though he hangs + // off the named branch. let from = lay .privates .iter() @@ -944,8 +960,8 @@ impl NavigatorApp { .add_filter("HTML", &["html"]) .save_file() { - // One button, either format — chosen by the extension the user typed, as the other - // two-format exports in this app do. + // One button for both formats. The extension the user typed chooses, as it does + // for the other two-format exports in this app. let html_wanted = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("html")); let body = if html_wanted { html } else { tsv }; match std::fs::write(&path, body) { @@ -975,8 +991,8 @@ mod tests { use super::*; use navigator_app::{Block, BlockMember}; - /// Layout depends only on the *number* of members, so the guid can be the nil one — this keeps - /// `uuid` out of the UI crate's dependencies. + /// The layout depends only on the *count* of members, so the guid can be the nil one. That + /// keeps `uuid` out of the dependencies of the UI crate. fn member(name: &str) -> BlockMember { BlockMember { guid: SampleGuid(Default::default()), @@ -1026,8 +1042,9 @@ mod tests { assert!(lay.placed[1].rect.top() >= lay.placed[0].rect.bottom()); } - /// Vertical position is cumulative, so a lineage that accrued more mutations sits lower than its - /// cousin at the same depth. Aligning depths into rows would flatten exactly that difference. + /// The vertical position is cumulative, so a lineage that took more mutations sits lower than + /// its cousin at the same depth. To put every depth on its own row would flatten exactly that + /// difference. #[test] fn a_lineage_that_accrued_more_snps_sits_lower() { let mut root = block(1, 0, 0, &[]); @@ -1084,15 +1101,15 @@ mod tests { let sh = layout(&[short], 1.0).placed[0].rect.height(); let th = layout(&[tall], 1.0).placed[0].rect.height(); assert!(th > sh, "20 equivalent SNPs must stand taller than 2"); - // One line per SNP: the 18 extra mutations are 18 extra rows. + // One line for each SNP: the 18 extra mutations are 18 extra rows. assert!( (th - sh - 18.0 * ROW_H).abs() < 0.5, "height tracks the SNP count exactly" ); } - /// No cap, at any size. A truncated box is a shortened box, and a shortened box is a shorter - /// span of time than the branch actually ran. + /// No cap, at any size. A box with a truncation is a shorter box, and a shorter box is a + /// shorter span of time than the branch ran. #[test] fn a_large_block_is_never_truncated() { let mut b = block(1, 0, 0, &[]); @@ -1102,8 +1119,8 @@ mod tests { assert!((lay.placed[0].rect.height() - (601.0 * ROW_H + 2.0 * PAD)).abs() < 0.5); } - /// The backbone the cohort merely passed through is upstream context, so it leaves the canvas - /// for the breadcrumb — otherwise one member-less box is taller than the whole cohort below it. + /// The backbone the cohort only passed through is upstream context, so it leaves the canvas for + /// the breadcrumb. If not, one box with no members is taller than the whole cohort below it. #[test] fn a_folded_backbone_root_becomes_a_breadcrumb() { let mut root = block(1, 0, 0, &[]); @@ -1115,8 +1132,8 @@ mod tests { assert_eq!(path, "P312 › L21 › Z290", "oldest to youngest"); assert_eq!(snps, 900); - // The live cohort's backbone carries two shallow kits, so men on it must not keep it in the - // canvas — the fold is what makes it upstream. + // The backbone of the live cohort carries two shallow kits. Men on it must not keep it in + // the canvas, because the fold is what makes it upstream. root.members = vec![member("a")]; assert!(upstream_breadcrumb(&[root]).is_some()); } @@ -1127,7 +1144,7 @@ mod tests { assert!(upstream_breadcrumb(&split()).is_none()); } - /// Men are blocks of their own hanging under their terminal, the way the Big Tree stems them. + /// Each man is a block of his own, under his terminal, the way the Big Tree stems them. #[test] fn men_share_one_band_below_the_diagram() { let mut blocks = split(); @@ -1149,7 +1166,7 @@ mod tests { ); } - /// The ruler is the scale that makes a block's height a quantity rather than an impression. + /// The ruler is the scale that makes the height of a block a quantity, and not an impression. #[test] fn the_ruler_graduates_the_deepest_lineage_in_snps() { let mut root = block(1, 0, 0, &[]); @@ -1176,8 +1193,8 @@ mod tests { #[test] fn men_do_not_overlap_their_uncles() { - // A block that both splits and holds men has to make room for both: the men take slots - // beside the child subtrees rather than sitting on top of them. + // A block that both splits and holds men has to make room for both. The men take slots + // beside the child subtrees, and not on top of them. let mut root = block(1, 0, 0, &["m1", "m2", "m3"]); root.subtree_members = 4; let lay = layout(&[root, block(2, 1, 1, &["a"])], 1.0); @@ -1227,7 +1244,7 @@ mod tests { (pv.rect.top() - lay.placed[0].rect.bottom()).abs() < 0.01, "flush under its branch — the axis must not skip" ); - // The men hang below it, and it is centred on them. + // The men hang below it, and it centres on them. let (lo, hi) = ( lay.members.iter().map(|m| m.rect.left()).fold(f32::MAX, f32::min), lay.members.iter().map(|m| m.rect.right()).fold(f32::MIN, f32::max), @@ -1241,8 +1258,8 @@ mod tests { } } - /// One column wide regardless of headcount. The average is a branch-level figure; sizing the box - /// to the number of men would make an identical average look different on two branches. + /// One column wide, whatever the headcount. The average is a figure at branch level. A box that + /// grew with the number of men would make one average look different on two branches. #[test] fn private_blocks_are_one_column_wide_whatever_the_headcount() { let mut one = block(1, 0, 0, &["a"]); @@ -1260,8 +1277,8 @@ mod tests { assert!((w1 - MEMBER_W).abs() < 0.01, "one member column wide"); } - /// The mean is over the men *placed on* the block, not its subtree — FTDNA reports 4 over 2 - /// participants for R-FGC29071 while 7 more men sit on branches below it. + /// The mean is over the men *on* the block, and not over its subtree. FTDNA reports 4 over 2 + /// participants for R-FGC29071, while 7 more men sit on branches below it. #[test] fn the_average_covers_the_men_placed_here_not_the_subtree() { let mut here = block(1, 0, 0, &["a", "b"]); @@ -1304,7 +1321,7 @@ mod tests { assert_eq!(pv.suppressed, 1, "and the exclusion is reported, never silent"); } - /// The average is of the *publishable* count — the one a branch claim could rest on. + /// The average is over the *publishable* count: the one a branch claim could rest on. #[test] fn the_average_is_of_the_gated_count() { let mut b = block(1, 0, 0, &["a"]); @@ -1320,7 +1337,7 @@ mod tests { assert_eq!(fmt_average(4.5), "4.5", "rounding would hide half a mutation"); } - /// `private_novel` is `None` until private-Y has been computed, which is not the same as zero. + /// `private_novel` is `None` until private-Y runs, and that is not the same as zero. #[test] fn a_branch_with_no_private_y_computed_gets_no_block() { let lay = layout(&split(), 1.0); @@ -1329,7 +1346,7 @@ mod tests { "absent evidence must not be drawn as zero mutations" ); - // And a mean is taken only over the men that have one. + // And the mean covers only the men that have one. let mut b = block(1, 0, 0, &["a", "b", "c"]); b.members[0].private_novel = Some(9); b.members[0].private_publishable = Some(9); diff --git a/crates/navigator-ui/src/ui/branch.rs b/crates/navigator-ui/src/ui/branch.rs index 026797b2..e658dba8 100644 --- a/crates/navigator-ui/src/ui/branch.rs +++ b/crates/navigator-ui/src/ui/branch.rs @@ -1,7 +1,8 @@ -//! Per-marker branch report card (`impl NavigatorApp`): the sample's genotype at every defining -//! marker of a chosen Y/mtDNA node's descendant subtree, for spot-checking placement accuracy and -//! exchanging observations with other researchers. Node-triggered (a text input + Load button, not -//! lazy), with a TSV export. Mirrors the descent card's worker/state wiring. +//! A branch report card for each marker (`impl NavigatorApp`). It shows the genotype of the sample +//! at every marker that defines a node in the descendant subtree of a chosen Y or mtDNA node. Use +//! it to spot-check the accuracy of a placement, and to exchange observations with other +//! researchers. A node starts it: a text input and a Load button, and it is not lazy. It has a TSV +//! export. It mirrors how the descent card connects its worker and its state. use super::*; @@ -86,8 +87,9 @@ impl NavigatorApp { return; } - // Render inside a block so the report borrow ends before the (mutating) export button; the - // block yields the formatted TSV + filename the button needs. + // Draw inside a block, so that the borrow of the report ends before the export button, + // which needs a mutable borrow. The block gives back the formatted TSV and the filename + // that the button needs. let (tsv, fname) = { let report = self .branch_reports @@ -135,9 +137,10 @@ impl NavigatorApp { ); ui.add_space(4.0); - // A subtree rooted at a shallow node (R-M269, or the tree root) carries tens of thousands of - // markers — a Grid lays out every row per frame and beach-balls. Fixed-width columns through - // ScrollArea::show_rows build only the visible slice (same idiom as the consensus-panel table). + // A subtree with a shallow node at its root (R-M269, or the tree root) carries tens of + // thousands of markers. A Grid lays out every row on every frame, and the app then stops + // to respond. Fixed-width columns through ScrollArea::show_rows build only the visible + // slice. This is the same idiom as the consensus-panel table. const W_NODE: f32 = 110.0; const W_MARKER: f32 = 90.0; const W_POS: f32 = 80.0; diff --git a/crates/navigator-ui/src/ui/central.rs b/crates/navigator-ui/src/ui/central.rs index e18ee810..367675a7 100644 --- a/crates/navigator-ui/src/ui/central.rs +++ b/crates/navigator-ui/src/ui/central.rs @@ -3,8 +3,9 @@ use super::*; impl NavigatorApp { - /// Route files dropped onto the window through the unified importer, attaching them to - /// the selected subject (auto-detected). No-op when nothing was dropped. + /// Send files that the user dropped onto the window through the unified importer, and attach + /// them to the selected subject, which the importer detects. It does nothing when the user + /// dropped nothing. pub(crate) fn handle_file_drops(&mut self, ctx: &egui::Context) { let dropped = ctx.input(|i| i.raw.dropped_files.clone()); if dropped.is_empty() { @@ -14,8 +15,9 @@ impl NavigatorApp { self.status = "Select a subject before dropping data files.".into(); return; }; - // Route every dropped path (files and/or folders) through the batch importer in one go — - // folders are walked for data files; the result comes back as a single summary modal. + // Send every dropped path through the batch importer in one call, whether it is a file or + // a folder. The importer walks a folder for data files, and the result comes back as one + // summary modal. let paths: Vec = dropped.into_iter().filter_map(|f| f.path).collect(); if !paths.is_empty() { self.status = format!("Importing {} dropped item(s)…", paths.len()); @@ -26,8 +28,8 @@ impl NavigatorApp { } } - /// While files are being dragged over the window, dim the screen and show whether the - /// drop will land on a subject. + /// While the user drags files over the window, dim the screen, and show whether the drop will + /// go to a subject. pub(crate) fn paint_drop_hint(&self, ctx: &egui::Context) { if ctx.input(|i| i.raw.hovered_files.is_empty()) { return; @@ -90,7 +92,8 @@ impl NavigatorApp { }); ui.separator(); } - // Members vs Report on tabs — both can run to thousands of rows, so they do not stack. + // Members and Report sit on tabs. Both can run to thousands of rows, so they do not + // stack. ui.add_space(4.0); self.project_tab = self.sub_bar(ui, self.project_tab, &ProjectTab::ALL); match self.project_tab { @@ -107,10 +110,9 @@ impl NavigatorApp { } } - /// The Subjects work area: the selected subject's detail — header + sub-tabs. - /// A segmented sub-tab bar (one row of selectable labels + a separator). Takes the current - /// selection by value and returns the new one, so callers avoid a `&mut self.field` borrow clash - /// with `self.tr` inside the row. + /// A segmented sub-tab bar: one row of selectable labels, and a separator. It takes the current + /// selection by value, and returns the new one. A caller then avoids a `&mut self.field` borrow + /// clash with `self.tr` inside the row. pub(crate) fn sub_bar(&self, ui: &mut egui::Ui, current: T, items: &[(T, &'static str)]) -> T { let mut sel = current; ui.horizontal(|ui| { @@ -122,10 +124,11 @@ impl NavigatorApp { sel } - /// First-run call-to-action for the Simple-mode empty workspace: an "Import DNA" primary action - /// (pick file(s) → create a subject + import in one step) and a secondary "Add New Subject" that - /// reveals the inline name form. Without this the empty state told the user to add a subject but - /// offered no control to do so (Simple mode hides the left panel until a subject exists). + /// The first-run call-to-action for an empty workspace in Simple mode. The primary action is + /// "Import DNA": pick one or more files, and one step then makes a subject and imports. The + /// secondary action is "Add New Subject", which reveals the inline name form. Without this, the + /// empty state told the user to add a subject, and offered no control to do it. Simple mode + /// hides the left panel until a subject exists. fn simple_first_run(&mut self, ui: &mut egui::Ui) { ui.vertical_centered(|ui| { ui.add_space(56.0); @@ -174,9 +177,10 @@ impl NavigatorApp { self.add_subject_form(ui); } - // Reference genomes: a BAM/CRAM import needs one, and a multi-GB download is the slowest - // part of a first run. Users who already keep a FASTA on disk (a prior pipeline, another - // tool) can point us at it instead — deep-link them straight to Settings → References. + // Reference genomes. A BAM or CRAM import needs one, and a multi-GB download is the + // slowest part of a first run. A user who already keeps a FASTA on disk, from an + // earlier pipeline or another tool, can point us at it instead. Deep-link that user + // straight to Settings → References. ui.add_space(24.0); ui.separator(); ui.add_space(10.0); @@ -194,14 +198,16 @@ impl NavigatorApp { }); } - /// When the selected subject has imported data that has not been analyzed yet, show a prominent - /// call-to-action to run the analysis pipeline — the brief stays empty until it runs. Shown only - /// in the `Pending` state (has alignments, coverage not yet computed); hidden once analysis - /// completes (→ `Complete`) or when the subject has nothing to analyze (no status row). + /// When the selected subject holds imported data that no analysis has covered, show a prominent + /// call-to-action to run the analysis pipeline. The brief stays empty until it runs. This + /// appears only in the `Pending` state, which means the subject has alignments and no coverage + /// yet. It goes away when the analysis ends (→ `Complete`), and when the subject has nothing to + /// analyze (no status row). pub(crate) fn simple_analyze_prompt(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { - // Gate on the brief itself (rebuilt whenever the subject's data changes — import, clear, - // delete+re-add) rather than the separate `subject_status` census map, whose async refresh - // lagged behind those flows and left the prompt missing. + // Gate on the brief itself, and not on the separate `subject_status` census map. The brief + // builds again whenever the data of the subject changes: an import, a clear, or a delete + // and add again. The async refresh of that census map ran behind those flows, and left the + // prompt missing. let needs_analysis = matches!(&self.subject_brief, Some((g, b)) if *g == guid && b.needs_analysis); if !needs_analysis { return; @@ -227,9 +233,10 @@ impl NavigatorApp { ui.add_space(6.0); } - /// Simple-mode "Your DNA sides": the parent-split chromosome painting in plain language. Paints - /// from the consensus on demand (same command/state as the Advanced card), shows the two side - /// tracks with their labels, and a top-populations summary per side. + /// The Simple-mode "Your DNA sides": the parent-split chromosome painting in plain language. It + /// paints from the consensus on demand, with the same command and state as the Advanced card. + /// It shows the two side tracks with their labels, and a summary of the top populations for + /// each side. pub(crate) fn simple_dna_sides_section(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { card(ui, self.tr("card.dnaSides"), |ui| { ui.label(egui::RichText::new(self.tr("dnaSides.intro")).small()); @@ -266,10 +273,12 @@ impl NavigatorApp { }); } + /// The Subjects work area: the detail of the selected subject, with a header and sub-tabs. pub(crate) fn subjects_central(&mut self, ui: &mut egui::Ui) { let Some(guid) = self.selected_sample else { - // First launch (Simple mode, empty workspace) has no left panel and so no reachable - // Add-Subject button — give it a real call-to-action instead of a dead-end hint. + // The first launch, in Simple mode with an empty workspace, has no left panel, and so + // no Add-Subject button the user can reach. Give it a real call-to-action, and not a + // hint that leads nowhere. if self.ui_mode == UiMode::Simple && self.all_biosamples.is_empty() { self.simple_first_run(ui); } else { @@ -279,8 +288,8 @@ impl NavigatorApp { }; self.subject_detail_header(ui, guid); ui.add_space(6.0); - // Simple mode hides the per-DNA-type tabs — the subject view is the plain-language brief, - // split across a section rail rather than stacked in one scroll (see `ui::simple`). + // Simple mode hides the tab of each DNA type. The subject view is the plain-language + // brief, split across a section rail, and not stacked in one scroll (see `ui::simple`). if self.ui_mode == UiMode::Simple { self.simple_subject_view(ui, guid); return; @@ -304,7 +313,8 @@ impl NavigatorApp { DetailTab::YDna => { self.y_sub = self.sub_bar(ui, self.y_sub, &YSub::ALL); match self.y_sub { - // Compact landing: the subject's Y consensus (source of truth across sources). + // A compact first view: the Y consensus of the subject, which is the + // source of truth over all sources. YSub::Haplogroup => { card(ui, self.tr("card.yHaplogroup"), |ui| { if self.consensus_y.is_some() { @@ -324,8 +334,9 @@ impl NavigatorApp { }); } } - // The heavy SNP surface: a compact chrY variant track as shared context, then - // the heavy tables one at a time (each runs to thousands of rows on a WGS). + // The heavy SNP surface. First a compact chrY variant track as shared + // context, then the heavy tables one at a time. Each of those runs to + // thousands of rows on a WGS. YSub::Snp => { self.ensure_y_snp_names(guid); card(ui, self.tr("card.variantTrack"), |ui| self.y_variant_track(ui)); @@ -403,8 +414,9 @@ impl NavigatorApp { } } DetailTab::Ancestry => { - // Consensus is the source of truth: estimate from the subject's pooled autosomal - // consensus (no per-alignment BAM walk), decoupled from any selected source. + // The consensus is the source of truth. Estimate from the pooled autosomal + // consensus of the subject, with no BAM walk for each alignment, and with no + // link to any selected source. card(ui, self.tr("card.donorAncestry"), |ui| { ui.horizontal(|ui| { let label = if self.donor_ancestry.is_some() { self.tr("common.refresh") } else { self.tr("btn.estimateAncestry") }; @@ -420,8 +432,8 @@ impl NavigatorApp { }); asset_status_line(ui, &self.asset_status); self.donor_ancestry_summary(ui); - // Publish the subject's consensus ancestry breakdown (one record per method) - // — available once it is been estimated. + // Publish the consensus ancestry breakdown of the subject, one record for + // each method. It is available after an estimate runs. if self.donor_ancestry.is_some() { self.publish_row(ui, "Publish ancestry to PDS", Command::PublishAncestry { biosample_guid: guid }); } @@ -431,10 +443,11 @@ impl NavigatorApp { ui.add_space(10.0); card(ui, self.tr("card.pcaScatter"), |ui| self.pca_scatter_section(ui)); } - // Detailed reports from the same consensus estimate (persisted alongside the - // super-population ADMIXTURE): modern fine populations + ancient components. Each - // is now a compact pie + legend, so the two sit **side by side** (columns) instead - // of stacking — far less vertical scroll in the Advanced view. + // Detailed reports from the same consensus estimate, which the store keeps + // beside the super-population ADMIXTURE: modern fine populations, and ancient + // components. Each is now a compact pie with a legend, so the two sit **side by + // side** in columns, and do not stack. That is much less vertical scroll in the + // Advanced view. let show_modern = self.fine_ancestry.is_some(); let show_ancient = navigator_app::ANCIENT_ANCESTRY_ENABLED; // The ancient card's build/refresh control + component render (borrows/mutates @@ -489,7 +502,8 @@ impl NavigatorApp { ui.add_space(10.0); ancient_card(self, ui); } - // Chromosome painting (diploid local ancestry) — its own section, from the consensus. + // Chromosome painting (diploid local ancestry): its own section, from the + // consensus. ui.add_space(10.0); card(ui, self.tr("card.chromosomePainting"), |ui| { ui.horizontal(|ui| { @@ -512,7 +526,8 @@ impl NavigatorApp { } } }); - // Runs of homozygosity (F_ROH / endogamy signal) — its own section, from the consensus. + // Runs of homozygosity (F_ROH, the endogamy signal): its own section, from the + // consensus. ui.add_space(10.0); card(ui, self.tr("card.roh"), |ui| { ui.horizontal(|ui| { @@ -534,12 +549,13 @@ impl NavigatorApp { draw_roh(ui, result, regions); } }); - // Per-tab AI explanation of the ROH result (M5) — only once it is been computed. + // An AI explanation of the ROH result on this tab (M5). It appears only after + // the result exists. if self.roh.is_some() { ui.add_space(8.0); self.ai_explain(ui, guid, SignalKind::Roh); } - // Archaic (Neanderthal / Denisovan) marker count — Tier A, from the consensus. + // Archaic (Neanderthal, Denisovan) marker count: Tier A, from the consensus. ui.add_space(10.0); card(ui, self.tr("card.archaic"), |ui| { ui.horizontal(|ui| { @@ -566,8 +582,8 @@ impl NavigatorApp { }); if let Some(r) = &self.archaic { ui.add_space(8.0); - // Headline is a COUNT over what was actually assayed — never a - // "% Neanderthal" (design §1/§7). + // The main figure is a COUNT over what the test assayed. It is never a + // "% Neanderthal" (design §1 and §7). ui.label( egui::RichText::new(format!( "{} of {}", @@ -587,8 +603,8 @@ impl NavigatorApp { "Neanderthal-diagnostic {} · shared-archaic {}", r.neanderthal_copies, r.shared_copies )); - // §7: never present a small Denisovan number as a positive finding. - // It sits at the noise floor for most non-Oceanian ancestries. + // §7: never show a small Denisovan number as a positive result. It + // sits at the noise floor for most ancestries outside Oceania. ui.label( egui::RichText::new(self.tr("archaic.denisovanNote")) .weak() @@ -600,8 +616,9 @@ impl NavigatorApp { ui.label(format!("More than {p:.0}% of {c} reference samples.")); } _ => { - // Sparse input (a chip covers a few % of the panel, biased to its - // common tail) can not be ranked against the WGS-scored cohort. + // Sparse input can not rank against the cohort that WGS + // scored. A chip covers a few % of the panel, with a bias to + // its common tail. ui.label( egui::RichText::new(self.tr("archaic.noPercentile")) .weak() @@ -617,16 +634,17 @@ impl NavigatorApp { ); } }); - // Per-tab AI explanation of the archaic result — only once it is been computed. + // An AI explanation of the archaic result on this tab. It appears only after + // the result exists. if self.archaic.is_some() { ui.add_space(8.0); self.ai_explain(ui, guid, SignalKind::Archaic); } - // Tier B: archaic SEGMENTS (WGS only — needs genome-wide de-novo calls). - // Withheld pending a working method: the card states that rather than - // disappearing, because a section that silently vanishes between releases reads - // as a bug, where a stated withholding is a finding about the data. + // Tier B: archaic SEGMENTS. WGS only, because it needs genome-wide de-novo + // calls. It waits for a method that works, and the card says so. It does not + // go away, because a section that vanishes between releases, with no message, + // reads as a fault. A stated hold is a result about the data. ui.add_space(10.0); card(ui, self.tr("card.archaicSegments"), |ui| { if !navigator_app::ARCHAIC_SEGMENTS_ENABLED { @@ -671,17 +689,18 @@ impl NavigatorApp { r.summary.n_segments, r.summary.pct_callable, r.summary.callable_mb )); ui.add_space(6.0); - // The comparability limit sits directly under the number, not in a - // footnote: this figure is measured against four sequenced archaic - // genomes that represent some ancestries better than others, so - // comparing it between people of different ancestry is the one use it - // can not support. Stated where the number is read, or it will not be. + // The comparability limit sits directly under the number, and not in a + // footnote. The measure is against four sequenced archaic genomes, and + // those represent some ancestries better than others. So a comparison + // between people of different ancestry is the one use this figure can + // not support. Put it where the reader sees the number, or nobody + // reads it. ui.label( egui::RichText::new(self.tr("archaicSegments.withinPopulation")) .color(egui::Color32::from_rgb(230, 180, 90)), ); ui.add_space(4.0); - // Lineage split is withheld, not merely absent — say so. + // The lineage split is on hold, and not only absent. Say so. ui.label( egui::RichText::new(self.tr("archaicSegments.noLineage")) .weak() @@ -700,7 +719,8 @@ impl NavigatorApp { // Discovery + consent live in the top-level Matching tab (they are // account-scoped); what belongs to *this* subject is the results it produced. card(ui, self.tr("card.encryptedExchange"), |ui| self.exchange_section(ui, guid)); - // Per-source compare + within-subject identity (the QC gate) — advanced. + // A compare of each source, and identity inside one subject (the QC gate). + // Advanced. if self.selected_alignment.is_some() { ui.add_space(10.0); let per_source = self.tr("card.panelGenotypingIbd"); @@ -717,7 +737,7 @@ impl NavigatorApp { return; }; ui.add_space(6.0); - // When the subject was opened from a project's report, offer a way back to that project. + // When the subject came from the report of a project, offer a way back to that project. if let Some(pid) = self.return_to_project { if ui.button(self.tr("detail.backToProject")).clicked() { self.nav = Nav::Projects; @@ -823,9 +843,10 @@ impl NavigatorApp { match &self.account { Some(did) => { ui.label(format!("Signed in as {did}")); - // Via the catalog, not a literal: this line duplicated `account.online`/`.offline` - // in English-only text, and its hand-written dot was a character with no glyph in - // egui's font — an empty box next to "online" on the dashboard. + // Through the catalog, and not a literal. This line used to repeat + // `account.online` and `.offline` in English-only text. Its hand-written dot was a + // character with no glyph in the font of egui. That drew an empty box next to + // "online" on the dashboard. ui.label(self.tr(if self.online { "account.online" } else { @@ -840,8 +861,8 @@ impl NavigatorApp { self.maintenance_section(ui); } - /// i18n keys for a chore. Static because `tr` takes a `&'static str` — and because a chore - /// with no label should fail to compile rather than render a raw key. + /// i18n keys for a chore. They are static, because `tr` takes a `&'static str`. A chore with no + /// label must also fail to compile, and must not draw a raw key. fn chore_labels(chore: navigator_app::Chore) -> (&'static str, &'static str) { match chore { navigator_app::Chore::PrivateY => ("maint.privateY", "maint.privateY.hint"), @@ -850,16 +871,16 @@ impl NavigatorApp { } } - /// **Workspace maintenance** — the periodic batch jobs, in one place. + /// **Workspace maintenance**: the periodic batch jobs, in one place. /// /// These were CLI-only (`private-y --project`, `rebuild-signatures --stale-tree`, - /// `publish-origins`), and each design doc that noted the missing GUI trigger also noted that - /// they wanted *one* answer rather than a button apiece. So this is a table over - /// `navigator_app::Chore`: a fourth chore is a row, not a new surface. + /// `publish-origins`). Each design doc that noted the missing GUI trigger also noted that they + /// wanted *one* answer, and not a button for each. So this is a table over + /// `navigator_app::Chore`: a fourth chore is a row, and not a new surface. /// - /// Nothing is surveyed until asked. Measuring what these would do costs real work — one walks - /// every alignment, another fetches and parses a multi-MB haplotree — and a dashboard must not - /// pay that on every visit. + /// No survey runs until the user asks for it. To measure what these would do costs real work: + /// one walks every alignment, and another reads and parses a multi-MB haplotree. A dashboard + /// must not pay that on every visit. fn maintenance_section(&mut self, ui: &mut egui::Ui) { ui.heading(self.tr("maint.title")); ui.label(egui::RichText::new(self.tr("maint.hint")).weak().small()); @@ -890,7 +911,8 @@ impl NavigatorApp { }); ui.add_space(6.0); - // The running chore's own line, so a long job shows life rather than a frozen panel. + // The line of the chore in progress, so that a long job shows life, and not a frozen + // panel. if let Some((chore, done, total, label, fraction)) = self.chore_running.clone() { ui.group(|ui| { ui.label(egui::RichText::new(self.tr(Self::chore_labels(chore).0)).strong()); @@ -914,8 +936,8 @@ impl NavigatorApp { ui.vertical(|ui| { ui.label(egui::RichText::new(self.tr(title)).strong()); ui.label(egui::RichText::new(self.tr(hint)).weak().small()); - // `due of total` — what makes "0 due" read as "nothing to do" rather than - // "nothing found". + // `due of total`. That is what makes "0 due" read as "nothing to do", and + // not as "nothing found". let line = match &s.blocked { Some(why) => format!("{} — {why}", self.tr("maint.blocked")), None => format!("{} / {} {}", s.due, s.total, self.tr("maint.due")), @@ -972,7 +994,7 @@ impl NavigatorApp { self.status = "Select an alignment (Data Sources) to run analysis.".into(); } } - // Compare needs a second subject (multi-select) — disabled for now. + // Compare needs a second subject (multi-select), so it is off for now. let _ = ui.add_enabled(false, egui::Button::new(self.tr("action.compare"))); }); }); @@ -981,9 +1003,9 @@ impl NavigatorApp { } } -/// A friendly default subject name derived from the first picked file's stem, stripping the known -/// data-file extensions (including double extensions like `.vcf.gz`). `None` if nothing usable is -/// left, so the caller can fall back to a localized default. The user can rename later. +/// A friendly default subject name, from the stem of the first picked file. It removes the known +/// data-file extensions, and a double extension like `.vcf.gz` too. `None` when nothing usable +/// remains, so that the caller can fall back to a localized default. The user can rename it later. fn first_run_subject_name(paths: &[std::path::PathBuf]) -> Option { const EXTS: [&str; 18] = [ ".g.vcf.gz", diff --git a/crates/navigator-ui/src/ui/chrome.rs b/crates/navigator-ui/src/ui/chrome.rs index 3a8a4a56..e3910de2 100644 --- a/crates/navigator-ui/src/ui/chrome.rs +++ b/crates/navigator-ui/src/ui/chrome.rs @@ -3,7 +3,7 @@ use super::*; impl NavigatorApp { - /// Kick off the full-analysis pipeline for an alignment and show the modal immediately. + /// Start the full-analysis pipeline for an alignment, and show the modal at once. pub(crate) fn start_full_analysis(&mut self, alignment_id: i64) { self.analysis = Some(AnalysisModal { step: 1, @@ -17,9 +17,10 @@ impl NavigatorApp { let _ = self.tx.send(Command::RunFullAnalysis { alignment_id }); } - /// Kick off the full-analysis pipeline for a subject (its representative alignment is resolved on - /// the worker) and show the modal immediately. The Simple "My DNA" view uses this so a casual - /// user can analyze without hunting for an alignment id in the Advanced sources table. + /// Start the full-analysis pipeline for a subject, and show the modal at once. The worker + /// resolves the representative alignment of that subject. The Simple "My DNA" view uses this. + /// A casual user can then analyze with no search for an alignment id in the Advanced sources + /// table. pub(crate) fn start_analysis_for_subject(&mut self, guid: SampleGuid) { self.analysis = Some(AnalysisModal { step: 1, @@ -33,27 +34,28 @@ impl NavigatorApp { let _ = self.tx.send(Command::AnalyzeSubject { biosample_guid: guid }); } - /// Translate a catalog key for the active language. Returns `&'static str` (catalogs are - /// embedded), so it never borrows `self` — convenient inside egui closures. + /// Translate a catalog key for the active language. It returns `&'static str`, because the + /// build embeds the catalogs, so it never borrows `self`. That is convenient inside an egui + /// closure. pub(crate) fn tr(&self, key: &'static str) -> &'static str { crate::i18n::tr(self.lang, key) } - /// Whether the loaded alignment has a BAM/CRAM path on record — the gate on every control that - /// would have to walk reads. An alignment we can't find is treated as having no file, so a - /// stale id disables the button rather than launching a walk that would fail. + /// True when the loaded alignment has a BAM or CRAM path on record. It is the gate on every + /// control that would have to walk reads. An alignment we can not find counts as one with no + /// file. So a stale id disables the button, and does not start a walk that would fail. pub(crate) fn alignment_has_bam(&self, alignment_id: i64) -> bool { self.alignments .iter() .any(|a| a.id == alignment_id && a.bam_path.is_some()) } - /// The cancel control shown beside a running analysis: it disables itself once clicked. + /// The cancel control beside an analysis in progress. It disables itself after a click. /// - /// The disable matters — cancellation is cooperative and does not take effect until the walk - /// reaches its next check, so a live button invited repeat clicks and made a working cancel - /// look ignored. Every place that can start a walk needs the same behaviour, so it lives here - /// rather than being re-derived per tab. + /// The disable matters. Cancellation is cooperative, and it takes effect only when the walk + /// reaches its next check. A live button invited repeat clicks, and made a cancel that worked + /// look ignored. Every place that can start a walk needs the same behaviour, so it lives here, + /// and no tab derives it again. pub(crate) fn cancel_button(&mut self, ui: &mut egui::Ui) { let requested = self.cancelling; let label = if requested { @@ -68,8 +70,8 @@ impl NavigatorApp { } } - /// A destructive-action button (filled [`DANGER`], white label) — the visual promise that this - /// one is not like the others. Returns whether it was clicked. + /// A button for a destructive action (filled [`DANGER`], white label). It is the visual promise + /// that this one is not like the others. Returns true after a click. pub(crate) fn danger_button(&self, ui: &mut egui::Ui, key: &'static str) -> bool { ui.add(egui::Button::new(egui::RichText::new(self.tr(key)).color(egui::Color32::WHITE)).fill(DANGER)) .clicked() @@ -84,17 +86,17 @@ impl NavigatorApp { .find(|b| b.guid == guid) } - /// The display name for `guid` — its donor identifier, falling back to the bare guid. Used - /// wherever the UI has to name a subject it is about to act on (the confirm modals). + /// The display name for `guid`: its donor identifier, or the bare guid when there is none. The + /// UI uses it wherever it must name a subject it is about to act on (the confirm modals). pub(crate) fn subject_label(&self, guid: SampleGuid) -> String { self.find_subject(guid) .map(|b| b.donor_identifier.clone()) .unwrap_or_else(|| guid.0.to_string()) } - /// Set the interface mode (Simple ⇄ Advanced), pin it (so the first-run heuristic stops - /// overriding), persist the choice, and keep the nav consistent (Simple hides - /// Projects/Community). Chosen from Settings → Appearance. + /// Set the interface mode (Simple ⇄ Advanced), pin it so that the first-run heuristic no longer + /// overrides it, and persist the choice. It also keeps the nav consistent, because Simple hides + /// Projects and Community. The user chooses it from Settings → Appearance. pub(crate) fn set_ui_mode(&mut self, mode: UiMode) { if self.ui_mode == mode && self.ui_mode_pinned { return; @@ -107,8 +109,9 @@ impl NavigatorApp { self.normalize_for_mode(); } - /// Switch from Simple to Advanced (the "See the data →" bridge), pinning + persisting the choice. - /// The selected subject and its Overview carry over; the app-bar toggle flips back. + /// Switch from Simple to Advanced (the "See the data →" bridge). It pins the choice and + /// persists it. The selected subject and its Overview carry over, and the app-bar toggle goes + /// back. pub(crate) fn enter_advanced_mode(&mut self) { if self.ui_mode != UiMode::Advanced { self.ui_mode = UiMode::Advanced; @@ -119,9 +122,10 @@ impl NavigatorApp { } } - /// First-run default: until the user pins a mode, derive it from the workspace — a casual user - /// (no projects, at most one subject) gets Simple; anyone with projects or multiple subjects - /// gets Advanced. Re-evaluated as subjects/projects load; a no-op once pinned. + /// The first-run default. Until the user pins a mode, this derives it from the workspace. A + /// casual user, with no projects and at most one subject, gets Simple. Anybody with projects, + /// or with more than one subject, starts in Advanced. It runs again as subjects and projects load, + /// and it does nothing after the user pins a mode. pub(crate) fn apply_ui_mode_heuristic(&mut self) { if self.ui_mode_pinned { return; @@ -141,9 +145,9 @@ impl NavigatorApp { } } - /// In Simple mode, the casual user has (usually) one subject — select it automatically so the - /// brief/overview appears without a list interaction. Fires once per load (guarded by the - /// existing selection). + /// In Simple mode the casual user usually has one subject. Select it without help, so that the + /// brief and the overview appear with no interaction with a list. It fires one time on each + /// load, and the existing selection guards it. pub(crate) fn auto_select_single_subject(&mut self) { if self.ui_mode == UiMode::Simple && self.selected_sample.is_none() && self.all_biosamples.len() == 1 { let guid = self.all_biosamples[0].guid; @@ -151,14 +155,16 @@ impl NavigatorApp { } } - /// Open the Settings dialog on a given sub-tab, seeding the form from the persisted settings and - /// asking the worker for the reference-genome rows. Shared by the app bar's ⚙ button and any - /// in-app shortcut that deep-links into a specific tab (e.g. the first-run References prompt). + /// Open the Settings dialog on a given sub-tab. It fills the form from the persisted settings, + /// and it asks the worker for the reference-genome rows. The ⚙ button of the app bar shares it + /// with any in-app shortcut that deep-links into a specific tab, for example the first-run + /// References prompt. pub(crate) fn open_settings(&mut self, ctx: &egui::Context, tab: SettingsTab) { self.settings_form = SettingsForm::from_settings(); - // Seed the scale slider from the *applied* zoom, not the (often unpersisted) setting: the - // HiDPI auto-probe applies a zoom it never persists, so from_settings would otherwise snap - // the slider — and thus the live scale — back to 1.0 the instant Settings opens. + // Fill the scale slider from the zoom that *applies*, and not from the setting, which + // often has no persisted value. The HiDPI auto-probe applies a zoom it never persists. So + // from_settings would otherwise snap the slider back to 1.0 the moment Settings opens, and + // the live scale with it. self.settings_form.ui_scale = ctx.zoom_factor(); self.settings_tab = tab; self.show_settings = true; @@ -211,8 +217,9 @@ impl NavigatorApp { let tabs: &[(Nav, &str, &str)] = match self.ui_mode { UiMode::Simple => &[ (Nav::Dashboard, "📊", "nav.dashboard"), - // Not 🧬 (U+1F9EC): absent from egui's Proportional family, renders as tofu. - // 👤 pairs with Advanced's 👥 — one person here, the workspace roster there. + // Not 🧬 (U+1F9EC). The Proportional family of egui does not have it, and + // it draws as tofu. The 👤 pairs with the 👥 of Advanced: one person + // here, and the workspace roster there. (Nav::Subjects, "👤", "nav.myDna"), ], UiMode::Advanced => &[ @@ -246,8 +253,8 @@ impl NavigatorApp { if ui.button(self.tr("account.signOut")).clicked() { let _ = self.tx.send(Command::Logout); } - // A local did:key identity self-certifies (no PDS) — show a "local" chip; a real PDS - // account shows online/offline. + // A local did:key identity certifies itself, with no PDS, so show a "local" chip. + // A real PDS account shows online or offline. if did.starts_with("did:key:") { ui.colored_label(egui::Color32::from_rgb(150, 160, 220), self.tr("account.localIdentity")); } else if self.online { @@ -280,9 +287,9 @@ impl NavigatorApp { .desired_width(180.0), ); ui.label(self.tr("account.pds")); - // Self-certifying did:key identity — a dev/local-stack affordance (federation only, no - // PDS repo). Compiled out of distributed (release) builds; opt in for a release dev - // build with `--features dev-identity`. + // A did:key identity that certifies itself. It is a control for a dev or local + // stack: federation only, with no PDS repo. The build leaves it out of a release + // that users get. Opt in for a release dev build with `--features dev-identity`. if cfg!(any(debug_assertions, feature = "dev-identity")) && ui .add_enabled(!self.logging_in, egui::Button::new(self.tr("account.useLocal"))) @@ -296,17 +303,17 @@ impl NavigatorApp { /// The left panel, routed by the active nav tab. Hidden on the Dashboard. /// - /// Each variant gets its **own** panel id. egui persists a side panel's width per id, and a - /// stored width overrides `default_width` — so while these all shared the id `"left"`, whichever - /// panel was shown last dictated the width of every other. In practice that meant the Advanced - /// subjects table (a wide multi-column grid) handed its 680px to Simple mode's list of names, - /// which needs a quarter of that and was taking half the window. + /// Each variant gets its **own** panel id. egui persists the width of a side panel against its + /// id, and a stored width wins over `default_width`. While these all shared the id `"left"`, + /// the panel that appeared last set the width of every other one. In practice the Advanced + /// subjects table, a wide multi-column grid, handed its 680px to the list of names in Simple + /// mode. That list needs a quarter of it, and it took half the window. pub(crate) fn left_panel(&mut self, ctx: &egui::Context) { match self.nav { // Dashboard, Matching and Community are full-width (no side panel). Nav::Dashboard | Nav::Matching | Nav::Community => {} Nav::Projects if self.projects_collapsed => { - // Collapsed: a thin strip with just an expand button, handing the detail panel + // Collapsed: a thin strip with only an expand button. The detail panel then gets // the full width for the wide Y-STR chart. egui::SidePanel::left("left_projects_collapsed") .resizable(false) @@ -325,10 +332,11 @@ impl NavigatorApp { .min_width(240.0) .show(ctx, |ui| self.projects_side(ui)); } - // Simple mode: a single-subject experience. With one subject (the common case) the - // side panel is hidden entirely — the brief/overview fills the window; with several, - // a minimal "who am I looking at" selector. It holds nothing but names, so it is capped: - // every pixel it takes comes out of the brief, which is the thing the user came to read. + // Simple mode: an experience for one subject. With one subject, which is the common + // case, the side panel goes away completely, and the brief and overview fill the + // window. With more than one, it is a small "which person is this" selector. It holds + // names and nothing else, so it has a cap. Every pixel it takes comes out of the brief, + // and the brief is what the user came to read. Nav::Subjects if self.ui_mode == UiMode::Simple => { if self.all_biosamples.len() > 1 { egui::SidePanel::left("left_subjects_simple") @@ -340,8 +348,8 @@ impl NavigatorApp { } } Nav::Subjects if self.subjects_collapsed => { - // Collapsed: a thin strip with just an expand button, handing the detail panel - // the full width for charts/tables. + // Collapsed: a thin strip with only an expand button. The detail panel then gets + // the full width for charts and tables. egui::SidePanel::left("left_subjects_collapsed") .resizable(false) .exact_width(34.0) @@ -422,8 +430,8 @@ impl NavigatorApp { } self.reference_prompt(ui); - // FTDNA project import — imports into the selected project, or creates one named from the - // exports if none is selected. + // FTDNA project import. It imports into the selected project. If the user selected + // nothing, it makes a project, and takes the name from the exports. ui.add_space(8.0); ui.label(self.tr("ftdna.importHint")); let hover = if self.selected_project.is_some() { @@ -440,12 +448,13 @@ impl NavigatorApp { self.start_ftdna_import(paths); } } - // Panels (import sites VCF) moved to Settings (⚙) → they are a workspace-wide asset, not a - // per-project action. + // Panels (import sites VCF) moved to Settings (⚙). They are an asset for the whole + // workspace, and not an action on one project. } - /// Classify the picked files by header sniff, route each to its FTDNA parser slot, and dispatch a - /// dry-run plan against the open project. Unrecognized files are ignored (noted in the status). + /// Classify the picked files by a header sniff, send each to its FTDNA parser slot, and + /// dispatch a dry-run plan against the open project. This drops a file it does not recognize, + /// and the status notes it. fn start_ftdna_import(&mut self, paths: Vec) { use navigator_domain::ftdna::{classify, FtdnaFileKind}; let (mut member, mut paternal, mut maternal, mut ystr) = (None, None, None, None); @@ -463,10 +472,11 @@ impl NavigatorApp { self.status = self.tr("ftdna.noneRecognized").to_string(); return; } - // FTDNA prefixes each export file with its own project name — target THAT project, not - // whatever happens to be selected. (Importing one project's files while another was open - // used to misfile the kits into the open project, with no easy way to separate them after.) - // Reuse an existing project of the same name; otherwise the plan/commit creates it. + // FTDNA puts its own project name at the front of each export file. Target THAT project, + // and not whatever the user selected. An import of the files of one project, while another + // was open, used to put the kits into the open project. There was no easy way to separate + // them afterward. Reuse an existing project of the same name, or let the plan and the + // commit make it. let derived_name = [&member, &paternal, &maternal, &ystr] .into_iter() .flatten() @@ -492,9 +502,10 @@ impl NavigatorApp { }); } - /// Simple-mode subject selector: a "who am I looking at" list with a free-text filter and a - /// scrollable, row-virtualized body (a research surface can hold thousands of subjects), plus the - /// Add-New affordance. Only shown when more than one subject exists. + /// The subject selector of Simple mode: a "which person is this" list. It has a free-text + /// filter, and a body that scrolls with virtualized rows, because a research surface can hold + /// thousands of subjects. It also has the Add-New control. It appears only when there is more + /// than one subject. fn simple_subjects_side(&mut self, ui: &mut egui::Ui) { ui.add_space(8.0); ui.heading(self.tr("nav.myDna")); @@ -585,11 +596,12 @@ impl NavigatorApp { self.subjects_table(ui); } - /// All biosamples, independent of any project (the project link is optional). Selecting - /// one drives the runs → alignments → analysis flow in the central panel; adding one - /// tags it to the open project if there is one, else leaves it project-less. - /// The subjects table: columns ID / Name / Y-DNA / mtDNA / Sex / Center / Status, with the - /// selected row highlighted. Clicking a row selects the subject. + /// All biosamples, independent of any project, because the project link is optional. A select + /// of one drives the runs → alignments → analysis flow in the central panel. An add of one tags + /// it to the open project when there is one, and leaves it with no project when there is not. + /// + /// The subjects table: the columns ID, Name, Y-DNA, mtDNA, Sex, Center and Status, with the + /// selected row highlighted. A click on a row selects that subject. fn subjects_table(&mut self, ui: &mut egui::Ui) { use egui_extras::{Column, TableBuilder}; @@ -598,8 +610,9 @@ impl NavigatorApp { return; } - // The display rows are derived (6 `String` clones per subject, then a natural sort), so they - // are cached and rebuilt only when their inputs change — this fn runs every frame. + // The display rows come from a derivation: 6 `String` clones for each subject, then a + // natural sort. So a cache holds them, and they build again only when their inputs change. + // This function runs on every frame. self.refresh_subject_rows(); let rows = &self.subject_rows.rows; @@ -627,7 +640,7 @@ impl NavigatorApp { row.set_selected(Some(r.guid) == selected); for (ci, cell) in r.cells.iter().enumerate() { row.col(|ui| { - // The "Status" column is rendered as a muted accent badge. + // The "Status" column draws as a muted accent badge. if ci == 5 && cell != "-" { chip( ui, diff --git a/crates/navigator-ui/src/ui/community.rs b/crates/navigator-ui/src/ui/community.rs index 0f79f7dc..09ea7655 100644 --- a/crates/navigator-ui/src/ui/community.rs +++ b/crates/navigator-ui/src/ui/community.rs @@ -1,6 +1,7 @@ -//! `impl NavigatorApp` — the Community tab: the signed-in tester's social surface over the AppView's -//! signed Edge API (Support threads to the team / community Feed / Notifications). Account-global -//! (not per-subject); requires a signed-in identity. Mirrors the `central` rendering idioms. +//! `impl NavigatorApp`: the Community tab. It is the social surface of a tester who has signed in, +//! over the signed Edge API of the AppView: Support threads to the team, the community Feed, and +//! Notifications. It is global to the account, and not tied to one subject. It needs an identity +//! that has signed in. It mirrors the idioms that `central` uses to draw. use super::*; impl NavigatorApp { @@ -42,7 +43,8 @@ impl NavigatorApp { }); } - /// Re-poll all sections (also drives the app-bar bell via the Notifications event). + /// Poll all sections again. This also drives the app-bar bell, through the Notifications + /// event. fn refresh_community(&self) { let _ = self.tx.send(Command::LoadSupportThreads); let _ = self.tx.send(Command::LoadCommunityFeed); @@ -164,13 +166,14 @@ impl NavigatorApp { self.feed_topic.clear(); } }); - // Opt-in federation: publishing to your own PDS makes the post a portable, public - // `feed.post` record (mirrored back as a "via Atmosphere" entry). Only a real PDS - // account has a repo to write to — a local did:key identity can't federate. + // Federation is opt-in. A write to your own PDS makes the post a portable, public + // `feed.post` record, which comes back as a `via Atmosphere` entry. Only a real PDS + // account has a repo to write to, and a local did:key identity can not federate. let can_federate = self.account.as_deref().is_some_and(|d| !d.starts_with("did:key:")); if can_federate { - // Bind the labels first: `tr()` borrows `&self`, which would clash with the - // `&mut self.feed_publish_pds` checkbox binding (the i18n borrow gotcha). + // Bind the labels first. `tr()` borrows `&self`, which would clash with the + // `&mut self.feed_publish_pds` bind of the checkbox. This is the i18n borrow + // trap. let label = self.tr("community.publishPds"); let hint = self.tr("community.publishPds.hint"); ui.checkbox(&mut self.feed_publish_pds, label).on_hover_text(hint); @@ -301,7 +304,7 @@ impl NavigatorApp { }); ui.add_space(8.0); - // Inbound requests awaiting our consent (symmetric-blind). + // Inbound requests that wait for our consent (symmetric-blind). if !self.dm_incoming.is_empty() { ui.label(egui::RichText::new(self.tr("dm.incoming")).strong()); for r in self.dm_incoming.clone() { @@ -438,7 +441,7 @@ impl NavigatorApp { } } -/// A truncated DID for display (pseudonymous handle, not PII) — full value goes in a hover. +/// A truncated DID for display (a pseudonymous handle, not PII). The full value goes in a hover. fn short_did(did: &str) -> String { did.chars().take(20).collect() } @@ -450,7 +453,7 @@ struct FeedCard<'a> { body: &'a str, at: Option<&'a str>, pinned: bool, - /// Provenance badge (e.g. "via Atmosphere" for a federated post). + /// Provenance badge, for example `via Atmosphere` for a federated post. badge: Option<&'a str>, } diff --git a/crates/navigator-ui/src/ui/descent.rs b/crates/navigator-ui/src/ui/descent.rs index 9582dd6f..2c0d334c 100644 --- a/crates/navigator-ui/src/ui/descent.rs +++ b/crates/navigator-ui/src/ui/descent.rs @@ -1,7 +1,8 @@ -//! YFull-YReport-style descent visualization (`impl NavigatorApp`): the subject's root→terminal -//! Y/mtDNA path drawn as labelled node badges, each followed by its defining SNPs as call-colored -//! chips. One generic renderer (built on the `DescentReport` aggregate) serves both lineages and -//! two densities — a compact path chain for the Simple view, the full chip wall for Advanced. +//! A descent visualization in the style of a YFull YReport (`impl NavigatorApp`). It draws the +//! root→terminal Y or mtDNA path of the subject as labelled node badges. After each badge come the +//! SNPs that define that node, as chips coloured by the call. One generic function, built on the +//! `DescentReport` aggregate, serves both lineages and two densities. Those are a compact path +//! chain for the Simple view, and the full chip wall for Advanced. use super::*; @@ -13,10 +14,11 @@ const ANCESTRAL: egui::Color32 = egui::Color32::from_rgb(200, 120, 40); // ances const NOCALL: egui::Color32 = egui::Color32::from_rgb(110, 110, 110); // no confident base impl NavigatorApp { - /// Render the descent report for `dna`, loading it lazily off the worker thread on first view and - /// caching the result. `compact` = the Simple-view path chain; otherwise the full Advanced report. - /// Additive and self-contained: shows a spinner while loading and a plain note when there is no - /// placement, so it is safe to drop into any tab. + /// Draw the descent report for `dna`. It loads the report lazily off the worker thread on the + /// first view, and caches the result. `compact` gives the path chain of the Simple view, and + /// anything else gives the full Advanced report. It is additive and self-contained: it shows a + /// spinner while the report loads, and a plain note when there is no placement. So it is safe + /// to put into any tab. pub(crate) fn descent_card(&mut self, ui: &mut egui::Ui, guid: SampleGuid, dna: DnaType, compact: bool) { self.ensure_descent(guid, dna); let entry = self @@ -26,7 +28,8 @@ impl NavigatorApp { .map(|(_, _, r)| r.is_some()); match entry { Some(true) => { - // Render inside a block so the report borrow ends before the (mutating) export button. + // Draw inside a block, so that the borrow of the report ends before the export + // button, which needs a mutable borrow. let has_snps = { let report = self .descent_reports @@ -37,7 +40,8 @@ impl NavigatorApp { self.render_descent(ui, report, compact); report.nodes.iter().any(|n| !n.snps.is_empty()) }; - // Export the descent report (mirrors the on-screen grid) to TSV — full view only. + // Export the descent report to TSV. It mirrors the grid on the screen, and it is + // in the full view only. if !compact && has_snps { ui.add_space(6.0); if ui.button(self.tr("descent.export")).clicked() { @@ -65,8 +69,9 @@ impl NavigatorApp { } } - /// Shown when there is no cached report: a one-time "Build" affordance that runs (and persists) - /// the variant profile this report is drawn from, or a plain note if it is built but unplaced. + /// This appears when the cache holds no report. It is a one-time "Build" control that runs the + /// variant profile this report comes from, and persists it. If that profile exists but has no + /// placement, this is a plain note instead. fn descent_build_prompt(&mut self, ui: &mut egui::Ui, guid: SampleGuid, dna: DnaType) { let (built, loading) = match dna { DnaType::Y => (self.y_profile.is_some(), self.y_profile_loading), @@ -99,10 +104,11 @@ impl NavigatorApp { } } - /// Inline replacement for the Simple-view brief's lineage trail: the compact, call-coloured - /// descent path when the variant profile is built, otherwise the plain root→tip name trail (so - /// the brief card is never empty and Simple mode never triggers an expensive build). Render-only; - /// `subject_brief_view` pre-fires [`ensure_descent`] so the report loads. + /// An inline replacement for the lineage trail of the Simple-view brief. It gives the compact, + /// call-coloured descent path when the variant profile exists, and the plain root→tip name + /// trail when it does not. So the brief card is never empty, and Simple mode never starts a + /// build that costs hours. This only draws: `subject_brief_view` fires [`ensure_descent`] + /// first, so the report loads. pub(crate) fn brief_descent_trail(&self, ui: &mut egui::Ui, guid: SampleGuid, lb: &LineageBrief) { let dna = match lb.kind { LineageKind::Paternal => DnaType::Y, @@ -118,7 +124,7 @@ impl NavigatorApp { self.render_descent_compact(ui, report); return; } - // Fallback until the profile is built: the plain collapsible root→tip trail. + // The fallback until the profile exists: the plain collapsible root→tip trail. if lb.lineage_path.len() > 1 { ui.add_space(2.0); egui::CollapsingHeader::new(self.tr("brief.lineageTrail")) @@ -129,8 +135,8 @@ impl NavigatorApp { } } - /// Fire a `LoadDescentReport` command if this (subject, DNA) report is not already loaded or in - /// flight. Idempotent — safe to call every frame. + /// Fire a `LoadDescentReport` command if this (subject, DNA) report is not already loaded, and + /// not already in progress. It is idempotent, and safe to call on every frame. pub(crate) fn ensure_descent(&mut self, guid: SampleGuid, dna: DnaType) { let loaded = self.descent_reports.iter().any(|(g, d, _)| *g == guid && *d == dna); let loading = self.descent_loading.iter().any(|(g, d)| *g == guid && *d == dna); @@ -141,7 +147,8 @@ impl NavigatorApp { } fn render_descent(&self, ui: &mut egui::Ui, report: &DescentReport, compact: bool) { - // The root carries no defining SNPs; drop empty nodes so the chain starts at the first call. + // The root has no SNPs that define it. Drop an empty node, so the chain starts at the + // first call. if report.nodes.iter().all(|n| n.snps.is_empty()) { ui.label(egui::RichText::new(self.tr("descent.none")).weak()); return; @@ -153,8 +160,8 @@ impl NavigatorApp { } } - /// Simple view: the lineage as a wrapped chain of node badges with a derived/total count each, - /// terminal highlighted. No per-SNP chips. + /// Simple view: the lineage as a wrapped chain of node badges, each with a derived count and a + /// total count, and the terminal highlighted. It draws no chip for each SNP. fn render_descent_compact(&self, ui: &mut egui::Ui, report: &DescentReport) { ui.horizontal_wrapped(|ui| { let mut first = true; @@ -176,8 +183,9 @@ impl NavigatorApp { }); } - /// Advanced view: a colour legend, then per node a badge + a wrapped grid of its defining SNPs - /// as chips coloured by the sample's call (derived / ancestral / no-call). + /// Advanced view: a colour legend, then for each node a badge and a wrapped grid. The grid + /// holds the SNPs that define the node, as chips coloured by the call of the sample (derived, + /// ancestral, or no-call). fn render_descent_full(&self, ui: &mut egui::Ui, report: &DescentReport) { ui.horizontal_wrapped(|ui| { legend_chip(ui, self.tr("descent.derived"), DERIVED); @@ -226,7 +234,7 @@ impl NavigatorApp { } } -/// (derived, total) defining-SNP counts for one node. +/// (derived, total) counts, over the SNPs that define one node. fn node_counts(node: &navigator_app::NodeEvidence) -> (usize, usize) { let derived = node .snps diff --git a/crates/navigator-ui/src/ui/detail.rs b/crates/navigator-ui/src/ui/detail.rs index 2b451fff..255612db 100644 --- a/crates/navigator-ui/src/ui/detail.rs +++ b/crates/navigator-ui/src/ui/detail.rs @@ -3,13 +3,8 @@ use super::*; impl NavigatorApp { - /// Y-STR profiles for the selected subject + an import form (CSV/TSV marker table). - /// Donor-level Y-STR consensus across all of the subject's panels (Phase 2 rollup): the modal - /// value per marker, with cross-panel disagreements flagged. - /// Y-STR report (FTDNA/YSEQ style): summary header (provider toggle, tier badges, conflict - /// count) + a By-Panel / All-Markers / Consensus view, rendered from the already-loaded - /// `str_profiles`. - /// Y-STR called from sequence (HipSTR caller → FTDNA convention) vs the imported vendor profile. + /// Y-STR that the code called from sequence (the HipSTR caller → the FTDNA convention), against + /// the vendor profile that an import gave. pub(crate) fn ystr_sequence_section(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { ui.horizontal(|ui| { let have = matches!(&self.str_concordance, Some((g, _, _)) if *g == guid); @@ -65,8 +60,9 @@ impl NavigatorApp { .small(), ); ui.add_space(4.0); - // No inner ScrollArea — the tab is already one vertical scroll; nesting clips + captures the - // wheel (see str_by_panel_view). Flow the (filtered) grid into the page; the filter narrows it. + // No inner ScrollArea. The tab is already one vertical scroll, and a nested one clips the + // content and takes the wheel (see str_by_panel_view). Let the filtered grid flow into the + // page, because the filter makes it narrow. egui::Grid::new(("ystr_seq_grid", guid)) .num_columns(4) .striped(true) @@ -189,7 +185,7 @@ impl NavigatorApp { ui.add_space(4.0); ui.label(egui::RichText::new(format!("{} matches", matches.len())).weak().small()); ui.add_space(4.0); - // No inner ScrollArea — the tab is already one vertical scroll (see str_by_panel_view). + // No inner ScrollArea. The tab is already one vertical scroll (see str_by_panel_view). egui::Grid::new(("ymatch_grid", guid)) .num_columns(7) .striped(true) @@ -245,6 +241,9 @@ impl NavigatorApp { }); } + /// Y-STR report in the FTDNA or YSEQ style. It has a summary header with a provider toggle, + /// tier badges and a conflict count. Below that comes a By-Panel, All-Markers or Consensus + /// view. It draws from the `str_profiles` that are already in memory. pub(crate) fn ystr_report_section(&mut self, ui: &mut egui::Ui) { if self.str_profiles.is_empty() { ui.label(egui::RichText::new("No STR profiles yet — import one under Data Sources.").weak()); @@ -254,8 +253,8 @@ impl NavigatorApp { let comparison = strprofile::compare_profiles(&self.str_profiles); let multi_provider = comparison.providers.len() > 1; - // Working copies (written back after rendering) so row clicks can mutate selection while - // `self.str_profiles` is borrowed immutably. + // Local copies, which the code writes back after it draws, so that a row click can change + // the selection while `self.str_profiles` has an immutable borrow. let mut sel_provider = self.str_provider.clone().unwrap_or_else(|| { self.str_profiles .iter() @@ -340,13 +339,16 @@ impl NavigatorApp { self.str_provider = Some(sel_provider); self.str_marker_filter = filter; - // Per-tab AI explanation of the Y-STR panels (M5) — additive, below the structured report. + // An AI explanation of the Y-STR panels on this tab (M5). It is additive, and it sits + // below the structured report. if let Some(guid) = self.selected_sample { ui.add_space(8.0); self.ai_explain(ui, guid, SignalKind::YStr); } } + /// Donor-level Y-STR consensus over all the panels of the subject (Phase 2 rollup): the modal + /// value of each marker, with a flag where the panels disagree. fn str_consensus_section(&mut self, ui: &mut egui::Ui) { if self.str_profiles.is_empty() { ui.label(egui::RichText::new("No STR profiles yet — import one under Data Sources.").weak()); @@ -389,10 +391,8 @@ impl NavigatorApp { }); } - /// Donor-level ancestry headline (Phase 3): the best estimate across the subject's sources, - /// with which source + method it came from. - /// The donor's projected (PC1, PC2). Only ADMIXTURE carries PCA coordinates now — the deep - /// (ancient) breakdown is a frequency model and has no position in PC space. + /// The projected (PC1, PC2) of the donor. Only ADMIXTURE carries PCA coordinates now. The deep + /// (ancient) breakdown is a frequency model, and it has no position in PC space. fn sample_pca(&self) -> Option<(f64, f64)> { [self.donor_ancestry.as_ref().map(|(_, r)| r)] .into_iter() @@ -403,10 +403,11 @@ impl NavigatorApp { }) } - /// PCA scatter: the donor's PC1×PC2 against the reference population centroids. The donor's - /// coordinate is always projected in the CHM13 consensus PCA frame, so the reference centroids are - /// loaded from that same asset (once, guarded) — not the selected source's build, which would mix - /// frames (or miss the asset entirely) and collapse the plot onto the lone donor point. + /// PCA scatter: the PC1×PC2 of the donor against the reference population centroids. The + /// projection of the donor coordinate is always in the CHM13 consensus PCA frame. So the + /// reference centroids come from that same asset, one time, behind a guard. They do not come + /// from the build of the selected source. That would mix frames, or miss the asset completely, + /// and it would collapse the plot onto the one donor point. pub(crate) fn pca_scatter_section(&mut self, ui: &mut egui::Ui) { let key = navigator_app::CONSENSUS_SOURCE_ID; let loaded = matches!(&self.pca_reference, Some((a, _)) if *a == key); @@ -429,6 +430,8 @@ impl NavigatorApp { draw_pca_scatter(ui, self.sample_pca(), reference); } + /// The donor-level ancestry summary (Phase 3): the best estimate over the sources of the + /// subject, with the source and the method it came from. pub(crate) fn donor_ancestry_summary(&self, ui: &mut egui::Ui) { let Some((aln, r)) = &self.donor_ancestry else { ui.label(egui::RichText::new("No ancestry estimate for any source yet.").weak()); @@ -461,10 +464,11 @@ impl NavigatorApp { }); } - /// The build/refresh control shared by the Y/mt/autosomal consensus cards: a button that - /// reads "Refresh" once a profile exists (else `build_label_key`), an inline spinner while - /// `loading`, and a weak cost hint. On click it sets `status` and dispatches `command`; - /// returns whether it was clicked so the caller can set its own loading flag. + /// The build and refresh control that the Y, mt and autosomal consensus cards share. It is a + /// button that reads "Refresh" after a profile exists, and `build_label_key` before that. It + /// has an inline spinner while `loading`, and a weak cost hint. A click sets `status` and + /// dispatches `command`. It returns true after a click, so that the caller can set its own + /// loading flag. #[allow(clippy::too_many_arguments)] fn profile_build_control( &mut self, @@ -496,10 +500,12 @@ impl NavigatorApp { clicked } - /// Multi-source Y-variant profile: per-SNP concordance across the subject's Y sources, with - /// status (confirmed/novel/conflict/single) and per-source provenance. + /// The multi-source Y-variant profile: concordance at each SNP over the Y sources of the + /// subject. Each SNP carries a status (confirmed, novel, conflict, or single), and the + /// provenance of each source. pub(crate) fn y_variant_profile_section(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { - // Build/refresh control first (it mutates self / dispatches), before borrowing the profile. + // The build and refresh control first, because it changes self and dispatches. Do it + // before the borrow of the profile. if self.profile_build_control( ui, self.y_profile.is_some(), @@ -512,8 +518,8 @@ impl NavigatorApp { self.y_profile_loading = true; } - // Read-only source-audit (per-source provenance + per-conflict evidence) — opens a modal - // over the cached profile; no re-genotyping. + // A read-only source audit: the provenance of each source, and the evidence for each + // conflict. It opens a modal over the cached profile, and nothing genotypes again. if self.y_profile.is_some() && ui.small_button(self.tr("audit.open")).clicked() { self.audit_y_profile = true; } @@ -542,9 +548,10 @@ impl NavigatorApp { self.y_profile_query = query; } - /// Multi-source mtDNA consensus profile: per-mutation concordance across the subject's mt - /// sources (alignments' chrM placement, imported mtDNA sequences, the chip mt panel). Mirrors - /// the Y-variant card over the same generic consensus engine. + /// The multi-source mtDNA consensus profile: concordance at each mutation over the mt sources + /// of the subject. Those sources are the chrM placement of the alignments, the imported mtDNA + /// sequences, and the chip mt panel. It mirrors the Y-variant card, over the same generic + /// consensus engine. pub(crate) fn mt_variant_profile_section(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { if self.profile_build_control( ui, @@ -566,7 +573,8 @@ impl NavigatorApp { }; let mut filter = self.mt_profile_filter; let mut query = std::mem::take(&mut self.mt_profile_query); - // mtDNA mutations are already named (rCRS notation) — no Y-SNP catalogue annotation. + // The mtDNA mutations already have names, in rCRS notation, so there is no annotation + // from the Y-SNP catalogue. draw_consensus_profile( ui, profile, @@ -583,11 +591,13 @@ impl NavigatorApp { self.mt_profile_query = query; } - /// Multi-source autosomal (diploid 0/1/2) consensus over the canonical IBD-panel sites. Build/ - /// Refresh recomputes (panel-genotypes every WGS + chip source); the cached snapshot loads instantly. - /// Autosomal-consensus **Summary** sub-tab: the build/refresh control plus a one-line digest - /// (site count + overall confidence) once the profile exists. The heavy per-site table lives on - /// the Profile sub-tab ([`autosomal_profile_table`]). + /// The **Summary** sub-tab of the multi-source autosomal consensus, which is a diploid 0/1/2 + /// call over the canonical IBD-panel sites. + /// + /// It has the build and refresh control, plus a one-line digest with the site count and the + /// overall confidence, after a profile exists. Build and Refresh compute the consensus again, + /// and genotype every WGS and chip source at the panel, and a cached snapshot loads at once. + /// The heavy table of each site lives on the Profile sub-tab ([`autosomal_profile_table`]). pub(crate) fn autosomal_summary_section(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { if self.profile_build_control( ui, @@ -616,8 +626,8 @@ impl NavigatorApp { ui.label(egui::RichText::new(self.tr("hint.autoProfileTable")).weak().small()); } - /// Autosomal-consensus **Profile** sub-tab: the full per-site reconciled table. Renders nothing - /// until the profile has been built from the Summary sub-tab. + /// The **Profile** sub-tab of the autosomal consensus: the full reconciled table, one row for + /// each site. It draws nothing until a build from the Summary sub-tab makes the profile. pub(crate) fn autosomal_profile_table(&mut self, ui: &mut egui::Ui) { let Some(profile) = &self.auto_profile else { ui.label(egui::RichText::new(self.tr("hint.autoProfileBuild")).weak()); @@ -637,10 +647,11 @@ impl NavigatorApp { self.auto_profile_query = query; } - /// A per-tab "Explain this" affordance (M5): a small button that asks the local model to explain - /// just one signal (`kind`) for the selected subject in plain language, plus the streamed/finalized - /// explanation rendered below it. Additive — the structured facts in the tab always remain, and - /// it is a no-op when the AI assistant is off. Only one explanation runs at a time (one worker). + /// An "Explain this" control on one tab (M5). It is a small button that asks the local model to + /// explain one signal (`kind`) for the selected subject, in plain language. Below it comes the + /// explanation, first as a stream and then as the final text. It is additive: the structured + /// facts in the tab always stay, and it does nothing when the AI assistant is off. Only one + /// explanation runs at a time, because there is one worker. pub(crate) fn ai_explain(&mut self, ui: &mut egui::Ui, guid: SampleGuid, kind: SignalKind) { if !self.ai_enabled { return; @@ -666,7 +677,7 @@ impl NavigatorApp { } }); - // Prefer the live stream while generating; fall back to the finalized explanation. + // Prefer the live stream while the model writes. Fall back to the final explanation. let live = self .signal_stream .as_ref() @@ -699,9 +710,10 @@ impl NavigatorApp { } } - /// "Ask about your results" chat (M2): a subject-scoped Q&A grounded in the brief. Sign-in is not - /// required (it is local), but the AI assistant must be enabled. Answers are AI-generated from the - /// results, so a persistent banner says to verify against the data. + /// The "Ask about your results" chat (M2): a Q&A for one subject, grounded in the brief. The + /// user does not have to sign in, because it is local, but the AI assistant must be on. An AI + /// makes the answers from the results, so a permanent banner says to check them against the + /// data. pub(crate) fn simple_chat_section(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { if !self.ai_enabled { return; @@ -808,14 +820,16 @@ impl NavigatorApp { .small(), ); } - // The lineage trail, upgraded in place to the call-coloured descent path when the variant - // profile is built (else the plain root→tip names). egui's default font renders no arrow - // glyph, so the fallback uses a middle dot; the path reads root→tip left-to-right. + // The lineage trail. It becomes the call-coloured descent path in place when the variant + // profile exists, and it is the plain root→tip names before that. The default font of egui + // has no arrow glyph, so the fallback uses a middle dot. The path reads root→tip from left + // to right. self.brief_descent_trail(ui, guid, lb); } - /// Consensus dashboard (Overview): the subject's source-of-truth at a glance — consensus Y/mt - /// haplogroups, top ancestry, the autosomal concordance one-liner, and a source inventory. + /// The consensus dashboard (Overview): the source of truth for the subject, in one view. It has + /// the consensus Y and mt haplogroups, the top ancestry, the one-line autosomal concordance, + /// and an inventory of the sources. pub(crate) fn overview_dashboard(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { let none = self.tr("hint.noConsensusYet"); card(ui, self.tr("card.consensusSummary"), |ui| { @@ -904,10 +918,11 @@ impl NavigatorApp { } /// Genealogy for the open subject: vendor ids (kit numbers), FTDNA member labels, and the MDKA - /// per lineage — all editable here (add/remove kits, edit/add/remove MDKA). PII: shown locally - /// only, never federated. Rendered once the subject's genealogy has loaded (even when empty, so - /// the Add affordances are reachable). Edits are collected in locals and dispatched after the - /// render closure (which borrows `self.tr` immutably). + /// of each lineage. The user can edit all of it here: add and remove a kit, and edit, add or + /// remove an MDKA. It is PII, so it appears locally only, and never goes to federation. It + /// draws after the genealogy of the subject loads, and it draws when that is empty too, so that + /// the Add controls stay reachable. The edits collect in locals, and they dispatch after the + /// draw closure, which borrows `self.tr` immutably. fn genealogy_card(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { // Clone the loaded bundle for this subject so the closure does not hold a borrow of `self`. let Some(data) = self @@ -952,7 +967,8 @@ impl NavigatorApp { ui.add_space(10.0); card(ui, self.tr("card.genealogy"), |ui| { - // Vendor identifiers (kit numbers, etc.) — each removable; add a new one. + // Vendor identifiers (kit numbers, and others). The user can remove each one, and + // add a new one. ui.horizontal(|ui| { ui.strong(self.tr("geneal.ids")); if ui.small_button(self.tr("geneal.addKit")).clicked() { @@ -971,7 +987,8 @@ impl NavigatorApp { } }); } - // FTDNA member labels (reported haplogroups + access/consent) — read-only (imported). + // FTDNA member labels: the reported haplogroups, and the access and consent flags. + // Nobody can edit them here, because an import made them. if let Some(m) = &data.member { if let Some(y) = &m.y_haplogroup_ftdna { ui.horizontal(|ui| { @@ -997,7 +1014,8 @@ impl NavigatorApp { } }); } - // MDKA — paternal (Y) then maternal (Mt): edit/remove when present, add when absent. + // MDKA: paternal (Y) first, then maternal (Mt). Edit or remove one that is there, and + // add one that is not. for (lineage, label_key, add_key) in [ ("Y", "geneal.paternal", "geneal.addPaternal"), ("Mt", "geneal.maternal", "geneal.addMaternal"), @@ -1060,9 +1078,10 @@ impl NavigatorApp { } } - /// The per-sequencing-result hub: the source lists (runs/alignments/chips/STR/mtDNA) plus, for the - /// selected source, the inherently-per-result views (coverage, sex/metrics/SV, ideogram, - /// heteroplasmy, chrM de-novo) and that source's own Y/mt haplogroup placement. + /// The hub for each sequencing result. It holds the source lists (runs, alignments, chips, STR + /// and mtDNA). For the selected source it also holds the views that belong to one result alone: + /// coverage, sex, metrics, SV, the ideogram, heteroplasmy, and chrM de-novo. It also holds the + /// Y and mt haplogroup placement of that source. pub(crate) fn sources_tab(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { self.data_sources_tab(ui, guid); ui.add_space(10.0); @@ -1113,8 +1132,8 @@ impl NavigatorApp { } } - /// Donor-level private-Y union (Phase 3): off-backbone calls pooled + deduped across the - /// subject's Y-bearing sources. + /// The donor-level private-Y union (Phase 3): the off-backbone calls, pooled and deduped over + /// every source of the subject that carries Y. pub(crate) fn donor_private_y_section(&mut self, ui: &mut egui::Ui) { if self.donor_private_y.is_none() { ui.label( @@ -1136,7 +1155,8 @@ impl NavigatorApp { b.terminal )); } - // Per-tab AI explanation of the private-Y picture (M5) — additive, alongside the table below. + // An AI explanation of the private-Y picture on this tab (M5). It is additive, and it sits + // beside the table below. if let Some(guid) = self.selected_sample { self.ai_explain(ui, guid, SignalKind::PrivateY); } @@ -1154,9 +1174,10 @@ impl NavigatorApp { 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 the variants that match: a position, an off-path name, "novel", or the + // catalogued name. The table sits inside a scroll pane of fixed height, because a WGS + // bucket runs to thousands of rows. A hard cap stops a pathological bucket from a flood of + // even that pane. const CAP: usize = 1000; let matched: Vec<_> = bucket .variants @@ -1195,8 +1216,9 @@ impl NavigatorApp { ui.label(format!("{}>{}", v.reference, v.alternate)); ui.label(v.depth.to_string()); match &v.class { - // A "novel" call that lands on a catalogued Y-SNP: surface that name (it is not - // on the placed lineage, but it is a known site, not a brand-new variant). + // A "novel" call that lands on a catalogued Y-SNP. Show that name. + // The call is not on the placed lineage, but the catalogue holds + // the site, and it is not a new variant. PrivateClass::Novel => match names.get(&v.position) { Some(name) => ui .colored_label(teal, format!("novel · {name}")) @@ -1214,6 +1236,7 @@ impl NavigatorApp { } } + /// Y-STR profiles for the selected subject, and an import form for a CSV or TSV marker table. fn str_section(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { let mut want_delete: Option = None; for p in &self.str_profiles { @@ -1250,8 +1273,8 @@ impl NavigatorApp { ui.add_space(6.0); ui.collapsing(self.tr("str.import"), |ui| { - // Bind labels first — `self.tr()` (immutable) can't share the statement with the - // `&mut self.forms.*` below (the i18n borrow gotcha). + // Bind the labels first. `self.tr()` is immutable, and it can not share the statement + // with the `&mut self.forms.*` below. This is the i18n borrow trap. let (panel_lbl, provider_lbl, source_lbl) = (self.tr("form.panel"), self.tr("form.provider"), self.tr("form.source")); combo( @@ -1503,14 +1526,15 @@ impl NavigatorApp { }); } - /// mtDNA FASTA sequences for the selected subject + an import form. Per sequence: place the - /// haplogroup, or show its rCRS-relative mutation list (derived against the bundled rCRS). + /// mtDNA FASTA sequences for the selected subject, and an import form. For each sequence it can + /// place the haplogroup, or show the mutation list against rCRS, which it derives against the + /// bundled rCRS. pub(crate) fn mtdna_section(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { if self.mtdna_sequences.is_empty() { ui.label(egui::RichText::new("No mtDNA sequences yet.").weak()); } - // Bind before the &self loop borrow — used inside the per-row closures. + // Bind before the `&self` loop borrow, because the closure of each row uses it. let assign_lbl = self.tr("common.assignHaplogroup"); let mutations_lbl = self.tr("btn.showMtMutations"); let delete_lbl = self.tr("common.delete"); @@ -1587,10 +1611,11 @@ impl NavigatorApp { }); } - /// When an import is blocked on uncached reference builds, prompt to download them (with - /// a progress bar); on completion the import auto-retries (see the `ReferenceReady` event). - /// Also surfaces an in-flight coordinate-index build (`.bai`/`.crai`) so the user sees why a - /// freshly imported file is busy before its first analysis. + /// When an import waits on reference builds that are not in the cache, prompt to download them, + /// with a progress bar. When that ends, the import tries again on its own (see the + /// `ReferenceReady` event). This also shows a coordinate-index build (`.bai`/`.crai`) in + /// progress. The user then sees why a file that just came in is busy before its first + /// analysis. pub(crate) fn reference_prompt(&mut self, ui: &mut egui::Ui) { if self.reference_needs.is_empty() && self.reference_progress.is_none() && self.index_progress.is_none() { return; @@ -1630,9 +1655,9 @@ impl NavigatorApp { } } if let Some((build, received, total)) = self.reference_progress.clone() { - // The bar alone reads as an unexplained multi-GB download, so name it and say why - // it is happening — the pull is usually kicked off automatically by an import, not - // by anything the user clicked. + // The bar alone reads as a multi-GB download with no explanation. So name it, and + // say why it runs. An import usually starts the download on its own, and not + // anything the user clicked. ui.label(egui::RichText::new(self.tr("refdl.progressTitle")).strong()); ui.add_space(2.0); ui.label(egui::RichText::new(self.tr("refdl.why")).weak().small()); @@ -1656,23 +1681,24 @@ impl NavigatorApp { }); } - /// Realign a whole project, as a card rather than a dialog for the same reason the per-alignment - /// one is: this runs for *days*, and nothing that long should own the screen. + /// Realign a whole project. It is a card, and not a dialog, for the same reason as the card of + /// one alignment. This runs for *days*, and nothing that long must own the screen. /// - /// The card leads with how many alignments it would actually touch. A batch measured in days - /// deserves a real number before it starts, not after — and the count excludes everything that - /// would be skipped anyway, so it is the honest figure rather than an upper bound. + /// The card starts with how many alignments it would touch. A batch that takes days deserves a + /// real number before it starts, and not after. The count leaves out everything the job would + /// step over anyway, so it is the honest figure, and not an upper limit. pub(crate) fn project_realign_section(&mut self, ui: &mut egui::Ui) { let Some(project_id) = self.selected_project else { return; }; let target = navigator_app::DEFAULT_TARGET_BUILD; - // Asked of the app, once per project, rather than filtered here. This used to count - // `all_alignments` — the whole workspace — and label the result "in this project": a - // project whose alignments were every one already on the target build was still offered 35 - // of them. The rule also lives in exactly one place now (`realignable_in_project`), so the - // number shown and the batch the button starts can not disagree. + // This asks the app, one time for each project, and it does not filter here. It used to + // count `all_alignments`, which is the whole workspace, and label the result "in this + // project". A project whose alignments were every one already on the target build still + // got an offer of 35 of them. The rule now lives in exactly one place + // (`realignable_in_project`), so the number on the screen and the batch the button starts + // can not disagree. if self.project_realignable_asked != Some(project_id) { self.project_realignable_asked = Some(project_id); self.project_realignable = None; @@ -1689,7 +1715,7 @@ impl NavigatorApp { }); return; }; - // Only the count is read below, so take that rather than cloning the Vec every frame. + // Only the count matters below, so take that, and do not clone the Vec on every frame. let eligible_count = eligible.len(); if eligible_count == 0 { @@ -1734,7 +1760,7 @@ impl NavigatorApp { } }); - // While a batch runs, the same per-stage detail the single-alignment card shows. + // While a batch runs, the same detail of each stage that the single-alignment card shows. if let Some(state) = self.realign.as_ref().filter(|r| r.finished.is_none()) { ui.add_space(6.0); ui.label(format!( @@ -1799,8 +1825,8 @@ impl NavigatorApp { let mut assign_y: Option = None; let mut open_sample: Option = None; - // Column widths, mirroring the old Grid order. The trailing "actions" column (index 14) - // is neither sortable nor filterable. + // Column widths, in the same order as the old Grid. The "actions" column at the end + // (index 14) takes no sort and no filter. const REPORT_COLS: [(&str, f32); 15] = [ ("report.sample", 150.0), ("report.alns", 48.0), @@ -1821,7 +1847,8 @@ impl NavigatorApp { const ACTIONS_COL: usize = 14; let labels: [&str; 15] = REPORT_COLS.map(|(k, _)| self.tr(k)); - // 15 `String`s per member plus a natural sort — cached, since this fn runs every frame. + // 15 `String` values for each member, plus a natural sort. A cache holds them, because + // this function runs on every frame. self.refresh_report_rows(ACTIONS_COL); let order = &self.report_rows.order; let shown = order.len(); @@ -1863,12 +1890,13 @@ impl NavigatorApp { row.col(|ui| { ui.label(r.alignment_count.to_string()); }); - // Mean coverage, with a "lite" badge when it is a partial sidecar estimate that a - // deep walk (the per-row coverage button) would upgrade. + // Mean coverage, with a "lite" badge when it is a partial sidecar estimate. A deep + // walk, from the coverage button on that row, would replace it with a better one. row.col(|ui| { if let Some(err) = &r.decode_error { - // The last walk failed (corrupt/undecodable file) — show it instead of a - // silent "—" so the sample reads as failed, not merely un-analyzed. + // The last walk failed, because the file is corrupt or the decoder can not + // read it. Show that, and not a plain dash, so that the sample reads as a + // failure, and not as one that no analysis covered. ui.add(egui::Label::new( egui::RichText::new(failed_label).small().color(egui::Color32::from_rgb(200, 80, 80)), )) @@ -1947,16 +1975,19 @@ impl NavigatorApp { } } - /// The FTDNA-style "Y-DNA Results Overview" for the open project: members grouped by terminal Y - /// haplogroup, each subgroup prefixed by MIN/MAX/MODE rows, and member cells coloured when they - /// differ from the subgroup's modal value (blue below, red above, purple multi-copy). Only - /// markers reported by at least one member are shown, in canonical FTDNA column order. + /// The FTDNA-style "Y-DNA Results Overview" for the open project. It groups members by their + /// terminal Y haplogroup, and puts MIN, MAX and MODE rows before each subgroup. A member cell + /// takes a colour when it differs from the modal value of the subgroup. Blue is below, red is + /// above, and purple is a multi-copy marker. It shows only the markers that at least one + /// member + /// reports, in the canonical FTDNA column order. pub(crate) fn project_ystr_section(&mut self, ui: &mut egui::Ui) { use egui_extras::{Column, TableBuilder}; use navigator_app::{StrChartCell, StrRowKind}; use navigator_domain::strchart::Deviation; - // Header labels (pulled before borrowing the chart, so closures do not re-borrow `self.tr`). + // Header labels. Take them before the borrow of the chart, so that no closure borrows + // `self.tr` again. let (h_name, h_kit, h_hap, h_test) = ( self.tr("ystr.col.name").to_string(), self.tr("ystr.col.kit").to_string(), @@ -2226,8 +2257,9 @@ impl NavigatorApp { }; let mut pick = None; - // The parent (samples_section) owns the scroll area — no nested one here (that is the widget-ID - // clash and double-scrollbar). Render clusters directly. + // The parent (samples_section) owns the scroll area, so there is no nested one here. A + // nested one gives the widget-ID clash and the double scrollbar. Draw the clusters + // directly. for cluster in &clustering.clusters { // A cluster shows if its branch matches the filter (→ all members) or any member matches. let branch_hit = !filter.is_empty() @@ -2255,7 +2287,8 @@ impl NavigatorApp { cluster.suggested_count(), self.tr("cluster.suggested"), ); - // Auto-open when filtering (so matches are visible) or for small clusters. + // Open it without help when a filter is active, so that the matches are visible, and + // for a small cluster. let open = !filter.is_empty() || cluster.members.len() <= 30; egui::CollapsingHeader::new(egui::RichText::new(header).strong()) .default_open(open) @@ -2313,8 +2346,8 @@ impl NavigatorApp { } } - /// The Data Sources tab: sequencing runs (cards with expandable alignments), chip/array, - /// and STR profiles — each in a rounded card. + /// The Data Sources tab: sequencing runs, as cards whose alignments expand, then chip or array + /// data, then STR profiles. Each one sits in a rounded card. fn data_sources_tab(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { ui.add_space(4.0); card(ui, self.tr("card.sequencingRuns"), |ui| self.runs_card(ui, guid)); @@ -2338,8 +2371,9 @@ impl NavigatorApp { }); } - /// The sequencing-runs body: one card per run (provider chip, title, read meta, Y/mt - /// badges); the selected run expands to its alignment rows + the add-alignment form. + /// The body of the sequencing runs: one card for each run. A card has a provider chip, a + /// title, the read meta, and the Y and mt badges. The selected run expands to its alignment + /// rows, and the add-alignment form. fn runs_card(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { if self.runs.is_empty() { ui.label(egui::RichText::new("No sequencing runs yet.").weak()); @@ -2381,7 +2415,8 @@ impl NavigatorApp { ACCENT.gamma_multiply(0.3), ACCENT, ); - // Lab chip (FGC/FTDNA/YSEQ/Dante/Nebula…) when the sequencing facility is known. + // A lab chip (FGC/FTDNA/YSEQ/Dante/Nebula…) when the record names the + // sequencing facility. if let Some(lab) = r.sequencing_facility.as_deref().filter(|s| !s.is_empty()) { let abbr = navigator_domain::labs::abbreviation(lab, 6); chip( @@ -2407,8 +2442,9 @@ impl NavigatorApp { r.instrument_model.as_deref().unwrap_or("—") ); ui.label(egui::RichText::new(title).strong()); - // Standardized, vendor-neutral test label (WGS150 45Gbases / HiFi 90Gbases / - // BigY-700) when this is a yield/product test — the cohort-comparable label. + // The standardized, vendor-neutral test label (WGS150 45Gbases, HiFi + // 90Gbases, BigY-700) when this is a yield or product test. It is the label + // that compares across a cohort. if let Some(std) = r.standardized_label() { ui.label(egui::RichText::new(std).monospace().small().color(ACCENT)); } @@ -2418,8 +2454,9 @@ impl NavigatorApp { (Some(i), None) => format!(" Instr: {i}"), _ => String::new(), }; - // Library-level metrics: total reads + read/insert length (reads *aligned* - // is a per-alignment stat, shown on the alignment row, not here). + // Metrics at library level: the total reads, and the read length and + // insert length. Reads *aligned* is a statistic of one alignment, and the + // alignment row shows it, not this one. let read_len = r .mean_read_length .map(|v| format!("{v:.0} bp")) @@ -2478,9 +2515,9 @@ impl NavigatorApp { (edit_btn, del_btn, merge_btn) }); let (edit_btn, del_btn, merge_btn) = inner.inner; - // Row selection is sensed on the whole frame, which can swallow the inner buttons' - // clicks; treat a button as hit when it was clicked OR the row swallowed the click - // while the pointer was over it. + // The whole frame senses a row selection, and that can take the clicks of the buttons + // inside it. So count a button as hit when the user clicked it, OR when the row took + // the click while the pointer was over that button. let row_clicked = inner.response.interact(egui::Sense::click()).clicked(); if button_hit(&edit_btn, row_clicked) { want_edit_run = Some(EditRun { @@ -2526,10 +2563,10 @@ impl NavigatorApp { let mut del_btn: Option = None; ui.horizontal(|ui| { ui.label(egui::RichText::new(&a.reference_build).color(ACCENT).strong()); - // A realigned alignment sits next to the one it came from, on the - // same run, often with the same aligner — so without a mark the two - // rows are indistinguishable and the user can not tell which is the - // vendor's file. + // A realigned alignment sits next to the one it came from, on + // the same run, and often with the same aligner. Without a + // mark, the two rows look the same, and the user can not tell + // which one is the file of the vendor. if let Some(source) = a.derived_from_alignment_id { chip(ui, "realigned", ui.visuals().selection.bg_fill, egui::Color32::WHITE) .on_hover_text(format!("Produced by Navigator from alignment #{source}")); @@ -2648,8 +2685,9 @@ impl NavigatorApp { }); } - /// The "Add alignment" form for a run. Picking a BAM/CRAM probes its header to auto-fill the - /// reference build + aligner; the reference FASTA is never asked for (resolved from the build). + /// The "Add alignment" form for a run. When the user picks a BAM or CRAM, a probe of its header + /// fills in the reference build and the aligner. It never asks for the reference FASTA, because + /// that comes from the build. fn add_alignment_form(&mut self, ui: &mut egui::Ui, run_id: i64) { ui.collapsing(self.tr("aln.add"), |ui| { ui.horizontal(|ui| { @@ -2722,10 +2760,10 @@ impl NavigatorApp { }) } - /// Lazily resolve catalogued Y-SNP names for the two Y-SNP tables' position-only / novel calls. - /// Gathers every variant position from the Y consensus profile + the private-Y union and asks the - /// worker for `position → name` once per subject (re-armed when either source reloads). No-op until - /// at least one source is present. + /// Lazily resolve catalogued Y-SNP names for the position-only and novel calls of the two Y-SNP + /// tables. It collects every variant position from the Y consensus profile and the private-Y + /// union. Then it asks the worker for `position → name`, one time for each subject, and it arms + /// again when either source loads again. It does nothing until at least one source is there. pub(crate) fn ensure_y_snp_names(&mut self, guid: SampleGuid) { if self.y_snp_names_requested { return; @@ -2763,7 +2801,8 @@ impl NavigatorApp { return; } - // chrY length + PAR shading from the selected alignment's genome regions (lazily fetched). + // The chrY length, and the shade behind each PAR, from the genome regions of the selected + // alignment. The code reads those lazily. let (mut length, mut regions): (i64, Vec) = (62_460_029, Vec::new()); // CHM13 chrY fallback if let Some(id) = self.selected_alignment { if let Some(build) = self @@ -2808,7 +2847,7 @@ impl NavigatorApp { } } } - // Guard against build-mismatched positions overrunning the bar. + // Guard against a position from a different build that would go past the end of the bar. length = length.max(marks.iter().map(|m| m.position).max().unwrap_or(0) + 1); draw_variant_track(ui, "chrY", length, ®ions, &marks); } diff --git a/crates/navigator-ui/src/ui/events.rs b/crates/navigator-ui/src/ui/events.rs index 31ce5110..a6706ed4 100644 --- a/crates/navigator-ui/src/ui/events.rs +++ b/crates/navigator-ui/src/ui/events.rs @@ -5,8 +5,8 @@ use super::*; impl NavigatorApp { pub(crate) fn drain_events(&mut self) { while let Ok(event) = self.rx.try_recv() { - // Any event may replace data the per-frame view caches derive from, so invalidate them - // all. See `NavigatorApp::data_epoch` for why this is deliberately over-broad. + // Any event may replace data that the view caches derive from, so invalidate them all. + // See `NavigatorApp::data_epoch` for why this is over-broad on purpose. self.data_epoch = self.data_epoch.wrapping_add(1); match event { Event::Noop => {} @@ -34,7 +34,8 @@ impl NavigatorApp { if !summary.missing_index.is_empty() { msg.push_str(&format!("; {} sample(s) missing an index", summary.missing_index.len())); } - // Per-sample failures that were skipped so the rest could import (recoverable). + // Failures on one sample that the import stepped over, so that the rest could + // come in (recoverable). if !summary.sample_errors.is_empty() { msg.push_str(&format!( "; {} sample(s) skipped on error: {}", @@ -46,7 +47,7 @@ impl NavigatorApp { if !summary.reference_notes.is_empty() { msg.push_str(&format!(". References: {}", summary.reference_notes.join("; "))); } - // Fast path: what the pipeline sidecars filled without walking the CRAM. + // Fast path: what the pipeline sidecars filled with no walk of the CRAM. let fp = &summary.fast_path; if fp.samples_with_sidecars > 0 { msg.push_str(&format!( @@ -132,10 +133,11 @@ impl NavigatorApp { self.reference_needs = builds; } Event::ReferenceProgress { build, received, total } => { - // Mirror the download into the always-visible status bar. The progress bar is only - // drawn in a couple of views (and none in Simple mode), so without this a slow - // multi-GB reference pull — kicked off in the background after import — looks like - // the app is stuck. The status line is the one surface visible in every view. + // Mirror the download into the status bar, which is always visible. Only two + // views draw the progress bar, and no view in Simple mode does. Without this, a + // slow multi-GB reference download, which starts in the background after an + // import, looks like an app that has stopped. The status line is the one + // surface that every view shows. let recv_mb = received / 1_000_000; self.status = match total { Some(t) if t > 0 => format!( @@ -174,7 +176,8 @@ impl NavigatorApp { self.update_info = Some(*info); } Event::UpToDate => { - // Quietly current — no nagging. (A failed check surfaces via Event::Error.) + // Current, with no message and no repeat prompt. (A failed check comes back + // through Event::Error.) } Event::Samples { project_id, samples } => { if self.selected_project == Some(project_id) { @@ -217,7 +220,8 @@ impl NavigatorApp { Event::BranchReportLoaded { guid, dna, result } => { self.branch_loading.retain(|(g, d)| !(*g == guid && *d == dna)); if self.selected_sample == Some(guid) { - // Replace any prior report for this (guid, dna) — a new node was queried. + // Replace any earlier report for this (guid, dna), because a query named + // a new node. self.branch_reports.retain(|(g, d, _)| !(*g == guid && *d == dna)); match result { Ok(report) => self.branch_reports.push((guid, dna, report)), @@ -262,8 +266,8 @@ impl NavigatorApp { Ok(answer) => answer, Err(msg) => format!("{} {msg}", self.tr("brief.aiUnavailable")), }; - // Set the authoritative answer on the pending assistant turn (pre-pushed on - // send); fall back to appending one if it is missing. + // Set the authoritative answer on the assistant turn that waits, which the + // send pushed first. If that turn is missing, add one at the end instead. match self.chat_history.last_mut().filter(|t| !t.from_user) { Some(turn) => turn.text = text, None => self.chat_history.push(ChatTurn { from_user: false, text }), @@ -314,10 +318,10 @@ impl NavigatorApp { ); if self.selected_project == Some(project_id) { let _ = self.tx.send(Command::LoadProjectReport(project_id)); - // Haplogroups may have been assigned — regroup the STR chart. + // Something may have assigned haplogroups, so group the STR chart again. self.reload_project_str(); } - // Coverage was (re)computed — refresh the subjects-list Status column. + // Coverage just ran, so refresh the Status column of the subjects list. let _ = self.tx.send(Command::LoadSubjectStatus); } Event::MaintenanceSurvey(v) => { @@ -337,7 +341,7 @@ impl NavigatorApp { self.status = format!("{}: {}", chore.key(), outcome.summary); self.chore_last = Some((chore, outcome)); self.chore_running = None; - // The survey it was based on is now stale by construction. + // The survey behind it is now stale, by construction. self.maintenance = None; } Event::DeepAnalyzeProgress { @@ -362,12 +366,13 @@ impl NavigatorApp { self.status = format!("Importing: {done}/{total} ({pct}%) — {sample}…"); } Event::AllBiosamples(v) => { - // Drop a dangling selection: after deleting the last subject the async list reload - // lands here empty, but `selected_sample` may still point at the deleted (or any - // now-removed) subject. Left set, the per-frame auto-select and brief-load keep - // re-fetching a brief that errors — never clearing `subject_brief_loading` — so the - // Simple view spins on "Building your brief…" forever. Clear it so the empty-state - // (or a valid re-selection) renders instead. + // Drop a selection that hangs. After a delete of the last subject, the async + // list reload lands here empty, but `selected_sample` can still point at the + // subject that went. If it stays set, the auto-select and the brief load on + // each frame ask again for a brief that errors, and nothing ever clears + // `subject_brief_loading`. The Simple view then spins on `Building your brief…` + // for ever. Clear it, so that the empty state, or a valid new selection, draws + // instead. if let Some(sel) = self.selected_sample { if !v.iter().any(|b| b.guid == sel) { self.selected_sample = None; @@ -376,8 +381,9 @@ impl NavigatorApp { } } self.all_biosamples = v; - // One-time: restore the previously-focused subject now that the list is available. - // `.take()` so it applies once; a stale GUID (deleted subject) simply no-ops. + // One time only: restore the subject that had focus before, now that the list + // is here. `.take()` makes it apply one time, and a stale GUID, from a subject + // that went, does nothing. if self.selected_sample.is_none() { if let Some(guid_str) = self.pending_restore_subject.take() { if let Some(guid) = self @@ -443,8 +449,8 @@ impl NavigatorApp { if self.selected_sample == Some(guid) { let _ = self.tx.send(Command::LoadStrProfiles(guid)); } - // A member's STR data changed — refresh the project chart (best-effort; the - // builder only includes members of the open project). + // The STR data of a member changed, so refresh the project chart. It is + // best-effort, and the builder takes only members of the open project. self.reload_project_str(); self.status = "STR profile imported".into(); } @@ -470,8 +476,9 @@ impl NavigatorApp { Event::ChipProfilesChanged(guid) => { if self.selected_sample == Some(guid) { let _ = self.tx.send(Command::LoadChipProfiles(guid)); - // A chip import also places Y (and, for 23andMe, mtDNA) haplogroups — - // refresh the consensus so they appear without a manual reload. + // A chip import also places Y haplogroups, and mtDNA haplogroups for + // 23andMe. Refresh the consensus, so that they appear with no manual + // reload. let _ = self.tx.send(Command::LoadConsensus(guid)); } let _ = self.tx.send(Command::LoadHaploSummary); // subjects-list Y/mt columns @@ -517,8 +524,8 @@ impl NavigatorApp { if let Some(guid) = self.selected_sample { let _ = self.tx.send(Command::LoadConsensus(guid)); } - // A per-row "Assign Y" from the project report just recorded a call — - // refresh the report so its Y column fills in. + // An "Assign Y" on one row of the project report just recorded a call. Refresh + // the report, so that its Y column fills in. if let Some(pid) = self.selected_project { let _ = self.tx.send(Command::LoadProjectReport(pid)); } @@ -548,7 +555,8 @@ impl NavigatorApp { Some(top) => format!("Y haplogroup (panel): {} (score {:.3})", top.name, top.score), None => "No Y haplogroup match from the panel".into(), }; - // The call was recorded — refresh the donor consensus so the Y-DNA card fills in. + // The store has the call, so refresh the donor consensus and the Y-DNA card + // fills in. let _ = self.tx.send(Command::LoadConsensus(biosample_guid)); } Event::MtHaplogroup { @@ -610,7 +618,8 @@ impl NavigatorApp { if self.selected_sample == Some(biosample_guid) { self.consensus_y = y; self.consensus_mt = mt; - // Consensus drives the Simple-mode brief — (re)build it now (no-op in Advanced). + // The consensus drives the Simple-mode brief, so build it now. It does + // nothing in Advanced. self.reload_subject_brief(); } } @@ -641,7 +650,7 @@ impl NavigatorApp { dna_type, }); } - // An assigned haplogroup changed — regroup the project STR chart. + // An assigned haplogroup changed, so group the project STR chart again. if matches!(dna_type, DnaType::Y) { self.reload_project_str(); } @@ -650,7 +659,7 @@ impl NavigatorApp { self.status = format!("Private Y: {} novel, {} off-path", bucket.novel(), bucket.off_path()); self.private_y = Some((alignment_id, bucket)); self.finding_private_y = false; - // A fresh (self-masked) bucket was just cached — refresh the donor union. + // The cache just took a fresh (self-masked) bucket, so refresh the donor union. if let Some(guid) = self.selected_sample { let _ = self.tx.send(Command::LoadDonorPrivateY { biosample_guid: guid }); } @@ -720,10 +729,11 @@ impl NavigatorApp { } ); self.batch_import = Some(summary); - // The import may have added an alignment — refresh the analysis-status map so the - // Subjects Status column and the Simple-mode "Analyze" prompt (`Pending` = has data, - // not analyzed) pick it up. Without this, adding data to an existing subject leaves - // both stale, so the analyze prompt never appears. + // The import may have added an alignment. Refresh the analysis-status map, so + // that the Subjects Status column and the Simple-mode "Analyze" prompt take it + // up. In that prompt, `Pending` means the subject has data that no analysis + // covered. Without this, new data on an existing subject leaves both stale, and + // the analyze prompt never appears. let _ = self.tx.send(Command::LoadSubjectStatus); if self.selected_sample == Some(biosample_guid) { let _ = self.tx.send(Command::LoadRuns(biosample_guid)); @@ -731,8 +741,9 @@ impl NavigatorApp { let _ = self.tx.send(Command::LoadVariantSets(biosample_guid)); let _ = self.tx.send(Command::LoadChipProfiles(biosample_guid)); let _ = self.tx.send(Command::LoadMtdna(biosample_guid)); - // Rebuild the brief so the "Your test" card + not-analyzed state reflect the - // new file (Simple mode was showing the stale empty-subject brief). + // Build the brief again, so that the "Your test" card and the + // not-analyzed state show the new file. Simple mode used to show the stale + // brief of an empty subject. self.reload_subject_brief(); } } @@ -742,8 +753,9 @@ impl NavigatorApp { } => { self.status = format!("Created subject and imported {} file(s)", summary.imported.len()); self.batch_import = Some(summary); - // Refresh the list so the new subject appears, then select it — `select_sample` - // loads its runs/profiles and (in Simple mode) triggers the brief build. + // Refresh the list so that the new subject appears, then select it. + // `select_sample` loads its runs and profiles, and in Simple mode it starts the + // brief build. let _ = self.tx.send(Command::LoadAllBiosamples); let _ = self.tx.send(Command::LoadOverview); self.forms.show_add_subject = false; @@ -778,7 +790,8 @@ impl NavigatorApp { Event::DonorAncestry { alignment_id, result } => { self.estimating_donor_ancestry = false; self.donor_ancestry = Some((alignment_id, result)); - // A fresh consensus estimate persisted the detailed methods too — refresh them. + // A fresh consensus estimate persisted the detailed methods too, so refresh + // them. if let Some(g) = self.selected_sample { let _ = self.tx.send(Command::LoadConsensusAncestryDetail { biosample_guid: g }); } @@ -828,7 +841,8 @@ impl NavigatorApp { // A rebuild re-places the genome consensus (consensus_label); refresh the // Overview's cached Y/mt consensus so it does not 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. + // The descent report comes from this profile, so drop its cache and it + // builds again. self.descent_reports .retain(|(g, d, _)| !(*g == biosample_guid && *d == DnaType::Y)); } @@ -848,7 +862,8 @@ impl NavigatorApp { self.mt_profile = profile; // 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. + // The descent report comes from this profile, so drop its cache and it + // builds again. self.descent_reports .retain(|(g, d, _)| !(*g == biosample_guid && *d == DnaType::Mt)); } @@ -871,13 +886,14 @@ impl NavigatorApp { } => { if self.selected_run == Some(sequence_run_id) { self.alignments = alignments; - // Load cached coverage for every alignment so each Data Sources row shows - // coverage/callable without first being selected. + // Load cached coverage for every alignment, so that each Data Sources row + // shows coverage and callable before anybody selects it. let ids: Vec = self.alignments.iter().map(|a| a.id).collect(); if !ids.is_empty() { let _ = self.tx.send(Command::LoadCoverageBulk(ids)); } - // Apply a queued subject-default alignment once its run's list is loaded. + // Apply a queued subject-default alignment after the list of its run + // loads. if let Some(pid) = self.pending_alignment { if self.alignments.iter().any(|a| a.id == pid) { self.pending_alignment = None; @@ -926,7 +942,7 @@ impl NavigatorApp { self.coverage = result.clone(); self.coverage_hist_contig = None; // reset histogram selection to whole-genome } - // Keep the per-row map current after a (re)compute. + // Keep the map of each row current after a compute. match result { Some(c) => { self.coverage_by_aln.insert(alignment_id, c); @@ -946,8 +962,9 @@ impl NavigatorApp { self.sex = result; } self.running_sex = false; - // Sex inference may have written the sex back to the biosample — reload the - // subjects list so the table + header reflect it instead of "Unknown". + // Sex inference may have written the sex back to the biosample. Load the + // subjects list again, so that the table and the header show it, and not + // "Unknown". let _ = self.tx.send(Command::LoadAllBiosamples); if let Some(pid) = self.selected_project { let _ = self.tx.send(Command::LoadProjectReport(pid)); @@ -1034,8 +1051,9 @@ impl NavigatorApp { (None, true) => super::RealignFinished::Cancelled, (None, false) => super::RealignFinished::Failed(summary.clone()), }; - // step/total are zero rather than carried over: every consumer matches on - // `finished` first and none of them reads progress from a finished card. + // step and total are zero, and nothing carries them over. Every consumer + // matches on `finished` first, and none of them reads progress from a card that + // finished. self.realign = Some(super::RealignState { alignment_id, biosample_guid, @@ -1045,8 +1063,8 @@ impl NavigatorApp { detail: String::new(), finished: Some(finished), }); - // A new alignment row exists; the run's list has to learn about it or the - // realigned alignment is invisible until the user navigates away and back. + // A new alignment row exists. The list of the run has to learn about it, or the + // realigned alignment stays invisible until the user goes away and comes back. if new_alignment_id.is_some() { if let Some(run_id) = self .alignments @@ -1088,17 +1106,18 @@ impl NavigatorApp { } else { "Full analysis complete.".into() }; - // The subject's coverage just changed — refresh the Status column. + // The coverage of the subject just changed, so refresh the Status column. let _ = self.tx.send(Command::LoadSubjectStatus); - // In Simple mode, rebuild the brief so the just-computed lineages/ancestry replace - // the "not analyzed yet" prompt (no-op in Advanced / when nothing is selected). + // In Simple mode, build the brief again, so that the lineages and ancestry that + // just ran replace the "not analyzed yet" prompt. It does nothing in Advanced, + // and nothing when there is no selection. self.reload_subject_brief(); } Event::AllAlignments(a) => { self.all_alignments = a; - // The workspace's alignments just changed — an import finished, a realignment - // registered its output — so any project count is stale. Clearing the "already - // asked" marker makes the card re-ask on its next frame. + // The alignments of the workspace just changed: an import ended, or a + // realignment registered its output. So any project count is stale. A clear of + // the "already asked" marker makes the card ask again on its next frame. self.project_realignable_asked = None; } // Discarded unless it is still the project on screen: the query is async and the @@ -1147,7 +1166,7 @@ impl NavigatorApp { if agreed { " · agreed" } else { " · NOT agreed" } ); let _ = self.tx.send(Command::LoadIbdExchanges { biosample_guid }); - // The conversation is now complete — pick the result up in the ledger too. + // The conversation is now complete, so take the result into the ledger too. let _ = self.tx.send(Command::RefreshMatching); } Event::IbdExchanges { biosample_guid, rows } => { @@ -1179,7 +1198,8 @@ impl NavigatorApp { self.publishing = false; } Event::Queued { kind } => { - // The publish is durably queued; it sends now if online, else on reconnect. + // The publish sits in a durable queue. It sends now if the app is online, and + // on the next connection if it is not. self.status = format!("Queued {kind} for publish"); self.publishing = false; } @@ -1204,8 +1224,9 @@ impl NavigatorApp { self.pca_reference = Some((alignment_id, points)); } Event::SourceFilesVerified { missing } => { - // Do not clobber a live import's progress status with this workspace-wide sweep - // (the sweep and the import are unrelated; overwriting made imports look stalled). + // Do not let this sweep over the whole workspace write over the progress status + // of a live import. The sweep and the import have no relation, and a write over + // it made an import look stopped. if !self.importing { self.status = if missing == 0 { "All source files present".into() @@ -1301,8 +1322,8 @@ impl NavigatorApp { } Event::Error(e) => { self.status = format!("Error: {e}"); - // This failure carries no file-level cause; drop any report from a previous - // one so the status bar can't offer a "Details" that describes the wrong error. + // This failure carries no cause at file level. Drop any report from an earlier + // one, so that the status bar can not offer a "Details" for the wrong error. self.diagnosis = None; self.show_diagnosis = false; self.clear_in_flight(); @@ -1314,9 +1335,9 @@ impl NavigatorApp { Event::Diagnosed { message, report } => { self.status = format!("Error: {message}"); self.diagnosis = Some(report); - // Open it unprompted: the whole point is that the one-line message is the part - // that is not actionable, so making the user go find the detail would reproduce - // the original problem. + // Open it with no prompt. The whole point is that the one-line message is the + // part the user can not act on. To make the user go and find the detail would + // give back the original problem. self.show_diagnosis = true; self.clear_in_flight(); } @@ -1340,9 +1361,9 @@ impl NavigatorApp { self.reload_project_str(); } - /// (Re)build the Y-STR overview chart for the open project off the UI thread. Called on project - /// select and whenever a member's STR data or assigned haplogroup changes (so the grouping stays - /// in sync). No-op when no project is open. + /// Build the Y-STR overview chart for the open project, off the UI thread. A project select + /// calls it, and so does any change to the STR data of a member, or to an assigned haplogroup. + /// The groups then stay in step. It does nothing when no project is open. pub(crate) fn reload_project_str(&mut self) { if let Some(id) = self.selected_project { self.project_str_loading = true; @@ -1350,9 +1371,10 @@ impl NavigatorApp { } } - /// (Re)build the Simple-mode Subject Brief off the UI thread. Only meaningful in Simple mode; - /// called on subject select and whenever the subject's haplogroups/coverage change. No-op when - /// no subject is selected. Cheap (cache reads + pack lookups). + /// Build the Simple-mode Subject Brief, off the UI thread. It matters only in Simple mode. A + /// subject select calls it, and so does any change to the haplogroups or the coverage of the + /// subject. It does nothing when there is no selection. The cost is low: cache reads and pack + /// lookups. pub(crate) fn reload_subject_brief(&mut self) { if self.ui_mode != UiMode::Simple { return; @@ -1363,8 +1385,9 @@ impl NavigatorApp { } } - /// Open a subject from a project's report row: select it, switch to the Subjects view, and - /// remember the project so the detail header's "back to project" button can return there. + /// Open a subject from a report row of a project. It selects the subject, switches to the + /// Subjects view, and remembers the project. The "back to project" button in the detail header + /// can then return there. pub(crate) fn open_sample_from_project(&mut self, guid: SampleGuid) { let pid = self.selected_project; self.select_sample(guid); // clears return_to_project @@ -1374,14 +1397,14 @@ impl NavigatorApp { pub(crate) fn select_sample(&mut self, guid: SampleGuid) { self.selected_sample = Some(guid); - // A plain selection is not "from a project" — the project opener re-sets this after. + // A plain selection is not "from a project". The project opener sets this again after. self.return_to_project = None; self.y_sub = YSub::default(); self.y_snp_sub = YSnpSub::default(); self.mt_sub = MtSub::default(); self.auto_sub = AutoSub::default(); - // Simple mode opens on the landing synopsis for the newly-selected person, never on the - // panel the previous person happened to be left on. + // Simple mode opens on the first synopsis for the person the user just selected. It never + // opens on the panel where the user left the previous person. self.simple_panel = SimplePanel::default(); self.y_snp_names.clear(); self.y_snp_names_requested = false; @@ -1389,8 +1412,8 @@ impl NavigatorApp { self.donor_ancestry = None; self.fine_ancestry = None; self.ancient_ancestry = None; - // pca_reference is the global CHM13 centroid cloud (subject-independent) — keep it loaded - // across subject switches rather than re-fetching the asset each time. + // pca_reference is the global CHM13 centroid cloud, and it does not depend on the subject. + // Keep it in memory across subject switches, and do not read the asset again each time. self.estimating_donor_ancestry = false; self.painting = None; self.painting_running = false; @@ -1466,9 +1489,9 @@ impl NavigatorApp { let _ = self.tx.send(Command::LoadVariantSets(guid)); let _ = self.tx.send(Command::LoadChipProfiles(guid)); let _ = self.tx.send(Command::LoadMtdna(guid)); - // Subject-centric: auto-select the subject's default alignment so the analysis tabs work - // without navigating Data Sources, and load the donor-level aggregates (best ancestry + - // private-Y union across all sources). + // Subject-centric. Select the default alignment of the subject without help, so that the + // analysis tabs work with no visit to Data Sources. Also load the donor-level aggregates: + // the best ancestry, and the private-Y union over all sources. let _ = self.tx.send(Command::DefaultAlignment { biosample_guid: guid }); let _ = self.tx.send(Command::LoadDonorAncestry { biosample_guid: guid }); let _ = self @@ -1476,18 +1499,20 @@ impl NavigatorApp { .send(Command::LoadConsensusAncestryDetail { biosample_guid: guid }); // A cached chromosome painting (current for the consensus signature) shows without a click. let _ = self.tx.send(Command::LoadPainting { biosample_guid: guid }); - // Likewise a cached ROH result loads without recomputing. + // A cached ROH result also loads, and nothing computes it again. let _ = self.tx.send(Command::LoadRoh { biosample_guid: guid }); // ...and a cached archaic marker count. let _ = self.tx.send(Command::LoadArchaic { biosample_guid: guid }); let _ = self.tx.send(Command::LoadArchaicSegments { biosample_guid: guid }); let _ = self.tx.send(Command::LoadDonorPrivateY { biosample_guid: guid }); - // The Y-variant profile is *built* on explicit request (re-genotypes each alignment), but a - // previously-built snapshot loads cheaply — fetch it so the Y-DNA tab shows it immediately. + // The Y-variant profile *builds* only on an explicit request, because it genotypes each + // alignment again. But a snapshot that already exists loads at low cost, so read it, and + // the Y-DNA tab shows it at once. let _ = self.tx.send(Command::LoadYProfile { biosample_guid: guid }); - // Likewise the mtDNA consensus profile (cheap cached snapshot for the mtDNA tab). + // The mtDNA consensus profile too: a cached snapshot of low cost, for the mtDNA tab. let _ = self.tx.send(Command::LoadMtProfile { biosample_guid: guid }); - // The subject's persisted federated IBD exchange results (cheap; shown in the IBD tab). + // The persisted federated IBD exchange results of the subject. The cost is low, and the + // IBD tab shows them. let _ = self.tx.send(Command::LoadIbdExchanges { biosample_guid: guid }); // And the autosomal (diploid) consensus snapshot for the Autosomal tab. let _ = self.tx.send(Command::LoadAutosomalProfile { biosample_guid: guid }); @@ -1507,7 +1532,8 @@ impl NavigatorApp { pub(crate) fn select_alignment(&mut self, id: i64) { self.selected_alignment = Some(id); self.coverage = None; - // Ideogram regions are fetched lazily when its tab opens; reset for the new alignment. + // The code reads the ideogram regions lazily when that tab opens. Reset them for the new + // alignment. self.genome_regions = None; self.loading_regions = false; self.regions_attempted = None; @@ -1551,9 +1577,9 @@ impl NavigatorApp { self.coverage = None; } - /// Drop every in-flight spinner after a failure. A command failure is reported by whichever - /// worker arm was running, but the UI has no way to tell which flag that arm owned, so all of - /// them clear — a stuck spinner outlives the error message that explains it. + /// Drop every spinner in progress after a failure. Whichever worker arm was active reports a + /// command failure, but the UI has no way to tell which flag that arm owned. So all of them + /// clear. A spinner that sticks outlives the error message that explains it. fn clear_in_flight(&mut self) { self.cancelling = false; self.running = false; diff --git a/crates/navigator-ui/src/ui/ibd.rs b/crates/navigator-ui/src/ui/ibd.rs index f3ef8dbd..9cc85b89 100644 --- a/crates/navigator-ui/src/ui/ibd.rs +++ b/crates/navigator-ui/src/ui/ibd.rs @@ -3,9 +3,10 @@ use super::*; impl NavigatorApp { - /// Chip-compatible IBD: pick two sources (each a WGS alignment or an imported chip) and compare - /// over the multi-build IBD panel — the chip↔WGS / chip↔chip volume path (build-aware, asset- - /// backed). Needs the `ibd_panel` asset built; a chip source needs its raw file (source_path). + /// IBD that works with a chip. Pick two sources, each a WGS alignment or an imported chip, and + /// compare them over the multi-build IBD panel. This is the volume path for chip↔WGS and + /// chip↔chip, it knows the build, and an asset backs it. It needs the `ibd_panel` asset, and a + /// chip source needs its raw file (source_path). pub(crate) fn genotyping_section(&mut self, ui: &mut egui::Ui) { let mut sources: Vec<(navigator_app::IbdSource, String)> = Vec::new(); for a in &self.all_alignments { @@ -64,7 +65,8 @@ impl NavigatorApp { self.render_ibd_result(ui); } - /// The identity-verification verdict (shared by the per-source + subject-level compare paths). + /// The identity-verification verdict. The compare path for each source, and the one at subject + /// level, share it. fn render_identity(&self, ui: &mut egui::Ui) { let Some(v) = &self.identity else { return }; let (txt, col) = match v.status { @@ -90,8 +92,8 @@ impl NavigatorApp { }); } - /// Render the current IBD comparison result (summary line + segment table), if any. Shared by the - /// per-source picker and the subject-level consensus comparison. + /// Draw the current IBD comparison result, if there is one: a summary line and a segment table. + /// The picker of each source, and the consensus comparison at subject level, share it. fn render_ibd_result(&mut self, ui: &mut egui::Ui) { // Clone out of the borrow so the export button can touch `self.status` / `self.tx` below. let Some(cmp) = self.ibd_result.clone() else { return }; @@ -106,7 +108,8 @@ impl NavigatorApp { if cmp.segments.is_empty() { return; } - // Per-chromosome segment ideogram (true chr lengths when genome regions are loaded). + // A segment ideogram for each chromosome. It uses true chromosome lengths when the genome + // regions are in memory. ui.add_space(6.0); ui.label(egui::RichText::new(self.tr("ibd.segmentMap")).strong().small()); let regions = self.genome_regions.as_ref().map(|(_, r)| r.as_ref()); @@ -149,19 +152,22 @@ impl NavigatorApp { }); } - /// Subject-level IBD: compare this subject's autosomal consensus against another subject's — the - /// pooled-genotype path (no per-source genotyping). A near-complete match is the dedup/identity - /// signal (read off the relationship). - /// The comparison target is picked with a *Change* reveal — current choice, then a filter over a - /// virtualized list — rather than a dropdown. A `ComboBox` builds a widget per entry every frame - /// its popup is open, and this list is every other subject in the workspace; at 10k that is a - /// stall on each frame. The same reason the Matching tab's subject picker is shaped this way. + /// Subject-level IBD: compare the autosomal consensus of this subject against that of another + /// subject. This is the pooled-genotype path, and it genotypes no single source. A match that + /// is almost complete is the dedup and identity signal, which you read off the relationship. + /// + /// A *Change* reveal picks the comparison target: it shows the current choice, then a filter + /// over a virtualized list. It is not a dropdown. A `ComboBox` builds one widget for each entry + /// on every frame its popup is open, and this list is every other subject in the workspace. At + /// 10k that stops each frame. The subject picker of the Matching tab has this shape for the + /// same reason. pub(crate) fn consensus_ibd_section(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { if !self.all_biosamples.iter().any(|b| b.guid != guid) { ui.label(egui::RichText::new(self.tr("hint.ibdNoOtherSubjects")).weak()); return; } - // A lookup, not a copy of every subject — the old build allocated the whole roster per frame. + // A lookup, and not a copy of every subject. The old build allocated the whole roster on + // each frame. let sel = self .ibd_other_subject .and_then(|g| self.find_subject(g).map(|b| b.donor_identifier.clone())) @@ -191,7 +197,8 @@ impl NavigatorApp { b: self.ibd_other_subject.unwrap(), }); } - // Same-individual check (duplicate detection) over the same pooled consensus — no panel. + // The same-individual check (duplicate detection) over the same pooled consensus, with + // no panel. if ui .add_enabled(ready, egui::Button::new(self.tr("ibd.verifyIdentity"))) .clicked() @@ -217,10 +224,10 @@ impl NavigatorApp { self.render_ibd_result(ui); } - /// The revealed filter + virtualized subject list behind [`Self::consensus_ibd_section`]'s - /// *Change* button. Only the visible rows are built, so the cost is independent of workspace - /// size; the filtered `Vec` is assembled from immutable reads first so the scroll closure - /// borrows only locals. + /// The filter and the virtualized subject list that the *Change* button of + /// [`Self::consensus_ibd_section`] reveals. It builds only the visible rows, so the cost does + /// not depend on the size of the workspace. It assembles the filtered `Vec` from immutable + /// reads first, so the scroll closure borrows only locals. fn ibd_other_picker(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { if !self.ibd_other_picking { return; @@ -268,9 +275,10 @@ impl NavigatorApp { } } - /// This subject's completed federated exchanges. Discovery and consent are **not** here — they - /// are account-scoped and live in the top-level Matching tab; what this card answers is "what - /// did the network find for *this person*". Flows into the page scroll (no nested ScrollArea). + /// The federated exchanges of this subject that are complete. Discovery and consent are **not** + /// here. They are account-scoped, and they live in the top-level Matching tab. This card + /// answers one question: "what did the network find for *this person*". It goes into the page + /// scroll, with no nested ScrollArea. pub(crate) fn exchange_section(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { if self.account.is_none() { ui.label(self.tr("network.signInRequired")); @@ -314,8 +322,9 @@ impl NavigatorApp { } else { ui.colored_label(egui::Color32::from_rgb(200, 90, 90), self.tr("exchange.agreedNo")); } - // Open an encrypted DM with this match (social 3a) — sends a DM request and - // jumps to Community → Messages, where the conversation appears once accepted. + // Open an encrypted DM with this match (social 3a). It sends a DM request + // and goes to Community → Messages, where the conversation appears after + // the other side accepts. if ui.button(self.tr("dm.message")).clicked() { message_partner = Some(r.partner_did.clone()); } @@ -328,14 +337,15 @@ impl NavigatorApp { self.community_tab = CommunityTab::Messages; self.dm_loaded = false; // force a fresh inbox/conversation load on entry } - // Per-tab AI explanation of these matches (M5) — additive, below the structured table. + // An AI explanation of these matches on this tab (M5). It is additive, and it sits + // below the structured table. ui.add_space(6.0); self.ai_explain(ui, guid, SignalKind::Ibd); } } - /// mtDNA haplogroup assigned directly from the alignment's chrM — the standalone counterpart - /// to the Y-DNA section's "Assign Y haplogroup". + /// An mtDNA haplogroup that comes directly from the chrM of the alignment. It is the standalone + /// equivalent of "Assign Y haplogroup" in the Y-DNA section. pub(crate) fn mt_haplogroup_section(&mut self, ui: &mut egui::Ui, alignment_id: i64) { let has_bam = self.alignment_has_bam(alignment_id); ui.horizontal(|ui| { @@ -361,7 +371,7 @@ impl NavigatorApp { /// De-novo haploid SNP calls for a specific `contig` (chrY on the Y-DNA tab, chrM on mtDNA). pub(crate) fn denovo_section(&mut self, ui: &mut egui::Ui, alignment_id: i64, contig: &str) { - // Reference is resolved from the build on demand, so only the BAM is required. + // The code resolves the reference from the build on demand, so it needs only the BAM. let has_bam = self.alignment_has_bam(alignment_id); ui.horizontal(|ui| { diff --git a/crates/navigator-ui/src/ui/matching.rs b/crates/navigator-ui/src/ui/matching.rs index 10f82afb..e5745bb9 100644 --- a/crates/navigator-ui/src/ui/matching.rs +++ b/crates/navigator-ui/src/ui/matching.rs @@ -1,12 +1,12 @@ -//! `impl NavigatorApp` — the **Matching** tab: federated-IBD discovery and consent. +//! `impl NavigatorApp`: the **Matching** tab, for federated-IBD discovery and consent. //! -//! This is the front door for machinery that was already complete but had no coherent surface. It -//! is account-scoped, not subject-scoped: a conversation is keyed by our DID and the broker's -//! request URI, and a local subject is chosen only when it is time to exchange dosages. The -//! subject's own IBD tab keeps the *results* for that person; this tab owns the conversation. +//! This is the front door for code that was already complete but had no coherent surface. It is +//! account-scoped, and not subject-scoped. The key of a conversation is our DID and the request URI +//! of the broker. A local subject comes into it only when it is time to exchange dosages. The IBD +//! tab of the subject keeps the *results* for that person, and this tab owns the conversation. //! -//! Three sub-tabs follow one conversation's life — a ranked candidate (Suggestions) becomes a -//! request awaiting consent (Requests) and then a result (Results). +//! Three sub-tabs follow the life of one conversation. A ranked candidate (Suggestions) becomes a +//! request that waits for consent (Requests), and then a result (Results). use super::*; impl NavigatorApp { @@ -52,14 +52,15 @@ impl NavigatorApp { }); } - /// Which local subject an exchange speaks for. Explicit here rather than implied by whichever - /// subject tab happened to be open — the same account can hold several people's data, and - /// sending the wrong one's genotypes is not a recoverable mistake. + /// Which local subject an exchange speaks for. It is explicit here, and not implied by whatever + /// subject tab was open. One account can hold the data of more than one person. To send the + /// genotypes of the wrong one is a mistake nobody can undo. /// - /// Shown as the current choice plus a *Change* toggle rather than a dropdown: a workspace can - /// hold tens of thousands of subjects, and a `ComboBox` builds a widget per entry every frame - /// its popup is open. The reveal is the same filter-then-virtualized-list the subjects rail - /// uses, so the cost is the number of rows on screen, not the number in the workspace. + /// This shows the current choice, plus a *Change* toggle. It is not a dropdown. A workspace can + /// hold tens of thousands of subjects, and a `ComboBox` builds one widget for each entry on + /// every frame its popup is open. The reveal is the same filter over a virtualized list that + /// the subjects rail uses. So the cost is the number of rows on the screen, and not the number + /// in the workspace. fn matching_subject_picker(&mut self, ui: &mut egui::Ui) { if self.matching_subject.is_none() { self.matching_subject = self.selected_sample.or(self.all_biosamples.first().map(|b| b.guid)); @@ -128,8 +129,9 @@ impl NavigatorApp { } } - /// Ranked candidates from the AppView's engine. Pseudonymous: a candidate is an opaque sample - /// handle plus the signals behind its score — never a DID, never a name. + /// Ranked candidates from the engine of the AppView. They are pseudonymous: a candidate is an + /// opaque sample handle, plus the signals behind its score. It is never a DID, and never a + /// name. fn matching_suggestions(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { if ui @@ -149,8 +151,8 @@ impl NavigatorApp { }); ui.label(egui::RichText::new(self.tr("network.note")).weak().small()); - // Requesting an introduction and dismissing both remove a row, so filter against what the - // ledger already knows rather than trusting the fetched list to be current. + // A request for an introduction removes a row, and so does a dismiss. So filter against + // what the ledger already knows, and do not trust the fetched list to be current. let requested: std::collections::HashSet = self .matching .iter() @@ -221,7 +223,7 @@ impl NavigatorApp { } } - /// Every conversation that has not produced a result yet, with the action it is waiting on. + /// Every conversation that has no result yet, with the action it waits on. fn matching_requests(&mut self, ui: &mut egui::Ui) { let rows: Vec = self .matching @@ -249,8 +251,8 @@ impl NavigatorApp { ui.strong(""); ui.end_row(); for e in &rows { - // Before mutual consent there is no partner identity to show — the broker is - // symmetric-blind by design, so the request URI is all either side has. + // Before mutual consent there is no partner identity to show. The broker is + // symmetric-blind by design, so the request URI is all that either side has. match &e.partner_did { Some(did) => { let short: String = did.chars().take(20).collect(); @@ -370,8 +372,8 @@ impl NavigatorApp { ui.colored_label(WARN_RED, self.tr("exchange.agreedNo")); } // Whether the AppView has this match on the discovery graph. Not every result - // can be reported: a disputed summary, or a conversation with no AppView sample - // handles, is deliberately kept private. + // can go there. A summary in dispute stays private, and so does a conversation + // with no AppView sample handles. That is deliberate. if e.attested { ui.colored_label(OK_GREEN, self.tr("matching.reportedYes")) .on_hover_text(self.tr("matching.reportedHint")); @@ -413,7 +415,8 @@ impl NavigatorApp { /// Agreement / success green, matching the exchange card's existing verdict colour. const OK_GREEN: egui::Color32 = egui::Color32::from_rgb(60, 160, 60); -/// Disagreement / failure red (softer than [`DANGER`], which is reserved for destructive buttons). +/// The red for disagreement and failure. It is softer than [`DANGER`], which is only for a +/// destructive button. const WARN_RED: egui::Color32 = egui::Color32::from_rgb(200, 90, 90); /// i18n key for a direction. @@ -424,7 +427,7 @@ fn direction_key(d: navigator_app::MatchingDirection) -> &'static str { } } -/// i18n key for the tooltip explaining what a status is waiting on. +/// i18n key for the tooltip that says what a status waits on. fn status_hint_key(s: navigator_app::MatchingStatus) -> &'static str { use navigator_app::MatchingStatus as S; match s { diff --git a/crates/navigator-ui/src/ui/mod.rs b/crates/navigator-ui/src/ui/mod.rs index 17f88953..c95468a6 100644 --- a/crates/navigator-ui/src/ui/mod.rs +++ b/crates/navigator-ui/src/ui/mod.rs @@ -39,7 +39,7 @@ use rowcache::{ReportRowCache, SubjectRowCache, VariantRows}; #[derive(Default)] struct Forms { - /// Whether the inline "Add New Subject" form is expanded. + /// True when the inline "Add New Subject" form is open. show_add_subject: bool, project_name: String, project_admin: String, @@ -72,9 +72,10 @@ enum Nav { Dashboard, Subjects, Projects, - /// Federated IBD discovery + consent. Top-level rather than a subject tab because a matching - /// conversation belongs to the *account* (it is keyed by our DID and the broker's request URI), - /// not to any one biosample — the subject is only chosen when it is time to exchange dosages. + /// Federated IBD discovery and consent. It is top-level, and not a subject tab, because a + /// matching conversation belongs to the *account*. Its key is our DID and the request URI of + /// the broker, and it belongs to no one biosample. The subject comes into it only when it is + /// time to exchange dosages. Matching, Community, } @@ -137,7 +138,7 @@ impl CommunityTab { ]; } -/// Sub-tabs of the project detail panel (the member list vs the per-sample analysis report). +/// Sub-tabs of the project detail panel: the member list, and the analysis report of each sample. #[derive(Clone, Copy, PartialEq, Eq, Default)] enum ProjectTab { #[default] @@ -155,11 +156,11 @@ impl ProjectTab { ]; } -/// Sub-tabs of the Settings dialog, grouping preferences by locality of concern (general appearance -/// vs. server connection vs. ancestry-painter calibration vs. AI vs. reference genomes vs. one-off -/// tools vs. read-only advanced info). -// `pub(crate)` (unlike its sibling tab enums): `open_settings` deep-links into a specific tab, so -// the type appears in a `pub(crate)` signature. +/// Sub-tabs of the Settings dialog. They group the preferences by what each one covers: general +/// appearance, server connection, ancestry-painter calibration, AI, reference genomes, one-off +/// tools, and read-only advanced info. +// `pub(crate)`, and its sibling tab enums are not: `open_settings` deep-links into a specific tab, +// so the type appears in a `pub(crate)` signature. #[derive(Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum SettingsTab { #[default] @@ -221,8 +222,9 @@ impl DetailTab { } } - /// `(tab, i18n key)` in display order. The DNA-type tabs (Y / mt / Autosomal / Ancestry) show the - /// subject's *consensus* across all sources; `Sources` is the per-sequencing-result hub. + /// `(tab, i18n key)` in display order. The DNA-type tabs (Y, mt, Autosomal, Ancestry) show the + /// *consensus* of the subject over all sources. `Sources` is the hub for each sequencing + /// result. const ALL: [(DetailTab, &'static str); 7] = [ (DetailTab::Overview, "detail.overview"), (DetailTab::YDna, "detail.ydna"), @@ -236,11 +238,12 @@ impl DetailTab { /// Sections of the Simple-mode subject view, in rail order. /// -/// Simple mode used to be one long vertical scroll of every section at once; this splits it into -/// dedicated panels reached from a left rail, with [`SimplePanel::Story`] as the landing synopsis. -/// The order encodes the narrative the view is trying to tell: who you are, then the two lineages -/// that reach furthest back, then the autosomal ancestry (deep origins → recent populations), then -/// living relatives, then the test the whole thing rests on. +/// Simple mode used to be one long vertical scroll, with every section at once. This splits it into +/// panels that a left rail reaches, and [`SimplePanel::Story`] is the first synopsis. +/// +/// The order holds the narrative the view tells. First who you are, then the two lineages that +/// reach furthest back, then the autosomal ancestry (deep origins → recent populations). Then come +/// the relatives who are alive, and last the test the whole thing rests on. #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] enum SimplePanel { #[default] @@ -255,10 +258,10 @@ enum SimplePanel { impl SimplePanel { /// `(panel, icon, i18n label key)` in rail order. /// - /// Every icon here must be covered by egui's **Proportional** font family (Ubuntu-Light + - /// NotoEmoji + emoji-icon-font). That set is much narrower than it looks: `◆` U+25C6, `⚭` U+26AD, - /// `✓` U+2713 and `🧬` U+1F9EC are all absent and render as tofu boxes. `icon_glyphs_are_renderable` - /// pins this down — add an icon there when you add one here. + /// The **Proportional** font family of egui must cover every icon here (Ubuntu-Light, + /// NotoEmoji, emoji-icon-font). That set is much narrower than it looks: `◆` U+25C6, `⚭` + /// U+26AD, `✓` U+2713 and `🧬` U+1F9EC are all absent, and each one draws as a tofu box. + /// `icon_glyphs_are_renderable` pins this down, so add an icon there when you add one here. const ALL: [(SimplePanel, &'static str, &'static str); 6] = [ (SimplePanel::Story, "📖", "simple.panel.story"), (SimplePanel::Paternal, "♂", "simple.panel.paternal"), @@ -269,7 +272,8 @@ impl SimplePanel { ]; } -/// Y-DNA sub-tabs: compact haplogroup landing, the heavy SNP surface, and STR (separated from SNP). +/// Y-DNA sub-tabs: a compact haplogroup first view, the heavy SNP surface, and STR, which is +/// separate from SNP. #[derive(Clone, Copy, PartialEq, Eq, Default)] enum YSub { #[default] @@ -330,7 +334,7 @@ impl AutoSub { ]; } -/// Which Y-STR report view is shown in the Y-DNA tab. +/// Which Y-STR report view the Y-DNA tab shows. #[derive(Clone, Copy, PartialEq, Eq, Default)] enum StrReportView { /// FTDNA/YSEQ-style tier-grouped marker table. @@ -338,7 +342,7 @@ enum StrReportView { ByPanel, /// Flat, filterable marker table. AllMarkers, - /// Cross-panel consensus value per marker. + /// The cross-panel consensus value of each marker. Consensus, } @@ -353,8 +357,8 @@ struct RefRow { verify: String, } -/// Editable Settings-dialog state (loaded from `AppSettings`; reference rows arrive via -/// `Event::ReferenceSettings`). +/// The Settings-dialog state the user can edit. It loads from `AppSettings`, and the reference rows +/// arrive through `Event::ReferenceSettings`. #[derive(Clone)] struct SettingsForm { appview_url: String, @@ -432,12 +436,13 @@ fn resolved_ui_scale() -> f32 { AppSettings::load().ui_scale.unwrap_or(1.0).clamp(0.5, 3.0) } -/// One-shot auto UI-scale probe (the "behave like a native app" default). On the first frame the -/// monitor size is known, derive a zoom when the OS reports a ~1.0 scale factor on a clearly -/// high-resolution panel (e.g. native-4K, where macOS itself does not up-scale). A Retina / scaled -/// display (native ppp > 1) is already handled by egui's native scaling, so it is left at 1.0. Skipped -/// entirely when a manual scale is persisted (`probed` starts `true`). The result fills the Settings -/// slider but is not persisted until the user saves — re-probed each launch otherwise. +/// A one-shot auto UI-scale probe: the "behave like a native app" default. On the first frame that +/// knows the monitor size, it derives a zoom when the OS reports a ~1.0 scale factor on a clearly +/// high-resolution panel. Native 4K is the example, where macOS itself does not scale up. The +/// native scaling of egui already controls a Retina or scaled display, which has a native ppp above +/// 1, so this leaves that at 1.0. It does nothing at all when a manual scale is on disk, because +/// `probed` then starts `true`. The result fills the Settings slider, and nothing persists it until +/// the user saves, so it probes again on each launch. fn run_auto_scale(probed: &mut bool, form: &mut SettingsForm, ctx: &egui::Context) { if *probed { return; @@ -459,7 +464,7 @@ fn run_auto_scale(probed: &mut bool, form: &mut SettingsForm, ctx: &egui::Contex } } -/// In-flight full-analysis state, driving the modal dialog. +/// The state of a full analysis in progress. It drives the modal dialog. #[derive(Clone)] struct AnalysisModal { step: usize, @@ -467,23 +472,23 @@ struct AnalysisModal { label: String, detail: String, fraction: f32, - /// egui time (seconds) when this step began — for the elapsed-time display. + /// The egui time in seconds when this step began, for the elapsed-time display. started: f64, } -/// A realignment in flight, or the result of the last one. +/// A realignment in progress, or the result of the last one. /// -/// Deliberately card state rather than a modal: a realignment runs for hours, and a dialog that -/// owns the screen for that long stops the user doing anything else with a workspace they are -/// perfectly able to keep using. The card sits with the alignment it belongs to and updates in -/// place. +/// This is card state on purpose, and not a modal. A realignment runs for hours. A dialog that owns +/// the screen for that long stops the user from a workspace they can perfectly well keep open. The +/// card sits with the alignment it belongs to, and it updates in place. #[derive(Clone)] struct RealignState { - /// The source alignment this job belongs to — cards for other alignments ignore it. + /// The source alignment this job belongs to. The card of another alignment ignores it. alignment_id: i64, - /// The subject it belongs to. Simple mode's card is about a *person*, not an alignment, so it - /// has to match on this: with only the alignment id to go on, a page open on subject A during a - /// job on subject B told A their genome was being rebuilt. + /// The subject it belongs to. The card of Simple mode is about a *person*, and not an + /// alignment, so it must match on this. With only the alignment id to go on, a page open on + /// subject A during a job on subject B told A the wrong thing. It reported a job at work on + /// their genome. biosample_guid: Option, step: usize, total: usize, @@ -505,7 +510,8 @@ enum RealignFinished { Failed(String), } -/// Editable copy of a project, driving the project Edit modal (Some ⇒ the dialog is shown). +/// A copy of a project the user can edit. It drives the project Edit modal, and `Some` shows the +/// dialog. #[derive(Clone)] struct EditProject { id: i64, @@ -514,8 +520,8 @@ struct EditProject { administrator: String, } -/// Editable copy of a sequence run, driving the run Edit modal (Some ⇒ the dialog is shown). -/// Read-metric columns are not editable here, so they are not carried. +/// A copy of a sequence run the user can edit. It drives the run Edit modal, and `Some` shows the +/// dialog. The read-metric columns take no edit here, so this does not carry them. #[derive(Clone)] struct EditRun { id: i64, @@ -527,8 +533,9 @@ struct EditRun { sequencing_facility: String, } -/// Drives the destructive merge-sequence-runs modal (Some ⇒ shown). `secondary` is the run that -/// will be emptied + deleted; `primary` is the chosen merge target (its picker default). +/// This drives the destructive merge-sequence-runs modal, and `Some` shows it. `secondary` is the +/// run this empties and then deletes. `primary` is the chosen merge target, and the default of its +/// picker. #[derive(Clone)] struct MergeRuns { guid: SampleGuid, @@ -536,7 +543,8 @@ struct MergeRuns { primary: Option, } -/// Editable copy of an alignment, driving the alignment Edit modal (Some ⇒ the dialog is shown). +/// A copy of an alignment the user can edit. It drives the alignment Edit modal, and `Some` shows +/// the dialog. #[derive(Clone)] struct EditAlignment { id: i64, @@ -546,7 +554,7 @@ struct EditAlignment { variant_caller: String, } -/// Editable copy of a subject, driving the Edit modal (Some ⇒ the dialog is shown). +/// A copy of a subject the user can edit. It drives the Edit modal, and `Some` shows the dialog. #[derive(Clone)] struct EditSubject { guid: SampleGuid, @@ -566,9 +574,10 @@ struct EditKit { external_id: String, } -/// Editable copy of an MDKA (most distant known ancestor), driving the MDKA edit modal (Some ⇒ -/// shown). All fields are strings for editing; years/coords are parsed on save (blank ⇒ cleared). -/// `lineage` is fixed when the modal opens (Y/Mt/Auto). Upsert is keyed on `(guid, lineage)`. +/// A copy of an MDKA (most distant known ancestor) the user can edit. It drives the MDKA edit +/// modal, and `Some` shows it. Every field is a string, so that the user can edit it, and a save +/// parses the years and the coordinates. A blank field clears its column. `lineage` does not change +/// after the modal opens (Y, Mt or Auto). The key of the upsert is `(guid, lineage)`. #[derive(Clone)] struct EditMdka { guid: SampleGuid, @@ -583,8 +592,8 @@ struct EditMdka { notes: String, } -/// A data-source row pending delete confirmation. The `label` is shown in the confirm dialog; -/// the variant carries the ids the worker command needs (and the parent id to refresh). +/// A data-source row that waits for a delete confirmation. The confirm dialog shows the `label`, +/// and the variant carries the ids the worker command needs, plus the parent id to refresh. #[derive(Clone)] enum DataDelete { Run { id: i64, guid: SampleGuid, label: String }, @@ -647,15 +656,17 @@ struct YReport { lineage: Vec, } -/// Initial window inner size (egui points) on a first run / when nothing is remembered. The reworked -/// layout (subjects table + detail panel + action bar) needs room; the eframe default is far too small. +/// The first inner size of the window, in egui points, on a first run, and when the app remembers +/// nothing. The new layout needs room, because it holds the subjects table, the detail panel and +/// the action bar. The eframe default is far too small. pub(crate) const DEFAULT_WINDOW: [f32; 2] = [1360.0, 900.0]; -/// Minimum window inner size — a sane floor so the layout never collapses. +/// The minimum inner size of the window: a sane floor, so that the layout never collapses. pub(crate) const MIN_WINDOW: [f32; 2] = [1024.0, 680.0]; -/// Fit a desired window size (egui points) to the monitor, leaving a margin for the menu bar / dock / -/// taskbar, and never below `min`. An over-large remembered size (e.g. from a bigger display) shrinks -/// to fit; a size that already fits is returned unchanged. Pure, so the fit logic is unit-tested. +/// Fit a wanted window size, in egui points, to the monitor. It leaves a margin for the menu bar, +/// the dock and the taskbar, and it never goes below `min`. A remembered size that is too large, +/// for example from a bigger display, shrinks to fit. A size that already fits comes back +/// unchanged. It is pure, so a unit test covers the fit. pub(crate) fn fit_window_to_monitor(desired: [f32; 2], monitor: [f32; 2], min: [f32; 2]) -> [f32; 2] { let max_w = (monitor[0] * 0.98).max(min[0]); let max_h = (monitor[1] * 0.94).max(min[1]); @@ -665,73 +676,77 @@ pub(crate) fn fit_window_to_monitor(desired: [f32; 2], monitor: [f32; 2], min: [ pub struct NavigatorApp { tx: UnboundedSender, rx: Receiver, - /// In-flight full-analysis progress (Some ⇒ the modal dialog is shown). + /// The progress of a full analysis in progress. `Some` shows the modal dialog. analysis: Option, - /// The running (or last finished) realignment; see [`RealignState`]. + /// The realignment that runs now, or the last one that ended. See [`RealignState`]. realign: Option, - /// Simple mode's pending realignment confirmation — the same [`RealignOffer`] the brief - /// supplied, rather than a tuple re-spelling its two fields. + /// The realignment confirmation of Simple mode, while it waits. It is the same [`RealignOffer`] + /// the brief gave, and not a tuple that writes out its two fields again. /// - /// Simple mode gets a confirmation step where Advanced does not, and the asymmetry is - /// deliberate: the Advanced card sits among alignment internals and states its cost in a - /// paragraph its reader is equipped to weigh. Simple mode's reader has been shown a story about - /// their ancestors, and should not be able to commit the machine to four hours and 276 GB by - /// misjudging one button. + /// Simple mode gets a confirmation step, and Advanced does not. That difference is deliberate. + /// The Advanced card sits among alignment internals, and it states its cost in a paragraph its + /// reader can weigh. The reader of Simple mode has seen a story about their ancestors. One + /// button, read wrong, must not commit the machine to four hours and 276 GB. simple_realign_confirm: Option, - /// Set the moment Cancel is clicked, cleared when the run actually ends. + /// The code sets this the moment the user clicks Cancel, and clears it when the run ends. /// - /// Cancellation is cooperative: the walkers stop at their next check, so there is always a gap - /// between the click and the run ending. Without this the UI gave no acknowledgement at all — - /// spinner, timer and progress bar carried on and the button stayed live — which is why the - /// button read as broken rather than as working-on-it. + /// Cancellation is cooperative. The walkers stop at their next check, so there is always a gap + /// between the click and the end of the run. Without this the UI gave no acknowledgement at + /// all: the spinner, the timer and the progress bar all continued, and the button stayed live. + /// That is why the button read as broken, and not as busy. cancelling: bool, - /// Subject being edited (Some ⇒ the Edit modal is shown). + /// The subject the user edits. `Some` shows the Edit modal. edit_subject: Option, - /// Vendor-id (kit) association being added (Some ⇒ the add-kit modal is shown). + /// The vendor-id (kit) association the user adds. `Some` shows the add-kit modal. edit_kit: Option, - /// MDKA being edited/added (Some ⇒ the MDKA modal is shown). + /// The MDKA the user edits or adds. `Some` shows the MDKA modal. edit_mdka: Option, - /// Subject pending delete confirmation (Some ⇒ the confirm dialog is shown). + /// The subject that waits for a delete confirmation. `Some` shows the confirm dialog. confirm_delete: Option, - /// Subject pending "clear all data" confirmation (Some ⇒ the confirm dialog is shown). + /// The subject that waits for a "clear all data" confirmation. `Some` shows the confirm + /// dialog. confirm_clear: Option, - /// Subject pending "reset haplogroup placement" confirmation (Some ⇒ the confirm dialog is shown). + /// The subject that waits for a "reset haplogroup placement" confirmation. `Some` shows the + /// confirm dialog. confirm_reset_haplo: Option, /// The last batch-import summary, shown in a modal until dismissed. batch_import: Option, /// Y-STR-from-sequence concordance for the selected subject: `(guid, source alignment, rows)`. str_concordance: Option<(SampleGuid, i64, Vec)>, - /// Whether a Y-STR-from-sequence call is in flight (the heavy first pass). + /// True while a Y-STR-from-sequence call is in progress (the heavy first pass). str_running: bool, /// Cross-subject Y matches for the selected subject: `(guid, ranked matches)`. Gap §2. y_matches: Option<(SampleGuid, Vec)>, - /// Whether a Y-match search is in flight. + /// True while a Y-match search is in progress. y_matches_running: bool, /// Project filter for the Y-match search (None ⇒ whole workspace). y_match_project: Option, /// Text filter over the Y-match table (by donor / haplogroup). y_match_query: String, - /// Data-source row pending delete confirmation (Some ⇒ the confirm dialog is shown). + /// The data-source row that waits for a delete confirmation. `Some` shows the confirm dialog. confirm_data_delete: Option, - /// Subject being assigned to a project: (subject, selected project or None). Some ⇒ picker shown. + /// The subject the user assigns to a project: (subject, selected project or None). `Some` + /// shows the picker. assign_project: Option<(SampleGuid, Option)>, - /// Project being edited (Some ⇒ the project Edit modal is shown). + /// The project the user edits. `Some` shows the project Edit modal. edit_project: Option, - /// Project pending delete confirmation: (id, name). Some ⇒ the confirm dialog is shown. + /// The project that waits for a delete confirmation: (id, name). `Some` shows the confirm + /// dialog. confirm_delete_project: Option<(i64, String)>, - /// Sequence run being edited (Some ⇒ the run Edit modal is shown). + /// The sequence run the user edits. `Some` shows the run Edit modal. edit_run: Option, merge_runs: Option, /// Whether the read-only Y-profile source-audit modal is open (reads the cached `y_profile`). audit_y_profile: bool, - /// Alignment being edited (Some ⇒ the alignment Edit modal is shown). + /// The alignment the user edits. `Some` shows the alignment Edit modal. edit_alignment: Option, /// Current frame's egui time (seconds), captured at the top of `update`. frame_time: f64, - /// Window-size persistence (all in egui points): the current inner size, the last size written to - /// [`AppSettings`], and when it last changed (`frame_time` seconds) for debounced saving. - /// `window_restored` guards the one-time restore-and-fit-to-screen; `startup_frames` lets it wait - /// until the UI scale (zoom) has settled so the sizes are in a stable unit. + /// Window-size persistence, all in egui points. It holds the current inner size, the last size + /// that went to [`AppSettings`], and when it last changed (`frame_time` seconds), for a + /// debounced save. + /// `window_restored` guards the one-time restore and fit to the screen. `startup_frames` lets + /// that wait until the UI scale (zoom) settles, so that the sizes are in a stable unit. window_size: Option<[f32; 2]>, saved_window_size: Option<[f32; 2]>, window_size_changed_at: f64, @@ -740,44 +755,45 @@ pub struct NavigatorApp { /// Focused subject remembered from the last session (GUID string), applied once the subject list /// loads (`.take()`n so it restores only once). pending_restore_subject: Option, - /// Signature of the last navigation state persisted to [`AppSettings`] (view | subject | tab), so - /// a save fires only when it actually changes. + /// The signature of the last navigation state that went to [`AppSettings`] (view | subject | + /// tab), so that a save fires only on a real change. saved_ui_sig: Option, /// Selected primary navigation tab. nav: Nav, /// Interface mode: Simple (casual single-person briefs) vs. Advanced (full power-user UI). ui_mode: UiMode, - /// Whether the mode was explicitly pinned (env / settings / user toggle). When `false` the - /// first-run workspace heuristic may still adjust it as data loads. + /// True when something pinned the mode explicitly: the environment, the settings, or a user + /// toggle. With `false`, the first-run workspace heuristic can still change it as data loads. ui_mode_pinned: bool, /// Precomputed Simple-mode brief for the selected subject `(guid, brief)`; `None` until built. subject_brief: Option<(SampleGuid, SubjectBrief)>, - /// Whether a Subject Brief build is in flight. + /// True while a Subject Brief build is in progress. subject_brief_loading: bool, /// Free-text filter over the Simple-mode "My DNA" subject selector (matches the donor name). simple_subject_filter: String, - /// Which Simple-mode panel the left rail has open. Reset to the landing synopsis on every - /// subject switch — each person's view starts from their story, not from wherever the last - /// person's was left. + /// Which Simple-mode panel the left rail has open. It goes back to the first synopsis on every + /// subject switch. The view of each person starts from their story, and not from the panel that + /// held the last person. simple_panel: SimplePanel, - /// Whether the local-LLM "AI assistant" is enabled (cached from settings; gates "Polish with AI"). + /// True when the local-LLM "AI assistant" is on. It comes from the settings cache, and it gates + /// "Polish with AI". ai_enabled: bool, /// AI-assisted narration of the selected subject's brief `(guid, narration)`; `None` until run. brief_narration: Option<(SampleGuid, NarratedBrief)>, - /// Live narration text accumulating while it streams `(guid, text)`; cleared when the final - /// narration arrives. + /// The live narration text, which grows while the stream runs, as `(guid, text)`. It clears + /// when the final narration arrives. narration_stream: Option<(SampleGuid, String)>, - /// Whether a brief narration request is in flight. + /// True while a brief narration request is in progress. narrating: bool, /// "Ask my results" chat history for the selected subject (cleared on subject switch). chat_history: Vec, - /// The chat input box + whether an answer is in flight. + /// The chat input box, and whether an answer is in progress. chat_input: String, chat_pending: bool, - /// Per-tab "Explain this" (M5) state for the selected subject, all cleared on subject switch: - /// the finalized explanations keyed by `(guid, signal)`, the live stream buffer for the one in - /// flight, and which `(guid, signal)` is currently being narrated (`None` = idle; only one runs - /// at a time, sharing the single worker). + /// The "Explain this" (M5) state of each tab, for the selected subject. A subject switch clears + /// all of it. It holds the final explanations, with `(guid, signal)` as the key. It also holds + /// the live stream buffer for the one in progress, and which `(guid, signal)` the model narrates + /// now. `None` means idle. Only one runs at a time, because they share the one worker. signal_narration: Vec<(SampleGuid, SignalKind, NarratedBrief)>, signal_stream: Option<(SampleGuid, SignalKind, String)>, signal_narrating: Option<(SampleGuid, SignalKind)>, @@ -787,7 +803,8 @@ pub struct NavigatorApp { lang: crate::i18n::Lang, /// Dark (default) vs light theme. dark_mode: bool, - /// Whether the one-shot auto-UI-scale probe has run (skipped when a manual scale is persisted). + /// True after the one-shot auto-UI-scale probe runs. It does not run when a manual scale is on + /// disk. scale_probed: bool, /// Settings dialog open + its editable form. show_settings: bool, @@ -799,13 +816,15 @@ pub struct NavigatorApp { llm_models: Vec, llm_testing: bool, llm_test_msg: Option, - /// Sort + inline per-column filter state for the subjects table. + /// The sort state, and the inline filter state of each column, for the subjects table. subjects_table_ctl: TableControls, - /// Bumped once per worker [`Event`] applied — the invalidation signal for the per-frame view - /// caches below. Every field they derive from is written in [`Self::drain_events`], so a change - /// of epoch is the one thing they all have to watch. Bumping on *every* event over-invalidates - /// (a progress tick rebuilds a table it did not affect) rather than risk showing stale rows: an - /// extra rebuild costs one frame, a missed one shows wrong data until the user clicks something. + /// This grows by one for each worker [`Event`] the code applies. It is the invalidation signal + /// for the view caches below. Every field they derive from comes from [`Self::drain_events`], + /// so a change of epoch is the one thing they all have to watch. + /// + /// A bump on *every* event invalidates too much, because a progress tick rebuilds a table it + /// did not touch. That is better than a risk of stale rows. One extra rebuild costs one frame, + /// and one that never happens shows wrong data until the user clicks something. data_epoch: u64, /// Derived display rows for the subjects table (see [`Self::subject_rows`]). subject_rows: SubjectRowCache, @@ -818,57 +837,63 @@ pub struct NavigatorApp { /// Collapse the subjects side panel to a thin strip so the detail panel (charts/tables) /// gets the full width. subjects_collapsed: bool, - /// Collapse the projects side panel to a thin strip, handing the detail panel the full width. + /// Collapse the projects side panel to a thin strip, and give the detail panel the full width. projects_collapsed: bool, overview: Vec, selected_project: Option, - /// When a subject was opened from a project's report row, the project id to return to (drives - /// the detail header's "back to project" button). Cleared on any other navigation. + /// When a subject came from the report row of a project, the project id to return to. It drives + /// the "back to project" button in the detail header. Any other navigation clears it. return_to_project: Option, - /// Per-sample coverage/haplogroup report rows for the selected project. + /// The coverage and haplogroup report rows of each sample, for the selected project. project_report: Vec, /// Precomputed Y-STR overview (FTDNA-style chart) for the selected project; `None` until the /// background build returns. A boolean tracks the in-flight build so the UI can show a spinner. project_str_chart: Option, project_str_loading: bool, - /// Cohort Y **block tree** for the selected project; `None` until the background build returns. - /// Loaded **lazily on first view of the Tree tab**, not on project select like the STR chart: - /// building it fetches and parses a multi-MB haplotree, too much to spend on a tab nobody opened. + /// The cohort Y **block tree** for the selected project. It is `None` until the background build + /// returns. It loads **lazily, on the first view of the Tree tab**, and not on a project select + /// as the STR chart does. A build reads and parses a multi-MB haplotree, and that is too much to + /// spend on a tab nobody opened. project_blocktree: Option, project_blocktree_loading: bool, /// Blocks (by node id) the user expanded to reveal their equivalent SNPs and full member list. /// Zoom factor for the block-tree canvas (1.0 = natural size). blocktree_zoom: f32, - /// Candidate branch open for review (its synthetic node id), if any. A candidate is an - /// inference, so it gets a surface that shows the evidence rather than asking for trust. + /// The candidate branch open for review, as its synthetic node id, when there is one. A + /// candidate is an inference, so it gets a surface that shows the evidence, and does not ask for + /// trust. blocktree_review: Option, - /// Block whose member roster is showing beside the tree. The Big Tree keeps the men in a table - /// rather than in the diagram; this is that table, scoped to what the user clicked. + /// The block whose member roster sits beside the tree. The Big Tree keeps the men in a table, + /// and not in the diagram. This is that table, held to what the user clicked. blocktree_selected: Option, - /// Recentre the canvas on the root next frame — set when a tree first arrives, so the view does - /// not open on the empty left margin of a canvas far wider than any viewport. + /// Centre the canvas on the root again on the next frame. The code sets it when a tree first + /// arrives. The view then does not open on the empty left margin of a canvas far wider than any + /// viewport. blocktree_recentre: bool, samples: Vec, /// Every biosample (the project-independent subjects list). all_biosamples: Vec, - /// Per-subject Y/mt terminal haplogroups for the list columns (`guid → (Y, mt)`). + /// The terminal Y and mt haplogroups of each subject, for the list columns (`guid → (Y, mt)`). haplo_summary: std::collections::HashMap, Option)>, - /// Per-subject analysis status (Pending/Complete) for the subjects-list Status column. A subject - /// absent from the map has no alignments to analyze (shown with no status). + /// The analysis status of each subject (Pending or Complete), for the Status column of the + /// subjects list. A subject the map does not hold has no alignment to analyze, and it appears + /// with no status. subject_status: std::collections::HashMap, selected_sample: Option, runs: Vec, /// Donor-level haplogroup consensus for the selected subject (Y, mtDNA). consensus_y: Option, consensus_mt: Option, - /// YFull-style descent reports for the selected subject, loaded lazily per `DnaType` and cached - /// as `Some(report)` / `None` (placed-but-empty), so a built-once result is not re-fetched; plus - /// the (guid, dna) pairs currently loading. All cleared on subject switch. + /// Descent reports in the YFull style, for the selected subject. Each `DnaType` loads lazily, + /// and the cache holds `Some(report)`, or `None` when placement reached it and it is empty. A + /// result that built one time never loads again. This also holds the (guid, dna) pairs that + /// load now. A subject switch clears all of it. descent_reports: Vec<(SampleGuid, DnaType, Option)>, descent_loading: Vec<(SampleGuid, DnaType)>, - /// Per-marker branch report for the selected subject: the node-name text inputs (Y / mt), the - /// last-loaded report cached as `Some(report)` / `None` (no alignment), and the (guid, dna) pairs - /// currently loading. Cleared on subject switch. Node-triggered (a Load button), not lazy. + /// The branch report of each marker, for the selected subject. It holds the node-name text + /// inputs (Y and mt), and the last report the cache took, as `Some(report)`, or `None` when + /// there is no alignment. It also holds the (guid, dna) pairs that load now. A subject switch + /// clears it. A node starts it, from a Load button, and it is not lazy. branch_node_y: String, branch_node_mt: String, branch_reports: Vec<(SampleGuid, DnaType, Option)>, @@ -880,7 +905,8 @@ pub struct NavigatorApp { heteroplasmy: Option<(i64, Vec)>, /// STR profiles for the selected subject. str_profiles: Vec, - /// Y-STR report view-state: which view, which provider (when multiple), and the marker filter. + /// The view state of the Y-STR report: which view, which provider when there is more than one, + /// and the marker filter. str_report_view: StrReportView, str_provider: Option, str_marker_filter: String, @@ -890,7 +916,7 @@ pub struct NavigatorApp { chip_profiles: Vec, /// mtDNA sequences for the selected subject. mtdna_sequences: Vec, - /// rCRS-relative mutation lists per mtDNA sequence id (loaded on demand). + /// The mutation list against rCRS of each mtDNA sequence id. It loads on demand. mtdna_variants: std::collections::HashMap>, /// Last mtDNA haplogroup assignment: (sequence id, assignment). mtdna_haplogroup: Option<(i64, HaploAssignment)>, @@ -903,7 +929,7 @@ pub struct NavigatorApp { auto_sub: AutoSub, /// Full Y placement report (ranked candidates + lineage SNP evidence) for an alignment. y_report: Option, - /// True while the haplogroup report is being built. + /// True while a build of the haplogroup report runs. y_report_running: bool, /// Last mtDNA-from-alignment haplogroup assignment: (alignment id, assignment). mt_haplogroup: Option<(i64, HaploAssignment)>, @@ -916,7 +942,8 @@ pub struct NavigatorApp { ancient_ancestry: Option, /// Reference PC1/PC2 centroids for the PCA scatter, keyed by alignment_id (lazy-loaded). pca_reference: Option<(i64, PcaCentroids)>, - /// Which PCA-reference key we have already dispatched a load for (avoids re-sending every frame). + /// The PCA-reference key we already dispatched a load for. It stops a second dispatch on every + /// frame. pca_reference_attempted: Option, /// Donor-level private-Y union across the subject's sources. donor_private_y: Option, @@ -924,37 +951,39 @@ pub struct NavigatorApp { y_profile: Option, /// Y-variant profile status filter (None = all). y_profile_filter: Option, - /// Text search across the variant/SNP tables (by SNP name / site), per table. + /// Text search over the variant and SNP tables, by SNP name or site, one for each table. y_profile_query: String, mt_profile_query: String, auto_profile_query: String, private_y_query: String, str_seq_query: String, - /// Catalogued Y-SNP names at variant positions (`position → name`), used to annotate the two - /// Y-SNP tables' position-only / novel calls. Resolved once per subject from the Y-SNP dictionary. + /// The catalogued Y-SNP names at variant positions (`position → name`). They annotate the + /// position-only and novel calls of the two Y-SNP tables. The Y-SNP dictionary resolves them one + /// time for each subject. y_snp_names: std::collections::HashMap, - /// True once we have dispatched the Y-SNP-name resolution for the current subject (avoids re-sending). + /// True after we dispatch the Y-SNP-name resolution for the current subject. It stops a second + /// dispatch. y_snp_names_requested: bool, - /// True while the (expensive) Y-variant profile is being built. + /// True while a build of the Y-variant profile runs. That build has a high cost. y_profile_loading: bool, /// The selected subject's multi-source mtDNA consensus profile. mt_profile: Option, /// mtDNA consensus-profile status filter (None = all). mt_profile_filter: Option, - /// True while the (expensive) mtDNA consensus profile is being built. + /// True while a build of the mtDNA consensus profile runs. That build has a high cost. mt_profile_loading: bool, /// The selected subject's multi-source autosomal (diploid 0/1/2) consensus profile. auto_profile: Option, /// Autosomal consensus status filter (None = all). auto_profile_filter: Option, - /// True while the (expensive) autosomal consensus profile is being built. + /// True while a build of the autosomal consensus profile runs. That build has a high cost. auto_profile_loading: bool, - /// Whether the consensus-driven donor ancestry estimate is in flight. + /// True while the donor ancestry estimate from the consensus is in progress. estimating_donor_ancestry: bool, - /// Whether the heavy deep (ancient) ancestry estimate is in flight. + /// True while the heavy deep (ancient) ancestry estimate is in progress. estimating_deep_ancestry: bool, - /// Local-ancestry painting: (alignment id, result with per-side segments + side labels). - /// `painting_running` while genotyping. + /// Local-ancestry painting: (alignment id, result). The result holds the segments of each side, + /// and the side labels. `painting_running` is true while the genotyping runs. painting: Option<(i64, PaintingResult)>, painting_running: bool, /// Runs-of-homozygosity result for the selected subject. `roh_running` while the HMM computes. @@ -971,7 +1000,7 @@ pub struct NavigatorApp { finding_private_y: bool, /// Callable-region BED (external mask), reused across private-Y runs. y_mask_path: Option, - /// Use the sample's own callable-Y BED (self-referential) rather than an external mask. + /// Use the callable-Y BED of the sample itself (self-referential), and not an external mask. y_self_mask: bool, selected_run: Option, alignments: Vec, @@ -979,16 +1008,17 @@ pub struct NavigatorApp { /// An alignment to auto-select once its run's alignments load (subject-centric default). pending_alignment: Option, coverage: Option, - /// Cached coverage per alignment for the selected run's Data Sources rows (so each row shows - /// coverage/callable without first selecting that alignment). Keyed by alignment id. + /// The cached coverage of each alignment, for the Data Sources rows of the selected run. Each + /// row then shows coverage and callable before anybody selects that alignment. The key is the + /// alignment id. coverage_by_aln: std::collections::HashMap, - /// Genome-region metadata (cytoband ideogram) for the selected alignment's build, `(alignment_id, - /// regions)`. Lazily fetched when the Ideogram tab is opened. + /// Genome-region metadata (the cytoband ideogram) for the build of the selected alignment, as + /// `(alignment_id, regions)`. It loads lazily when the user opens the Ideogram tab. genome_regions: Option<(i64, std::sync::Arc)>, - /// True while the cytoBand fetch is in flight. + /// True while the cytoBand read is in progress. loading_regions: bool, - /// The alignment we have already kicked off (or completed) a region load for — avoids re-firing - /// the fetch every frame, including after a failure. + /// The alignment we already started a region load for, or completed one for. It stops a second + /// read on every frame, and after a failure too. regions_attempted: Option, /// Which contig's depth histogram the coverage view charts: `None` = whole-genome histogram, /// `Some(i)` = `coverage.contig_coverage_stats[i]`. @@ -1004,19 +1034,20 @@ pub struct NavigatorApp { denovo: std::collections::HashMap>, running_denovo: bool, all_alignments: Vec, - /// `(project_id, eligible alignment ids)` for the project realignment card — answered by the - /// app, because the rule is the app's and the number has to be scoped to the project rather - /// than to the workspace. + /// `(project_id, eligible alignment ids)` for the project realignment card. The app answers it, + /// because the rule belongs to the app, and the number must cover the project, and not the whole + /// workspace. project_realignable: Option<(i64, Vec)>, - /// The project a count has already been requested for, so the card does not re-ask every frame - /// (including after a failure). Same shape as `regions_attempted`. + /// The project that already has a count request. The card then does not ask again on every + /// frame, and it does not ask again after a failure. It has the same shape as + /// `regions_attempted`. project_realignable_asked: Option, /// Chip-compatible IBD compare: the two picked sources (each a WGS alignment or an imported chip). ibd_src_a: Option, ibd_src_b: Option, /// Subject-level (consensus) IBD compare: the other subject picked for comparison. ibd_other_subject: Option, - /// Whether the consensus-compare subject picker's filter + list is revealed. + /// True when the filter and the list of the consensus-compare subject picker are open. ibd_other_picking: bool, /// Filter text for that picker. ibd_other_filter: String, @@ -1026,46 +1057,49 @@ pub struct NavigatorApp { identity: Option, /// Federated IBD: pseudonymous match suggestions fetched from the AppView. ibd_suggestions: Vec, - /// Whether a suggestions fetch is in flight (drives the spinner). + /// True while a read of the suggestions is in progress. It drives the spinner. loading_ibd_suggestions: bool, - /// Per-candidate introduction status, keyed by `suggested_sample_guid` (e.g. "PENDING"). + /// The introduction status of each candidate, with `suggested_sample_guid` as the key, for + /// example "PENDING". ibd_intros: std::collections::HashMap, /// The selected subject's persisted IBD exchange results. exchange_results: Vec, - /// True while an inbox refresh / consent / exchange run is in flight. + /// True while an inbox refresh, a consent, or an exchange run is in progress. exchange_busy: bool, - /// The matching ledger: every conversation, whatever its stage. Replaces the per-card view of - /// the same data, and unlike `ibd_intros` it survives a restart because the app persists it. + /// The matching ledger: every conversation, whatever its stage. It replaces the view of the + /// same data on each card. `ibd_intros` does not survive a restart, and this does, because the + /// app persists it. matching: Vec, - /// Which stage of the Matching panel is showing. + /// Which stage of the Matching panel is on the screen. matching_tab: MatchingTab, - /// Candidates dismissed this session, hidden immediately rather than waiting for a refetch - /// (the AppView keeps the authoritative dismissal). + /// The candidates the user dismissed in this session. They go at once, and the code does not + /// wait for a second read. The AppView keeps the authoritative dismissal. dismissed_candidates: std::collections::HashSet, /// The local subject whose dosages an exchange will use. Defaults to the selected subject. matching_subject: Option, - /// Whether the subject picker's filter + list is revealed (it is a reveal, not a dropdown, so a - /// 10k-subject workspace costs only the rows on screen). + /// True when the filter and the list of the subject picker are open. It is a reveal, and not a + /// dropdown, so a workspace of 10k subjects costs only the rows on the screen. matching_subject_picking: bool, /// Filter text for that picker. matching_subject_filter: String, - /// Request URI whose consent decision is being confirmed, with what we know of the request. + /// The request URI whose consent decision waits for a confirmation, with what we know of the + /// request. consent_prompt: Option, /// Signed-in account DID, or `None`. Gates the "Publish" actions. account: Option, /// Whether the last PDS write reached the server (offline indicator). online: bool, - /// Outbox rows still awaiting a successful push (the "N pending" sync indicator). + /// The outbox rows that still wait for a push to succeed (the "N pending" sync indicator). sync_pending: i64, - /// True while a PULL reconcile is in flight. + /// True while a PULL reconcile is in progress. pulling: bool, logging_in: bool, publishing: bool, - /// A batch project-directory import is in flight (disables the button). + /// True while a batch project-directory import is in progress. It disables the button. importing: bool, - /// The dir to retry importing once needed references are downloaded. + /// The dir to import a second time, after the necessary references arrive. pending_import_dir: Option, - /// Reference builds an import is waiting on (prompt the user to download). + /// The reference builds an import waits on. Prompt the user to download them. reference_needs: Vec, /// In-flight reference download: (build, received, total). reference_progress: Option<(String, u64, Option)>, @@ -1074,42 +1108,45 @@ pub struct NavigatorApp { /// A newer installer is available (drives the update-notification modal). Set once by the /// startup `CheckForUpdate`; cleared when the user dismisses it. update_info: Option, - /// A project-wide analyze pass is running (disables the report's analyze button). + /// True while an analyze pass over the whole project runs. It disables the analyze button of + /// the report. analyzing: bool, - /// Streaming deep-analyze progress: `(done, total, current_sample, fraction)` while running. + /// Deep-analyze progress from the stream: `(done, total, current_sample, fraction)` while the + /// pass runs. deep_progress: Option<(usize, usize, String, f32)>, /// Workspace-chore survey (Dashboard → Maintenance). `None` until the user asks for it: two of /// the three chores cost real work to measure, one a multi-MB tree fetch. maintenance: Option>, - /// True while the survey is in flight, so the button can say so. + /// True while the survey is in progress, so that the button can say so. maintenance_surveying: bool, - /// The chore currently running, with its progress line. + /// The chore that runs now, with its progress line. chore_running: Option<(navigator_app::Chore, usize, usize, String, f32)>, /// What the last chore did, kept on screen so a finished job is not just a vanished bar. chore_last: Option<(navigator_app::Chore, navigator_app::ChoreOutcome)>, - /// The dry-run FTDNA import plan being reviewed (drives the review modal). + /// The dry-run FTDNA import plan under review. It drives the review modal. ftdna_plan: Option, - /// The admin's per-kit resolutions for the fuzzy rows in [`Self::ftdna_plan`]. + /// The resolution the admin chose for each kit, for the fuzzy rows in [`Self::ftdna_plan`]. ftdna_resolutions: std::collections::BTreeMap, /// The selected subject's imported genealogy (vendor ids + FTDNA member + MDKA), for the /// Overview card. `(guid, data)` so a stale bundle from a prior subject is not shown. genealogy: Option<(SampleGuid, FtdnaGenealogy)>, /// The current project's Y-STR clustering, keyed by project id (so a stale one is not shown). project_clustering: Option<(i64, YstrClustering)>, - /// True while the project Y-STR clustering is computing. + /// True while the project Y-STR clustering runs. clustering_running: bool, /// Active project detail sub-tab (Members vs Report). project_tab: ProjectTab, /// Filter for the project Members list (kit / name / branch substring). member_filter: String, - /// Sort + inline per-column filter state for the project Report table. + /// The sort state, and the inline filter state of each column, for the project Report table. report_table_ctl: TableControls, // ---- Community (social) ------------------------------------------------ /// Active Community sub-tab (Support / Feed / Notifications). community_tab: CommunityTab, /// The signed-in account's support threads. support_threads: Vec, - /// The opened thread's `(conversation_id, messages)` — `None` when viewing the list. + /// The `(conversation_id, messages)` of the open thread. It is `None` while the list is on the + /// screen. open_thread: Option<(String, Vec)>, /// The loaded community feed. feed: Option, @@ -1137,19 +1174,20 @@ pub struct NavigatorApp { thread_reply: String, feed_content: String, feed_topic: String, - /// Opt-in: also publish the next community post to the signed-in PDS as a federated - /// `feed.post` record (roadmap 3b). Off by default — publishing to your own repo is an + /// Opt-in: also publish the next community post to the PDS that signed in, as a federated + /// `feed.post` record (roadmap 3b). It is off by default, because a write to your own repo is an /// explicit, portable public act. feed_publish_pds: bool, forms: Forms, status: String, /// The file-level diagnosis behind the last failed alignment command, when there was one. - /// Set by [`Event::Diagnosed`], shown in a modal, and cleared when the user dismisses it or - /// starts something new. `Some` is what makes the status bar's "Details" affordance appear — - /// an error with no diagnosis must not offer one. + /// [`Event::Diagnosed`] sets it, a modal shows it, and it clears when the user dismisses it or + /// starts something new. `Some` is what makes the "Details" control of the status bar appear. + /// An error with no diagnosis must not offer one. diagnosis: Option, - /// Whether the diagnosis modal is open. Separate from [`Self::diagnosis`] so dismissing the - /// modal keeps the report reachable from the status bar instead of destroying it. + /// True when the diagnosis modal is open. It is separate from [`Self::diagnosis`], so that a + /// dismiss of the modal keeps the report reachable from the status bar, and does not destroy + /// it. show_diagnosis: bool, } @@ -1161,30 +1199,32 @@ pub(crate) const ACCENT: egui::Color32 = egui::Color32::from_rgb(45, 125, 246); /// Destructive-action red (Delete buttons, the confirm modals, unconfirmed rows). const DANGER: egui::Color32 = egui::Color32::from_rgb(220, 60, 60); -/// Rows shown before the heavy variant/site tables (Y/mt/autosomal consensus profiles, private-Y, -/// de-novo SNPs) scroll internally. On a WGS these run thousands of rows; bounding them keeps the -/// detail page navigable instead of forcing endless scrolling — but the pane must be tall enough to -/// be useful (the user wants 20-30 rows, not a 3-row slot). +/// How many rows the heavy variant and site tables show before they scroll inside themselves. Those +/// tables are the Y, mt and autosomal consensus profiles, private-Y, and the de-novo SNPs. On a WGS +/// they run thousands of rows. A limit keeps the detail page easy to move around, with no endless +/// scroll. But the pane must be tall enough to be useful: the user wants 20-30 rows, and not a slot +/// of 3. const PROFILE_TABLE_ROWS: usize = 26; -/// Explicit height for a scrollable profile table that shows up to [`PROFILE_TABLE_ROWS`] rows of the -/// given `count`, then scrolls. Sized to the content when there are fewer rows (no empty pane). -/// Computed (rather than a fixed constant) because a nested `ScrollArea` with vertical `auto_shrink` -/// collapses to a few rows inside the page scroll — we pair this with `auto_shrink([false, false])`. +/// An explicit height for a profile table that scrolls. It shows as many as +/// [`PROFILE_TABLE_ROWS`] rows of the given `count`, then it scrolls. With fewer rows it takes the +/// size of its content, so there is no empty pane. The code calculates it, and does not use a fixed +/// constant. A nested `ScrollArea` with vertical `auto_shrink` collapses to a few rows inside the +/// page scroll. Pair this with `auto_shrink([false, false])`. fn profile_pane_height(ui: &egui::Ui, count: usize) -> f32 { let row_h = ui.text_style_height(&egui::TextStyle::Body) + ui.spacing().item_spacing.y + 3.0; let rows = count.clamp(1, PROFILE_TABLE_ROWS) as f32; row_h * (rows + 1.0) // +1 for the header row } -/// Apply the Decoding-Us workbench look: a dark (or light) palette with the accent blue, -/// rounded widgets, and roomier spacing — the visual base that closes most of the gap to the -/// Scala Workbench. Re-applied on theme toggle. +/// Apply the Decoding-Us workbench look: a dark or light palette with the accent blue, rounded +/// widgets, and more room between them. That is the visual base that closes most of the gap to the +/// Scala Workbench. A theme toggle applies it again. fn apply_theme(ctx: &egui::Context, dark: bool) { use egui::{Color32, Rounding, Stroke}; - // Pin the preference so egui stops following the OS theme — otherwise `theme_preference` - // defaults to `System` and our styled visuals get clobbered by the host (e.g. a light macOS - // would show light even with Dark selected in Settings). + // Pin the preference, so that egui stops following the theme of the OS. If not, + // `theme_preference` defaults to `System`, and the host writes over our styled visuals. A light + // macOS would then show light, even with Dark selected in Settings. ctx.set_theme(if dark { egui::ThemePreference::Dark } else { @@ -1271,9 +1311,11 @@ impl NavigatorApp { 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. + // Check for a newer installer at startup, unless the user opted out. It is not fatal: a + // check that fails only logs to the status line, and the app never updates itself. + // + // One read of settings.json for the whole constructor. The code used to load it six + // separate times. let settings = AppSettings::load(); if settings.check_for_updates != Some(false) { let _ = tx.send(Command::CheckForUpdate); @@ -1281,13 +1323,15 @@ impl NavigatorApp { // 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 - // scale factor 1.0. egui's keyboard zoom (Cmd +/-/0) also works but is not persisted. + // The UI scale (the egui zoom) from disk. It fixes tiny text on a native-4K display that + // the OS reports at scale factor 1.0. The keyboard zoom of egui (Cmd +/-/0) also works, and + // nothing persists that. cc.egui_ctx.set_zoom_factor(resolved_ui_scale()); - // Restore the last navigation position (view / focused subject / detail tab). The subject is - // applied once the list loads (see the `AllBiosamples` handler); nav/tab apply immediately - // (nav is then reconciled to the interface mode by `normalize_for_mode`). Seed `saved_ui_sig` - // with the restored intent so a matching restore does not trigger a redundant re-save. + // Restore the last navigation position: the view, the focused subject, and the detail tab. + // The subject applies after the list loads (see the `AllBiosamples` handler). The nav and + // the tab apply at once, and `normalize_for_mode` then reconciles the nav to the interface + // mode. Fill `saved_ui_sig` with the restored intent, so that a restore that matches does + // not start a save nobody needs. let restore = &settings; let restored_nav = restore .last_nav @@ -1336,7 +1380,8 @@ impl NavigatorApp { edit_alignment: None, frame_time: 0.0, window_size: None, - // The remembered size (if any) — seeded so an unchanged window never re-writes settings. + // The remembered size, when there is one. It starts here, so that an unchanged window + // never writes the settings again. saved_window_size: settings.window_size, window_size_changed_at: 0.0, window_restored: false, @@ -1575,15 +1620,18 @@ impl NavigatorApp { } } - /// Remember the window size across launches and fit it to the screen. `screen_rect`, - /// `monitor_size`, and [`egui::ViewportCommand::InnerSize`] are all in egui points at the current - /// zoom, so persisting `screen_rect` and restoring at the same (persisted) zoom round-trips exactly. + /// Remember the window size across launches, and fit it to the screen. `screen_rect`, + /// `monitor_size` and [`egui::ViewportCommand::InnerSize`] are all in egui points at the current + /// zoom. So a `screen_rect` on disk, restored at the same zoom from disk, round-trips exactly. /// - /// The one-time restore/fit runs via `InnerSize` at runtime — not the startup `ViewportBuilder`, - /// which sizes before the UI scale is applied — once the zoom has settled (`startup_frames >= 2`, - /// `scale_probed`) and the monitor size is known: the remembered size is clamped to fit the screen - /// (an over-large size from a bigger display shrinks). Afterwards, size changes are saved to - /// [`AppSettings`], debounced until they settle and once more on window close. + /// The one-time restore and fit runs through `InnerSize` at runtime. It does not use the startup + /// `ViewportBuilder`, which sets the size before the UI scale applies. It waits until the zoom + /// settles (`startup_frames >= 2` and `scale_probed`), and until the monitor size is there. It + /// then clamps the remembered size to fit the screen, so a size that is too large, from a bigger + /// display, shrinks. + /// + /// After that, a change of size goes to [`AppSettings`]. The code debounces the write until the + /// size settles, and it writes one more time on window close. fn manage_window_geometry(&mut self, ctx: &egui::Context) { self.startup_frames = self.startup_frames.saturating_add(1); let size = ctx.screen_rect().size(); @@ -1596,7 +1644,7 @@ impl NavigatorApp { // One-time restore + fit-to-screen, after the zoom has settled so `cur`/target share a unit. if !self.window_restored { let monitor = ctx.input(|i| i.viewport().monitor_size); - // Wait until the UI scale (zoom) is settled and the monitor size is known. + // Wait until the UI scale (zoom) settles, and until the monitor size is there. let Some(mon) = monitor.filter(|_| self.scale_probed && self.startup_frames >= 2) else { ctx.request_repaint(); // keep frames coming until we can restore return; // don't track/save until the restore has run @@ -1609,16 +1657,17 @@ impl NavigatorApp { self.window_restored = true; } - // Keep `self.window_size` current (the on-exit save reads it) and persist shortly after a - // resize settles, coalescing a drag into one write. The *definitive* final save is - // [`Self::on_exit`], reached on window close and on macOS Cmd+Q — which terminates via - // `applicationWillTerminate:` and never runs a `close_requested` update frame, so relying on - // that flag alone lost the size. This in-loop save just means a hard kill still has a recent - // size on disk. + // Keep `self.window_size` current, because the on-exit save reads it. Persist shortly after + // a resize settles, so that one drag becomes one write. The *definitive* final save is + // [`Self::on_exit`], which a window close reaches, and so does Cmd+Q on macOS. Cmd+Q + // terminates through `applicationWillTerminate:`, and it never runs a `close_requested` + // update frame, so that flag alone lost the size. This save inside the loop means that a + // hard kill still leaves a recent size on disk. if self.window_size != Some(cur) { self.window_size = Some(cur); self.window_size_changed_at = self.frame_time; - // Ensure a frame fires after the resize stops so the settled save runs even if idle. + // Make sure a frame fires after the resize stops, so that the settled save runs even + // when nothing else happens. ctx.request_repaint_after(std::time::Duration::from_millis(500)); } if self.frame_time - self.window_size_changed_at >= 0.5 { @@ -1626,9 +1675,9 @@ impl NavigatorApp { } } - /// Write the window size to [`AppSettings`] (load-modify-save, so other settings are preserved), - /// skipping the write when it already matches disk. Shared by the debounced in-loop save and the - /// on-exit save. + /// Write the window size to [`AppSettings`]. It loads, changes and saves, so that the other + /// settings stay. It drops the write when the value already matches disk. The debounced save + /// inside the loop, and the on-exit save, both use it. fn persist_window_size(&mut self, size: [f32; 2]) { if self.saved_window_size == Some(size) { return; @@ -1650,13 +1699,13 @@ impl NavigatorApp { ) } - /// Persist the navigation position (view / focused subject / detail tab) to [`AppSettings`] when it - /// changes, so the next launch reopens where the user left off. Called once per frame; the - /// signature guard means a write happens only on an actual navigation change (load-modify-save, so - /// the window size and every other setting are preserved). + /// Persist the navigation position to [`AppSettings`] when it changes: the view, the focused + /// subject, and the detail tab. The next launch then opens where the user left off. This runs on + /// each frame, and the signature guard means a write happens only on a real navigation change. + /// It loads, changes and saves, so that the window size and every other setting stay. fn persist_ui_state(&mut self) { - // Hold off until the one-time subject restore has been applied (consumed once the subject list - // first loads). Otherwise the early frames — before any subject is selected — would overwrite + // Wait until the one-time subject restore applies, which happens when the subject list + // first loads. If not, the early frames, before anything selects a subject, would write over // the remembered subject with `None`. if self.pending_restore_subject.is_some() { return; @@ -1676,9 +1725,10 @@ impl NavigatorApp { } impl eframe::App for NavigatorApp { - /// Final, reliable window-size save. eframe calls this on shutdown (from `save_and_destroy` on - /// `LoopExiting`), which macOS Cmd+Q reaches via `applicationWillTerminate:` — a path that runs no - /// `close_requested` update frame. `self.window_size` is kept current by `manage_window_geometry`. + /// The final, reliable window-size save. eframe calls it on shutdown, from `save_and_destroy` on + /// `LoopExiting`. Cmd+Q on macOS reaches that through `applicationWillTerminate:`, and that path + /// runs no `close_requested` update frame. `manage_window_geometry` keeps `self.window_size` + /// current. fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { if let Some(size) = self.window_size { self.persist_window_size(size); @@ -1690,13 +1740,15 @@ impl eframe::App for NavigatorApp { self.frame_time = ctx.input(|i| i.time); run_auto_scale(&mut self.scale_probed, &mut self.settings_form, ctx); self.manage_window_geometry(ctx); - // While an analysis runs, keep repainting so the spinner/elapsed timer animate even - // during a long step that emits no events (e.g. whole-genome coverage). + // While an analysis runs, keep the paint going, so that the spinner and the elapsed timer + // animate. That holds even during a long step that emits no event, for example whole-genome + // coverage. if self.analysis.is_some() { ctx.request_repaint_after(std::time::Duration::from_millis(120)); } self.drain_events(); - // Mode upkeep: first-run heuristic (until pinned) + auto-select the sole subject in Simple. + // Mode upkeep: the first-run heuristic, until something pins the mode, and the auto-select + // of the one subject in Simple. self.apply_ui_mode_heuristic(); self.auto_select_single_subject(); self.handle_file_drops(ctx); @@ -1717,9 +1769,10 @@ impl eframe::App for NavigatorApp { ) .on_hover_text(self.tr("status.pendingHint")); } - // PULL reconcile — a PDS-repo op, so it needs a real PDS (OAuth) account. A local - // did:key identity (federation/exchange only) has no PDS repo → show it disabled with - // a reason rather than letting it fail with a confusing "not signed in". + // PULL reconcile is an operation on a PDS repo, so it needs a real PDS (OAuth) + // account. A local did:key identity covers federation and exchange only, and it has + // no PDS repo. So show the control disabled, with a reason. Do not let it fail with + // a "not signed in" that puzzles the user. if let Some(acct) = self.account.clone() { let is_pds = !acct.starts_with("did:key:"); ui.separator(); @@ -1737,15 +1790,15 @@ impl eframe::App for NavigatorApp { ui.separator(); ui.label(egui::RichText::new(self.tr("status.label")).weak()); ui.label(&self.status); - // Only offered when a diagnosis exists — an error without one must not imply - // there is more to see. Re-opens the modal after it has been dismissed. + // This appears only when a diagnosis exists, because an error with none must not + // suggest there is more to see. It opens the modal again after a dismiss. if self.diagnosis.is_some() && ui.button(self.tr("status.details")).clicked() { self.show_diagnosis = true; } }); }); - // The action bar's batch/compare/add-to-project affordances are power-user features — - // Advanced only. + // The batch, compare and add-to-project controls of the action bar are power-user + // features, and they appear in Advanced only. if self.nav == Nav::Subjects && self.ui_mode == UiMode::Advanced { self.action_bar(ctx); } @@ -1791,9 +1844,9 @@ impl eframe::App for NavigatorApp { /// Render a depth histogram (`bin d` = bases observed at depth `d`, top bin = ≥255) as an const STR_CONFLICT: egui::Color32 = egui::Color32::from_rgb(220, 150, 60); -/// FTDNA/YSEQ-style By-Panel view: markers grouped into tiers (Y-12 / Y-25 / …), each tier rendered -/// as transposed mini-grids (marker-name row over value row, ≤12 markers wide). Conflicting markers -/// are amber. +/// The By-Panel view in the FTDNA or YSEQ style. It groups markers into tiers (Y-12, Y-25, and so +/// on). It draws each tier as transposed mini-grids: a marker-name row over a value row, and no more +/// than 12 markers wide. A marker in conflict is amber. fn str_by_panel_view(ui: &mut egui::Ui, profile: &StrProfile, provider: &str, comparison: &StrComparison) { let conflicts: std::collections::HashSet = comparison .conflicts @@ -1806,9 +1859,9 @@ fn str_by_panel_view(ui: &mut egui::Ui, profile: &StrProfile, provider: &str, co return; } let canon = strpanel::canonical_provider(provider); - // No inner scroll area here — the detail panel is already wrapped in one vertical - // ScrollArea, and nesting a second (fixed-height) one clips the panel tables and - // captures the wheel so the page can't scroll. Let the tiers flow into the page scroll. + // No inner scroll area here. One vertical ScrollArea already wraps the detail panel. A second + // one of fixed height inside it clips the panel tables and takes the wheel, so the page can not + // scroll. Let the tiers flow into the page scroll. for (tier, markers) in &groups { ui.add_space(6.0); ui.label(egui::RichText::new(format!("{canon} {tier} ({} markers)", markers.len())).strong()); @@ -1833,8 +1886,9 @@ fn str_by_panel_view(ui: &mut egui::Ui, profile: &StrProfile, provider: &str, co } } -/// Flat, filterable marker table: Marker | Panel | Value, plus ⚠ | Other when >1 provider (the -/// other provider's disagreeing value). Conflicting values are amber. +/// A flat marker table with a filter: Marker | Panel | Value. With more than one provider it adds +/// ⚠ | Other, which is the value of the other provider that does not agree. A value in conflict is +/// amber. fn str_all_markers_view( ui: &mut egui::Ui, profile: &StrProfile, @@ -1870,8 +1924,8 @@ fn str_all_markers_view( }); let f = filter.trim().to_uppercase(); let cols = if multi { 5 } else { 3 }; - // Flow into the detail panel's outer ScrollArea (no nested vertical scroll — it clips - // the table and steals the wheel). + // Flow into the outer ScrollArea of the detail panel. No nested vertical scroll, because that + // clips the table and takes the wheel. egui::Grid::new("str_all_grid") .striped(true) .num_columns(cols) @@ -1917,9 +1971,10 @@ fn str_all_markers_view( }); } -/// Canonical short label + badge color for a consensus status, shared by the Y/mt consensus card -/// and the autosomal diploid card. `Novel` can't arise on the diploid (panel-site) path — see -/// [`navigator_domain::consensus::reconcile_diploid`] — but is mapped here for completeness. +/// The canonical short label and badge color for a consensus status. The Y and mt consensus card, +/// and the autosomal diploid card, both use it. `Novel` can not arise on the diploid path over panel +/// sites (see [`navigator_domain::consensus::reconcile_diploid`]), and the map holds it here to be +/// complete. fn consensus_status_badge(status: YVariantStatus) -> (&'static str, egui::Color32) { let amber = egui::Color32::from_rgb(220, 150, 60); match status { @@ -1932,11 +1987,14 @@ fn consensus_status_badge(status: YVariantStatus) -> (&'static str, egui::Color3 } } -/// Shared renderer for a multi-source consensus profile (Y or mtDNA — same generic engine): header -/// (counts + lineage label + provenance), a status filter, and the per-variant grid. `variant_col` -/// names the identity column ("SNP" / "Mutation"); `kind` labels the empty state; `id_salt` keeps the -/// two cards' scroll/grid ids distinct. `snp_names` annotates a position-only/novel row with the -/// catalogued Y-SNP name at that site (empty for mtDNA, whose mutations are already named). +/// The shared draw for a multi-source consensus profile, Y or mtDNA, over the same generic engine. +/// It has a header with the counts, the lineage label and the provenance. Then comes a status +/// filter, then a grid with one row for each variant. +/// +/// `variant_col` names the identity column ("SNP" or "Mutation"). `kind` labels the empty state. +/// `id_salt` keeps the scroll and grid ids of the two cards distinct. `snp_names` adds the +/// catalogued Y-SNP name at a site to a position-only or novel row. It is empty for mtDNA, whose +/// mutations already have names. #[allow(clippy::too_many_arguments)] fn draw_consensus_profile( ui: &mut egui::Ui, @@ -2001,9 +2059,10 @@ fn draw_consensus_profile( YState::Ancestral => "ancestral", YState::NoCall => "no-call", }; - // Bound the table to a fixed-height scroll pane — on a WGS these run thousands of rows and would - // otherwise force endless page scrolling. Status/text filters narrow the list; a cap bounds a - // pathological profile. The header/filter row above stays fixed; only the grid scrolls. + // Hold the table inside a scroll pane of fixed height. On a WGS these run thousands of rows, + // and they would otherwise force an endless page scroll. The status filter and the text filter + // narrow the list, and a cap limits a pathological profile. The header and filter row above + // stays fixed, and only the grid scrolls. const CAP: usize = 2000; // A catalogued Y-SNP name at this site (for a position-only / novel row). let cataloged_at = |v: &navigator_domain::consensus::ConsensusVariant| { @@ -2013,9 +2072,9 @@ fn draw_consensus_profile( None } }; - // Which variants match is cached — only `CAP` rows are ever rendered, but the total needs the - // whole profile scanned, and this fn runs every frame. (`snp_names` feeds the search, and it is - // loaded by an event, so `epoch` covers it too.) + // A cache holds which variants match. The view draws only `CAP` rows, but the total needs a + // scan of the whole profile, and this function runs on every frame. `snp_names` feeds the + // search, and an event loads it, so `epoch` covers it too. let status = *filter; let matching = rows.get(epoch, status, &q, &profile.variants, |v| { if status.is_some_and(|f| v.status != f) { @@ -2106,9 +2165,10 @@ fn draw_consensus_profile( } } -/// Renderer for the autosomal diploid consensus profile — the 0/1/2 sibling of -/// [`draw_consensus_profile`]. Header (confirmed/conflict/single + confidence), a status filter, and a -/// per-site grid `Site (rsID) | GT (0/0,0/1,1/1) | Status | Sources (per-source dosage)`. +/// The draw for the autosomal diploid consensus profile: the 0/1/2 sibling of +/// [`draw_consensus_profile`]. It has a header with confirmed, conflict, single and the confidence, +/// then a status filter, then a grid with one row for each site: +/// `Site (rsID) | GT (0/0,0/1,1/1) | Status | Sources (dosage of each source)`. fn draw_diploid_profile( ui: &mut egui::Ui, profile: &navigator_app::DiploidProfile, @@ -2170,8 +2230,9 @@ fn draw_diploid_profile( 2 => "1/1", _ => "./.", }; - // The panel has ~1.2M sites — a non-virtualized Grid lays out every row per frame and beach-balls. - // Render fixed-width columns through ScrollArea::show_rows, which only builds the visible slice. + // The panel has ~1.2M sites. A Grid with no virtualization lays out every row on every frame, + // and the app then stops to respond. Draw fixed-width columns through ScrollArea::show_rows, + // which builds only the visible slice. const W_SITE: f32 = 150.0; const W_GT: f32 = 44.0; const W_STATUS: f32 = 130.0; @@ -2215,10 +2276,10 @@ fn draw_diploid_profile( } }); }; - // Bound the rows to a fixed-height scroll pane (the panel is ~1.2M sites). A hard cap keeps only - // `shown` widgets built per frame, never the full panel; the status/text filters narrow further. - // Which sites match is cached (`rows`) — the count needs the whole panel scanned, and this fn - // runs every frame. + // Hold the rows inside a scroll pane of fixed height, because the panel is ~1.2M sites. A hard + // cap builds only `shown` widgets on each frame, and never the full panel. The status filter and + // the text filter narrow it further. A cache holds which sites match (`rows`), because the count + // needs a scan of the whole panel, and this function runs on every frame. const CAP: usize = 2000; let status = *filter; let matching = rows.get(epoch, status, &q, &profile.variants, |v| { @@ -2246,8 +2307,9 @@ fn draw_diploid_profile( } } -/// The rCRS-relative mtDNA mutation list, grouped by region (HVR2 / Coding / HVR1) — the classic -/// mtDNA result. `variants` are derived against the bundled rCRS; notation is standard mtDNA form. +/// The mtDNA mutation list against rCRS, in groups by region (HVR2, Coding, HVR1). It is the classic +/// mtDNA result. The `variants` come from a derivation against the bundled rCRS, and the notation is +/// the standard mtDNA form. fn mtdna_mutations_view(ui: &mut egui::Ui, mtdna_id: i64, variants: &[MtVariant]) { if variants.is_empty() { ui.label(egui::RichText::new("Identical to rCRS (no mutations).").weak()); @@ -2302,7 +2364,7 @@ mod window_geometry_tests { #[test] fn size_that_fits_is_unchanged() { - // A comfortable window on a 2560×1440 monitor is left as-is. + // A comfortable window on a 2560×1440 monitor stays as it is. let got = fit_window_to_monitor([1600.0, 1000.0], [2560.0, 1440.0], MIN_WINDOW); assert_eq!(got, [1600.0, 1000.0]); } @@ -2370,11 +2432,13 @@ mod nav_persistence_tests { /// Every icon the chrome renders must have a real glyph in the font family it renders with. /// -/// egui's **Proportional** family is Ubuntu-Light + NotoEmoji-Regular + emoji-icon-font — a much -/// narrower set than "Unicode". Picking a plausible-looking character without checking is how the -/// Simple-mode rail shipped `◆`, `⚭` and `✓` and the nav shipped `🧬`, all four of which rendered as -/// empty tofu boxes. A missing glyph is invisible to every other test and to the compiler; only -/// looking at the running app catches it. So look at the fonts instead. +/// The **Proportional** family of egui is Ubuntu-Light, NotoEmoji-Regular and emoji-icon-font. That +/// is a much narrower set than "Unicode". +/// +/// A character that looks plausible, with no check, is how the Simple-mode rail went out with `◆`, +/// `⚭` and `✓`. The nav went out with `🧬`. All four drew as empty tofu boxes. A glyph that is +/// missing stays invisible to every other test, and to the compiler. Only a look at the live app +/// catches it. So read the fonts instead. #[cfg(test)] mod icon_glyph_tests { use super::egui::{FontDefinitions, FontFamily}; @@ -2383,9 +2447,10 @@ mod icon_glyph_tests { /// 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` - /// returns 0 (`.notdef` — the tofu box) when a font has no mapping for the character. + /// It reads the `FontDefinitions::default()` of egui itself, and not a vendored copy of the + /// `.ttf` files. So the test keeps its grip on the fonts the app ships, as egui moves forward. + /// `glyph_id` returns 0, which is `.notdef`, the tofu box, when a font has no mapping for the + /// character. fn renderable(c: char) -> bool { let defs = FontDefinitions::default(); let chain = &defs.families[&FontFamily::Proportional]; @@ -2397,10 +2462,10 @@ mod icon_glyph_tests { #[test] fn probe_agrees_with_known_bad_glyphs() { - // Guards the test itself: if these ever start reporting renderable, the check has broken - // rather than the fonts having improved. The second row is the near-miss set — each one is - // a character someone reached for because it looked right, and each shipped a box: `✕` - // beside `✖`, `✎` beside `✏`, `●` beside `⚫`, `▲▼▸` beside `⏶⏷▶`. + // This guards the test itself. If these ever come back as drawable, the check broke, and + // the fonts did not improve. The second row is the near-miss set. Each one is a character + // somebody reached for because it looked right, and each one went out as a box: `✕` beside + // `✖`, `✎` beside `✏`, `●` beside `⚫`, and `▲▼▸` beside `⏶⏷▶`. for c in ['◆', '⚭', '✓', '🧬', '✕', '✎', '✗', '●', '▲', '▼', '▸', '→'] { assert!( !renderable(c), @@ -2408,15 +2473,15 @@ mod icon_glyph_tests { c as u32 ); } - // And the replacements they were traded for, so a font change that drops one is caught here - // rather than in a screenshot. + // And the replacements that took their place, so that this test catches a font change that + // drops one, and a screenshot does not. for c in ['♂', '✖', '✏', '⚫', '⚪', '⏶', '⏷', '▶', '›'] { assert!(renderable(c), "sanity: {c} (U+{:04X}) is present", c as u32); } } - /// Prints coverage for the characters the app already uses plus a candidate set — run this when - /// choosing an icon instead of picking one that merely looks right: + /// Prints the coverage of the characters the app already uses, plus a candidate set. Run it + /// when you choose an icon, instead of a character that only looks right: /// `cargo test -p navigator-ui --bin navigator report_glyph_coverage -- --ignored --nocapture` #[test] #[ignore = "diagnostic, not a gate — see the doc comment for how to run it"] @@ -2445,10 +2510,11 @@ mod icon_glyph_tests { /// Every character of every translated string must be drawable. /// - /// The rail and nav icons below were checked individually, which left the ~1,900 strings in the - /// catalogs unchecked — and those held eight more undrawable characters (`←→▾◆●✓✗🗂`), including - /// the status dot on the dashboard and the check/cross on the exchange screen. Scanning the - /// whole catalog is the only version of this test that can't be outgrown by new copy. + /// A check on the rail and nav icons below covers each one on its own. That left the ~1,900 + /// strings in the catalogs with no check at all. Those held eight more characters nothing can + /// draw (`←→▾◆●✓✗🗂`). Among them were the status dot on the dashboard, and the check and the + /// cross on the exchange screen. A scan of the whole catalog is the only version of this test + /// that new copy can not outgrow. #[test] fn every_translated_string_is_renderable() { let mut bad: Vec = Vec::new(); @@ -2474,31 +2540,33 @@ mod icon_glyph_tests { /// Every string literal this crate draws must be drawable. /// - /// `every_translated_string_is_renderable` covers the catalogs, which is where user-facing copy - /// belongs — but icons do not live there. A button label like `ui.small_button("✎")` is a bare - /// literal in the source, invisible to the catalog scan, and that is where the second round of - /// tofu boxes was found: the MDKA edit/remove buttons and the kit-remove button on the - /// Genealogy card, every clear-filter `✕`, the Y-STR agreement `✓`/`✗`, the sortable-table - /// arrows, and the match-strength meter. Listing those by hand is the failure mode this test - /// exists to remove — it reads the sources themselves, so a new icon is covered the moment it - /// is typed. + /// `every_translated_string_is_renderable` covers the catalogs, and that is where copy for a + /// person belongs. But icons do not live there. A button label like `ui.small_button("✎")` is a + /// bare literal in the source, and the catalog scan can not see it. That is where the second + /// round of tofu boxes turned up. They were the MDKA buttons, the kit-remove button on the + /// Genealogy card, and every clear-filter `✕`. They were also the Y-STR agreement marks, the + /// arrows of the sortable table, and the match-strength meter. + /// + /// A list by hand is the failure mode this test exists to remove. It reads the sources + /// themselves, so a new icon has a check the moment somebody types it. + /// + /// A parse, and not a grep, is what makes that safe. Comments and doc comments are full of + /// characters that nothing ever draws: `→`, `⇒`, and a `◆` that names the fault it describes. + /// So the AST is the right input, because it holds literals and no comments. An override of + /// `visit_attribute` also drops `#[doc = "…"]`. /// - /// Parsing rather than grepping is what makes that safe. Comments and doc comments are full of - /// characters that are never drawn (`→`, `⇒`, `◆` naming the bug they describe), so the AST — - /// which has literals and no comments — is the right input, with `visit_attribute` overridden - /// to drop `#[doc = "…"]` as well. + /// Two things are out of scope on purpose, because egui never draws them. The first is `cli.rs`, + /// whose output goes to a terminal that draws in the font of the user. The second is a + /// `#[cfg(test)]` module, whose literals are assertion messages. /// - /// Two things are deliberately out of scope, because egui never draws them: `cli.rs`, whose - /// output goes to a terminal rendering in the user's own font, and `#[cfg(test)]` modules, - /// whose literals are assertion messages. + /// So is the rest of the workspace, and that is a real gap, and not an oversight. Two of the + /// boxes this round fixed came from `navigator-app`: a consensus warning, and the reference + /// notes on an import summary. Only this crate drew them. /// - /// So is the rest of the workspace, and that is a real gap rather than an oversight: two of the - /// boxes this round fixed were built in `navigator-app` (a consensus warning, the reference - /// notes on an import summary) and only rendered here. The invariant can't simply be lifted to - /// that crate — it serves the CLI and the HTML exporter too, where `→` and `✓` are correct — - /// and which of its strings reach a window is a dataflow question, not a syntactic one. When a - /// lower crate builds a string for the UI, it is on the author to check it; `report_glyph_coverage` - /// is the tool for that. + /// The invariant can not move to that crate as it stands. That crate also serves the CLI and + /// the HTML exporter, where `→` and `✓` are correct. Which of its strings reach a window is a + /// dataflow question, and not a syntactic one. When a lower crate builds a string for the UI, + /// the author has to check it, and `report_glyph_coverage` is the tool for that. #[test] fn every_source_string_literal_is_renderable() { use syn::visit::Visit; @@ -2521,10 +2589,11 @@ mod icon_glyph_tests { syn::visit::visit_item_mod(self, m); } } - /// A macro body is an unparsed `TokenStream`, and syn's visitor skips it — which would - /// hide most of what this test is for, since nearly every label is built by `format!`. - /// Walking the tokens by hand costs one recursion and needs no guess about the macro's - /// grammar: a `LitStr` is a `LitStr` wherever it sits. + /// A macro body is a `TokenStream` that nothing parsed, and the visitor of syn steps + /// over it. That would hide most of what this test is for, because `format!` builds + /// almost every label. A walk of the tokens by hand costs one recursion. It also needs + /// no guess about the grammar of the macro: a `LitStr` is a `LitStr` wherever it + /// sits. fn visit_macro(&mut self, m: &'ast syn::Macro) { self.tokens(m.tokens.clone()); } @@ -2547,8 +2616,8 @@ mod icon_glyph_tests { } } - /// True for `#[cfg(test)]` — matched on the token text, which is stable enough for an - /// attribute this conventional and avoids pulling in a nested-meta parser. + /// True for `#[cfg(test)]`. It matches on the token text, which is stable enough for an + /// attribute this conventional, and it avoids a nested-meta parser. fn is_cfg_test(attr: &syn::Attribute) -> bool { attr.path().is_ident("cfg") && attr.parse_args::().is_ok_and(|p| p.is_ident("test")) } @@ -2592,7 +2661,8 @@ mod icon_glyph_tests { ); } - /// The marks in the asset-status line, which are built inline rather than translated. + /// The marks in the asset-status line, which the code builds inline, and no catalog + /// translates. #[test] fn asset_status_marks_are_renderable() { use crate::charts::{MARK_ABSENT, MARK_PRESENT, MARK_VERIFIED}; diff --git a/crates/navigator-ui/src/ui/modals.rs b/crates/navigator-ui/src/ui/modals.rs index 55ecc8db..8be3df11 100644 --- a/crates/navigator-ui/src/ui/modals.rs +++ b/crates/navigator-ui/src/ui/modals.rs @@ -3,12 +3,13 @@ use super::*; impl NavigatorApp { - /// The "Full Analysis" progress modal: a dimmed backdrop + centered card with the current - /// step, a progress bar + percent, and a Cancel button. Shown while `self.analysis` is set. + /// The "Full Analysis" progress modal. It has a dimmed backdrop, and a centered card with the + /// current step, a progress bar with a percent, and a Cancel button. It appears while + /// `self.analysis` has a value. pub(crate) fn analysis_modal(&mut self, ctx: &egui::Context) { let Some(p) = self.analysis.clone() else { return }; - // Deferred so only `self.tr` (immutable) is used inside the closure, matching the other - // modals here. + // Deferred, so that the closure touches only `self.tr`, which is immutable. This matches + // the other modals here. let mut cancel_clicked = false; // Dim everything behind the dialog. @@ -28,7 +29,8 @@ impl NavigatorApp { ui.label(format!("Step {}/{}: {} — {}", p.step, p.total, p.label, p.detail)); ui.add_space(10.0); ui.horizontal(|ui| { - // `animate` shimmers the bar so a long step reads as working, not stalled. + // `animate` shimmers the bar, so that a long step reads as active, and not + // stopped. ui.add( egui::ProgressBar::new(p.fraction) .desired_width(360.0) @@ -45,9 +47,9 @@ impl NavigatorApp { ); ui.add_space(12.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Disabled once requested: cancellation is cooperative, so the run keeps going - // until the walk reaches its next check. Leaving the button live invited repeat - // clicks and made a working cancel look ignored. + // It disables after a request. Cancellation is cooperative, so the run continues + // until the walk reaches its next check. A button that stayed live invited repeat + // clicks, and made a cancel that worked look ignored. let requested = self.cancelling; let label = if requested { self.tr("analysis.cancelling") @@ -71,8 +73,9 @@ impl NavigatorApp { } } - /// The Edit-subject modal: editable fields over a dimmed backdrop. Save sends an - /// `UpdateBiosample` command; the resulting `BiosamplesChanged` event refreshes the lists. + /// The Edit-subject modal: fields the user can edit, over a dimmed backdrop. Save sends an + /// `UpdateBiosample` command, and the `BiosamplesChanged` event that follows refreshes the + /// lists. pub(crate) fn edit_subject_modal(&mut self, ctx: &egui::Context) { let Some(mut edit) = self.edit_subject.clone() else { return; @@ -143,9 +146,10 @@ impl NavigatorApp { } } - /// Add a vendor-id (kit) association to the open subject. Source is a well-known vendor or free - /// text; the kit id is required. The app layer enforces `(source, id)` uniqueness and reports a - /// conflict via `Event::Error`. Deferred dispatch (only `self.tr` is touched inside the closure). + /// Add a vendor-id (kit) association to the open subject. The source is a well-known vendor, or + /// free text, and the kit id is mandatory. The app layer holds `(source, id)` unique, and + /// reports a conflict through `Event::Error`. Deferred dispatch: the closure touches only + /// `self.tr`. pub(crate) fn add_kit_modal(&mut self, ctx: &egui::Context) { use navigator_domain::identity::IdSource; let Some(mut edit) = self.edit_kit.clone() else { return }; @@ -211,11 +215,11 @@ impl NavigatorApp { /// Simple mode's confirmation before a realignment starts. /// - /// The Advanced card starts the same job from one button, and that is right for the reader who - /// went looking for it among alignment internals. This reader arrived from a page about their - /// ancestors, so the cost is restated as its own decision: how long, how much disk, that the - /// original survives untouched, and that it can be stopped. Everything here is a fact the job - /// will otherwise deliver as a surprise four hours from now. + /// The Advanced card starts the same job from one button. That is right for the reader who + /// went to look for it among alignment internals. This reader came from a page about their + /// ancestors. So the cost gets its own decision: how long, how much disk, that the original + /// stays untouched, and that the user can stop it. Everything here is a fact that the job would + /// otherwise deliver as a surprise four hours from now. pub(crate) fn simple_realign_confirm_modal(&mut self, ctx: &egui::Context) { let Some(offer) = self.simple_realign_confirm.clone() else { return; @@ -277,8 +281,9 @@ impl NavigatorApp { } } - /// Edit (or add) the open subject's MDKA for one lineage. Years/coords are free-text and parsed - /// on save — a blank or unparseable field clears that column. Deferred dispatch. + /// Edit or add the MDKA of the open subject, for one lineage. Years and coordinates are free + /// text, and a save parses them. A field that is blank, or that does not parse, clears that + /// column. Deferred dispatch. pub(crate) fn edit_mdka_modal(&mut self, ctx: &egui::Context) { let Some(mut edit) = self.edit_mdka.clone() else { return }; @@ -346,7 +351,8 @@ impl NavigatorApp { field(ui, self.tr("mdka.notes"), &mut edit.notes, "notes (optional)"); ui.add_space(10.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // At least one field must be filled — an all-blank MDKA is meaningless. + // At least one field must have a value. An MDKA that is blank in every field has + // no use. let ready = [ &edit.ancestor_name, &edit.birth_year, @@ -396,13 +402,13 @@ impl NavigatorApp { } } - /// The diagnosis modal: why the last alignment command actually failed, file by file. + /// The diagnosis modal: why the last alignment command failed, file by file. /// - /// Shown when a command fails *and* the preflight found a concrete cause, because the one-line - /// status-bar message is exactly the part that is not actionable — the reader helpers report - /// whichever path the failing call was handed, which is routinely not the file at fault. The - /// report is selectable and copyable so it can go straight into a bug report; that is the - /// primary job of this modal, not a convenience. + /// It appears when a command fails *and* the preflight found a concrete cause. The one-line + /// status-bar message is exactly the part the user can not act on. The reader helpers report + /// whatever path the call that failed received, and that is routinely not the file at fault. + /// The user can select the report and copy it, so that it goes straight into a bug report. That + /// is the primary job of this modal, and not a convenience. pub(crate) fn diagnosis_modal(&mut self, ctx: &egui::Context) { if !self.show_diagnosis { return; @@ -412,16 +418,16 @@ impl NavigatorApp { return; }; - // Deferred so only `self.tr` (immutable) is used inside the closure, matching the other - // modals here. + // Deferred, so that the closure touches only `self.tr`, which is immutable. This matches + // the other 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.subtitle")).weak()); ui.separator(); egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { - // A read-only multiline edit rather than a label: it wraps, scrolls, and lets the - // user select a single line without dragging across the whole modal. + // A read-only multiline edit, and not a label. It wraps, it scrolls, and it lets + // the user select one line with no drag across the whole modal. let mut text = report.as_str(); ui.add( egui::TextEdit::multiline(&mut text) @@ -445,9 +451,9 @@ impl NavigatorApp { }); if copy { - // Write through egui *and* the system clipboard. egui's own copy is what an - // in-app paste sees; arboard is what survives to the browser tab where the bug report - // is being written, which is the only destination that matters here. + // Write through egui *and* the system clipboard. The copy of egui is what a paste + // inside the app sees. The arboard copy is what survives to the browser tab where the + // user writes the bug report. That is the only destination that matters here. ctx.output_mut(|o| o.copied_text = report.clone()); self.status = match arboard::Clipboard::new().and_then(|mut c| c.set_text(report)) { Ok(()) => self.tr("diagnosis.copied").to_string(), @@ -459,12 +465,16 @@ impl NavigatorApp { } } - /// The Settings / Preferences modal, split into sub-tabs by locality of concern: General - /// (theme/scale/language/mode), Connection (AppView URL, Y-tree provider, tree-cache TTL), - /// Ancestry (chromosome-painter calibration), AI assistant (local LLM), References (local - /// FASTA + auto-download per build), Tools (VCF liftover), and a read-only Advanced tab. - /// Self-mutation/dispatch is deferred until after the closure so only `self.tr` (immutable) - /// is used inside it. + /// The Settings / Preferences modal, split into sub-tabs by what each one covers. General has + /// the theme, the scale, the language and the mode. Connection has the AppView URL, the Y-tree + /// provider and the tree-cache TTL. Ancestry has the chromosome-painter calibration. AI + /// assistant has the local LLM. References has the local FASTA, and the auto-download for each + /// build. + /// + /// Tools has the VCF liftover, and there is a read-only Advanced tab. + /// + /// Every change to `self`, and every dispatch, waits until after the closure, so that the + /// closure touches only `self.tr`, which is immutable. pub(crate) fn settings_modal(&mut self, ctx: &egui::Context) { if !self.show_settings { return; @@ -477,14 +487,16 @@ impl NavigatorApp { let mut ui_mode = self.ui_mode; let mut settings_tab = self.settings_tab; let (mut close, mut save) = (false, false); - // Deferred actions (dispatched after the closure, since only `self.tr` is used inside it). + // Deferred actions. They dispatch after the closure, because the closure touches only + // `self.tr`. let mut verify_build: Option = None; let mut lift_request = false; let mut test_llm: Option = None; let mut refresh_trees = false; - // While the scale slider is being dragged, Do not live-apply the zoom (see the live-apply - // block below): changing the zoom factor rescales the slider's own rail mid-drag, so the - // cursor maps to a runaway value that collapses to a bound. Apply only once the drag ends. + // While the user drags the scale slider, do not apply the zoom live (see the live-apply + // block below). A change to the zoom factor rescales the rail of the slider in mid-drag. + // The cursor then maps to a value that runs away and collapses to a bound. Apply only after + // the drag ends. let mut scale_dragging = false; modal_frame(ctx, "settings_modal", 580.0, |ui| { @@ -689,7 +701,7 @@ impl NavigatorApp { ui.label(egui::RichText::new(msg).weak().small()); } }); - // Model picker — populated by a successful Test connection. + // Model picker. A Test connection that succeeds fills it. ui.horizontal(|ui| { ui.label(self.tr("settings.ai.model")); let current = if form.llm_model.is_empty() { @@ -715,7 +727,7 @@ impl NavigatorApp { 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. + // Privacy line. It becomes a warning for a URL that is not loopback. if navigator_app::llm::is_loopback_url(&form.llm_base_url) { ui.label(egui::RichText::new(self.tr("settings.ai.local")).weak().small()); } else { @@ -875,9 +887,10 @@ impl NavigatorApp { self.dark_mode = theme_dark; apply_theme(ctx, self.dark_mode); } - // Apply the zoom only when the slider is not mid-drag (typed/committed/button changes still - // apply immediately). Applying during a drag would rescale the rail and make the value run - // away to a bound — the reported "only 0.8 or 2.5" symptom. + // Apply the zoom only when the slider is not in mid-drag. A typed value, a committed + // value, and a button change all still apply at once. To apply during a drag would rescale + // the rail, and make the value run away to a bound. That is the "only 0.8 or 2.5" symptom + // somebody reported. if !scale_dragging && (ctx.zoom_factor() - form.ui_scale).abs() > f32::EPSILON { ctx.set_zoom_factor(form.ui_scale.clamp(0.5, 3.0)); } @@ -891,8 +904,8 @@ impl NavigatorApp { if save { let appview = form.appview_url.trim().to_string(); - // The fields this dialog does not own are carried over from disk; read once rather than - // re-reading and re-parsing settings.json for each of them. + // The fields this dialog does not own come over from disk. Read them one time, and do + // not read and parse settings.json again for each one. let kept = AppSettings::load(); let settings = AppSettings { y_tree_provider: Some(form.y_tree_provider.clone()), @@ -906,7 +919,7 @@ impl NavigatorApp { }), prompt_before_download: Some(form.prompt_before_download), ui_scale: Some(form.ui_scale), - // Interface mode is toggled from the app bar, not this dialog — preserve it. + // The app bar toggles the interface mode, and this dialog does not, so keep it. ui_mode: kept.ui_mode, llm_enabled: Some(form.llm_enabled), llm_base_url: { @@ -918,13 +931,14 @@ impl NavigatorApp { (!m.is_empty()).then_some(m) }, llm_max_tokens: form.llm_max_tokens.trim().parse::().ok().filter(|n| *n > 0), - // Update-check preferences are managed from the update dialog, not this one — preserve. + // The update dialog controls the update-check preferences, and this one does not, + // so keep them. check_for_updates: kept.check_for_updates, skip_update_version: kept.skip_update_version, - // The window size is remembered automatically as the window is resized — preserve it. + // The app remembers the window size as the user resizes the window, so keep it. window_size: kept.window_size, - // Navigation state (view / focused subject / detail tab) is remembered as the user - // navigates — preserve it across a settings save. + // The app remembers the navigation state as the user moves around: the view, the + // focused subject, and the detail tab. Keep it across a settings save. last_nav: kept.last_nav, last_subject: kept.last_subject, last_detail_tab: kept.last_detail_tab, @@ -943,9 +957,9 @@ impl NavigatorApp { } // Reflect the AI toggle immediately (gates the "Polish with AI" affordance). self.ai_enabled = form.llm_enabled; - // One bulk command → one atomic load-modify-save of reference_sources.json. Sending a - // separate command per row raced the file into corruption (issue #26), because every - // worker command is spawned concurrently. + // One bulk command → one atomic load, change and save of reference_sources.json. A + // separate command for each row raced the file into corruption (issue #26), because + // every worker command starts at the same time as the others. let overrides: Vec = form .references .iter() @@ -961,7 +975,7 @@ impl NavigatorApp { let _ = self.tx.send(Command::SetReferenceOverrides(overrides)); } - // Deferred dispatch (only `self.tr` was used inside the closure). + // Deferred dispatch, because the closure touched only `self.tr`. if let Some(build) = verify_build { self.status = format!("Verifying {build}…"); let _ = self.tx.send(Command::VerifyReference { build }); @@ -993,8 +1007,8 @@ impl NavigatorApp { } } - /// The Delete-subject confirmation modal. Confirm sends a `DeleteBiosample` command; the app - /// layer refuses (surfaced via the status bar) when the subject still has dependent data. + /// The Delete-subject confirmation modal. Confirm sends a `DeleteBiosample` command. The app + /// layer refuses, and the status bar shows that, when the subject still has dependent data. pub(crate) fn delete_subject_modal(&mut self, ctx: &egui::Context) { let Some(guid) = self.confirm_delete else { return }; let name = self.subject_label(guid); @@ -1026,9 +1040,10 @@ impl NavigatorApp { } } - /// The Clear-data confirmation modal. Confirm sends a `ClearBiosampleData` command, which resets - /// the subject's analysis (runs, alignments, haplogroups, ancestry, profiles…) while keeping the - /// subject itself — the recovery tool for a botched import. + /// The Clear-data confirmation modal. Confirm sends a `ClearBiosampleData` command, which + /// resets the analysis of the subject: runs, alignments, haplogroups, ancestry, profiles, and + /// the rest. The subject itself stays. This is the recovery tool for an import that went + /// wrong. pub(crate) fn clear_subject_modal(&mut self, ctx: &egui::Context) { let Some(guid) = self.confirm_clear else { return }; let name = self.subject_label(guid); @@ -1057,8 +1072,9 @@ impl NavigatorApp { } } - /// Confirm resetting only the subject's haplogroup placement (stale-lineage cleanup) — keeps - /// coverage/ancestry/imported data; the placement re-derives on the next full analysis / re-import. + /// Confirm a reset of the haplogroup placement of the subject, and nothing else. It is the + /// cleanup for a stale lineage. Coverage, ancestry and imported data stay. The placement comes + /// back on the next full analysis, or the next import. pub(crate) fn reset_haplo_modal(&mut self, ctx: &egui::Context) { let Some(guid) = self.confirm_reset_haplo else { return }; let name = self.subject_label(guid); @@ -1095,9 +1111,9 @@ impl NavigatorApp { } } - /// Notify the user that a newer installer is available (set by the startup `CheckForUpdate`). - /// Purely informational — offers to open the download in a browser, skip this version, or - /// dismiss. The app never auto-updates. + /// Tell the user that a newer installer exists. The startup `CheckForUpdate` sets it. It is + /// only information: it offers to open the download in a browser, to skip this version, or to + /// dismiss. The app never updates itself. pub(crate) fn update_modal(&mut self, ctx: &egui::Context) { let Some(info) = self.update_info.clone() else { return; @@ -1127,7 +1143,8 @@ impl NavigatorApp { ui.add_space(8.0); ui.label(egui::RichText::new(self.tr("update.notes")).weak().small()); egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { - // Release notes are Markdown; show as plain, wrapped text (no Markdown renderer here). + // The release notes use Markdown. Show them as plain, wrapped text, because + // there is no Markdown renderer here. ui.label(info.notes.trim()); }); } @@ -1167,8 +1184,8 @@ impl NavigatorApp { } } - /// Summary modal after a batch Add Data / drag-and-drop: per-file detected type + any skipped - /// files with the reason. Dismissed with Close. + /// The summary modal after a batch Add Data, or a drag-and-drop. It shows the detected type of + /// each file, and any file it dropped, with the reason. Close dismisses it. pub(crate) fn batch_import_modal(&mut self, ctx: &egui::Context) { let Some(summary) = self.batch_import.clone() else { return; @@ -1218,8 +1235,9 @@ impl NavigatorApp { } } - /// Confirmation modal for deleting a data-source row (run/alignment/profile). Confirm sends - /// the variant's worker command; the resulting change event refreshes the affected list. + /// The confirmation modal for a delete of a data-source row: a run, an alignment, or a profile. + /// Confirm sends the worker command of that variant, and the change event that follows + /// refreshes the list it affects. pub(crate) fn data_delete_modal(&mut self, ctx: &egui::Context) { let Some(target) = self.confirm_data_delete.clone() else { return; @@ -1249,8 +1267,9 @@ impl NavigatorApp { } } - /// Destructive merge-sequence-runs modal: the `secondary` run's alignments are reparented onto a - /// chosen `primary` run and the now-empty secondary is deleted. Mirrors the data-delete confirm. + /// The destructive merge-sequence-runs modal. The alignments of the `secondary` run move to a + /// chosen `primary` run, and then the empty secondary goes. It mirrors the data-delete + /// confirm. pub(crate) fn merge_runs_modal(&mut self, ctx: &egui::Context) { let Some(mut m) = self.merge_runs.clone() else { return }; @@ -1335,10 +1354,11 @@ impl NavigatorApp { } } - /// Read-only Y-profile **source audit**: a per-source provenance table (label · type · method - /// tier weight · variants contributed) and a per-conflict evidence list (each conflicting variant - /// with every source's call), so the user can see what drove — or disagreed with — each consensus - /// call. Pure over the cached `y_profile`; no schema change, no re-genotyping. + /// A read-only Y-profile **source audit**. It has a provenance table for each source: label · + /// type · method tier weight · variants it gave. It also has an evidence list for each + /// conflict: the variant, with the call of every source. The user can then see what drove each + /// consensus call, and what disagreed with it. It is pure over the cached `y_profile`: no + /// schema change, and no second genotyping. pub(crate) fn y_profile_audit_modal(&mut self, ctx: &egui::Context) { if !self.audit_y_profile { return; @@ -1361,7 +1381,7 @@ impl NavigatorApp { } ui.separator(); egui::ScrollArea::vertical().max_height(440.0).show(ui, |ui| { - // --- Per-source provenance --- + // --- Provenance of each source --- ui.label(egui::RichText::new(self.tr("audit.sources")).strong()); egui::Grid::new("yaudit_sources") .striped(true) @@ -1433,8 +1453,9 @@ impl NavigatorApp { } } - /// The Add-to-Project picker: a dropdown of projects (plus "no project"). Save sends - /// `AssignBiosampleProject`; the resulting `BiosamplesChanged` event refreshes the lists. + /// The Add-to-Project picker: a dropdown of projects, plus "no project". Save sends + /// `AssignBiosampleProject`, and the `BiosamplesChanged` event that follows refreshes the + /// lists. pub(crate) fn assign_project_modal(&mut self, ctx: &egui::Context) { let Some((guid, mut chosen)) = self.assign_project else { return; @@ -1548,8 +1569,8 @@ impl NavigatorApp { } } - /// The Delete-project confirmation modal. Confirm sends `DeleteProject`; the app layer - /// refuses (surfaced via the status bar) while subjects still belong to the project. + /// The Delete-project confirmation modal. Confirm sends `DeleteProject`. The app layer refuses, + /// and the status bar shows that, while subjects still belong to the project. pub(crate) fn delete_project_modal(&mut self, ctx: &egui::Context) { let Some((id, name)) = self.confirm_delete_project.clone() else { return; @@ -1632,9 +1653,9 @@ impl NavigatorApp { .desired_width(f32::INFINITY), ); ui.add_space(4.0); - // Lab / sequencing facility — a dropdown from the labs catalog ("(none)" clears - // it). Resolved automatically from the instrument id once the AppView lookup - // ships (roadmap D8); set manually here meanwhile. + // Lab, or sequencing facility: a dropdown from the labs catalog, where "(none)" clears + // it. It will come from the instrument id when the AppView lookup ships (roadmap D8). + // Until then a person sets it here. ui.label(self.tr("editRun.lab")); let lab_text = if edit.sequencing_facility.is_empty() { "(none)".to_string() @@ -1680,8 +1701,8 @@ impl NavigatorApp { } } - /// The Edit-alignment modal: reference build / aligner / variant caller. File paths are - /// managed by import/probe. Save sends `UpdateAlignment`. + /// The Edit-alignment modal: the reference build, the aligner, and the variant caller. The + /// import and the probe control the file paths. Save sends `UpdateAlignment`. pub(crate) fn edit_alignment_modal(&mut self, ctx: &egui::Context) { let Some(mut edit) = self.edit_alignment.clone() else { return; @@ -1740,9 +1761,10 @@ impl NavigatorApp { } } - /// FTDNA import review: a dry-run plan grouped into Needs-confirmation (per-row Merge/New/Skip), - /// Auto-merged, and New subjects, with a Commit. Deferred dispatch — only `self.tr` is used inside - /// the closure; resolution edits + the commit are applied after. + /// The FTDNA import review: a dry-run plan in three groups, with a Commit. The groups are + /// Needs-confirmation, where each row takes Merge, New or Skip, then Auto-merged, then New + /// subjects. Deferred dispatch: the closure touches only `self.tr`, and the resolution edits + /// and the commit apply after it. pub(crate) fn ftdna_review_modal(&mut self, ctx: &egui::Context) { let Some(plan) = self.ftdna_plan.clone() else { return }; let (n_new, n_merge, n_confirm) = plan.counts(); @@ -1776,7 +1798,8 @@ impl NavigatorApp { s.scanned_subjects, )) .small(); - // Red when no roster was recognized (likely the Member_Information file was not selected). + // Red when nothing recognized a roster. The user probably did not select the + // Member_Information file. if s.roster == 0 { ui.label(roster_txt.color(egui::Color32::from_rgb(210, 120, 70))); ui.label( @@ -1789,7 +1812,7 @@ impl NavigatorApp { } ui.separator(); egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { - // Needs confirmation — the only group with per-row actions. + // Needs confirmation: the only group where each row has actions. if n_confirm > 0 { ui.label(egui::RichText::new(self.tr("ftdna.needsConfirm")).strong()); for row in plan.rows.iter() { @@ -1805,8 +1828,9 @@ impl NavigatorApp { .small(), ); } - // Mutually-exclusive choice per kit: merge into a candidate, new, or skip. - // Radio buttons (not selectable labels) so the active choice is obvious. + // One choice for each kit, and the choices exclude each other: merge + // into a candidate, new, or skip. Radio buttons, and not selectable + // labels, so that the reader sees the active choice at once. let cur = resolutions.get(&row.kit_number); for c in candidates { let sel = matches!(cur, Some(FtdnaResolution::Merge(g)) if *g == c.guid); @@ -1839,7 +1863,7 @@ impl NavigatorApp { } } - // Auto-merged (exact kit#) — informational. + // Auto-merged (exact kit#). Information only. if n_merge > 0 { ui.add_space(4.0); ui.collapsing(format!("{} ({n_merge})", self.tr("ftdna.autoMerged")), |ui| { @@ -1851,7 +1875,7 @@ impl NavigatorApp { }); } - // New subjects — informational (orphans flagged). + // New subjects. Information only, and it flags an orphan. ui.add_space(4.0); ui.collapsing(format!("{} ({n_new})", self.tr("ftdna.new")), |ui| { for row in plan.rows.iter() { @@ -1894,10 +1918,10 @@ impl NavigatorApp { impl NavigatorApp { /// The consent decision for an inbound matching request. /// - /// A modal rather than an Accept button in a table row, because consenting does two things the - /// row can not say: it reveals our DID to the counterpart, and it puts our IBD-panel dosages on - /// 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. + /// A modal, and not an Accept button in a table row, because consent does two things the row + /// can not say. It reveals our DID to the counterpart, and it puts our IBD-panel dosages on the + /// encrypted channel. Nobody can undo either one. 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; @@ -1967,11 +1991,11 @@ impl NavigatorApp { /// Review a candidate branch: the shared position(s) and every carrier's read evidence. /// - /// A candidate is inferred, not published, and "1 SNP shared by three men" can not be judged from - /// the canvas. What decides it is the evidence behind each call — depth, and how cleanly the - /// derived allele dominates on a chromosome carrying one copy. A middling fraction or a thin - /// depth is the signature of the mapping artefacts this view is most at risk of presenting as - /// discoveries, so they are shown plainly and flagged. + /// A candidate is an inference, and nothing published it. Nobody can judge "1 SNP shared by + /// three men" from the canvas. The evidence behind each call decides it: the depth, and how + /// cleanly the derived allele dominates on a chromosome with one copy. This view is most at + /// risk when it shows a mapping artefact as a discovery. A fraction in the middle, or a thin + /// depth, is the signature of that. So it shows both plainly, and flags them. pub(crate) fn blocktree_review_modal(&mut self, ctx: &egui::Context) { let Some(node_id) = self.blocktree_review else { return }; let Some(block) = self @@ -2031,7 +2055,8 @@ impl NavigatorApp { ui.label(egui::RichText::new(format!("{}>{}", e.reference, e.alternate)).small()); ui.label(egui::RichText::new(e.depth.to_string()).small()); ui.label(egui::RichText::new(e.alt_depth.to_string()).small()); - // The determinism signal: on haploid chrY a real call is essentially 1.0. + // The determinism signal: on haploid chrY a real call is 1.0, or very + // near it. let af = egui::RichText::new(format!("{:.2}", e.allele_fraction)).small(); ui.label(if e.allele_fraction >= 0.95 { af diff --git a/crates/navigator-ui/src/ui/rowcache.rs b/crates/navigator-ui/src/ui/rowcache.rs index 4aca3e8d..c305b585 100644 --- a/crates/navigator-ui/src/ui/rowcache.rs +++ b/crates/navigator-ui/src/ui/rowcache.rs @@ -1,22 +1,25 @@ -//! Per-frame view caches for the three tables whose display rows are expensive to derive. +//! View caches, one for each frame, for the three tables whose display rows cost a lot to derive. //! -//! egui is immediate-mode: a render fn runs on **every** frame, up to 60 times a second, for as long -//! as its tab is open. Deriving display rows inline therefore repeats that work at frame rate even -//! when nothing changed — 6 `String` clones and a natural sort per subject, 15 per project member, or -//! (worst) a full scan of an autosomal consensus panel of ~1.2M sites just to count how many match -//! the filter. +//! egui is immediate-mode: a draw function runs on **every** frame, as often as 60 times a second, +//! for as long as its tab is open. So a derivation of the display rows inline repeats that work at +//! frame rate, even when nothing changed. That is 6 `String` clones and a natural sort for each +//! subject, and 15 for each project member. At worst it is a full scan of an autosomal consensus +//! panel of ~1.2M sites, only to count how many match the filter. //! -//! Each cache here records the inputs its rows were derived from and rebuilds only when one of them -//! differs. The data inputs are nearly all written in `NavigatorApp::drain_events`, so they collapse -//! to a single `data_epoch`; the rest are UI state the user changes directly (selection, language, -//! sort column, filter text). Rebuilding is always safe — the failure mode to avoid is *not* -//! rebuilding — so `data_epoch` is bumped on every event rather than per affected field. +//! Each cache here records the inputs its rows came from, and it builds again only when one of +//! those inputs differs. Almost all the data inputs come from `NavigatorApp::drain_events`, so they +//! collapse to one `data_epoch`. The rest are UI state that the user changes directly: the +//! selection, the language, the sort column, and the filter text. To build again is always safe, +//! and the failure mode to avoid is *not* to build again. So every event bumps `data_epoch`, and no +//! field has its own. //! -//! Every key also carries the **length** of the collection the rows were derived from. These caches -//! store indices, so a stale index into a shrunken collection would panic rather than merely look -//! wrong — and not every mutation goes through an event (`select_project`, for one, clears -//! `project_report` directly on a click). Keying on the length makes a cached index into a -//! different-length collection impossible to observe, without having to enumerate every mutator. +//! Every key also carries the **length** of the collection its rows came from. These caches store +//! indices. A stale index into a collection that shrank would panic, and not only look wrong. Also, +//! not every mutation goes through an event: `select_project`, for one, clears `project_report` +//! directly on a click. +//! +//! With the length in the key, nobody can observe a cached index in a collection of a different +//! length. Nobody has to list every mutator either. use super::*; use crate::i18n::Lang; @@ -33,7 +36,7 @@ pub(crate) struct SubjectRowCache { /// `None` until the first build. lang: Option, epoch: u64, - /// `all_biosamples.len()` when the rows were built — see the module docs. + /// `all_biosamples.len()` at the time this built the rows. See the module docs. len: usize, selected: Option, sort: Option, @@ -42,15 +45,17 @@ pub(crate) struct SubjectRowCache { pub(crate) rows: Vec, } -/// Row order for the project Report table. The 15 cells of display text per member are what the -/// filter and the natural sort run over, but they are only ever needed during a rebuild — the body -/// renders from the live report rows (the lite badge and action buttons need more than flat text), -/// so `order` indexes `NavigatorApp::project_report` and the text itself is dropped. +/// Row order for the project Report table. +/// +/// The filter and the natural sort run over the 15 cells of display text of each member. Those +/// cells matter only while the cache builds. The body draws from the live report rows, because +/// the lite badge and the action buttons need more than flat text. So `order` indexes +/// `NavigatorApp::project_report`, and the text itself goes. #[derive(Default)] pub(crate) struct ReportRowCache { built: bool, epoch: u64, - /// `project_report.len()` when `order` was built — see the module docs. + /// `project_report.len()` at the time this built `order`. See the module docs. len: usize, sort: Option, ascending: bool, @@ -58,16 +63,16 @@ pub(crate) struct ReportRowCache { pub(crate) order: Vec, } -/// Indices of the variants matching a variant table's status filter and search text. +/// Indices of the variants that match the status filter and the search text of a variant table. /// -/// The autosomal consensus panel runs to ~1.2M sites and a WGS Y profile to thousands. Only the first -/// `CAP` matches are ever rendered, but the *count* ("N of M matching") needs the whole scan — so -/// without this the entire panel was walked on every frame the tab was open. +/// The autosomal consensus panel runs to ~1.2M sites, and a WGS Y profile to thousands. The view +/// draws only the first `CAP` matches, but the *count* ("N of M matching") needs the whole scan. +/// Without this cache, the code walked the entire panel on every frame the tab was open. #[derive(Default)] pub(crate) struct VariantRows { built: bool, epoch: u64, - /// `variants.len()` when `rows` was built — see the module docs. + /// `variants.len()` at the time this built `rows`. See the module docs. len: usize, filter: Option, query: String, @@ -130,8 +135,9 @@ impl NavigatorApp { .all_biosamples .iter() .map(|s| { - // Y/mt from the bulk per-subject summary; the selected row prefers the freshly - // loaded consensus (reflects a just-run assignment before the summary reloads). + // Y and mt come from the bulk summary of each subject. The selected row prefers + // the consensus that just loaded, which shows an assignment that just ran, before + // the summary loads again. let sel = self.selected_sample == Some(s.guid); let summary = self.haplo_summary.get(&s.guid); let y = sel @@ -144,8 +150,9 @@ impl NavigatorApp { .flatten() .or_else(|| summary.and_then(|(_, m)| m.clone())) .unwrap_or_else(|| "-".into()); - // Analysis status: Complete once every alignment is analyzed, Pending while any is - // not (e.g. a just-imported file); a subject with no alignments has no status. + // Analysis status. It is Complete after an analysis covers every alignment, and + // Pending while one is still open, for example a file that just came in. A subject + // with no alignment has no status. let status = match self.subject_status.get(&s.guid) { Some(SubjectAnalysisStatus::Complete) => self.tr("subjectStatus.complete"), Some(SubjectAnalysisStatus::Pending) => self.tr("subjectStatus.pending"), @@ -165,7 +172,8 @@ impl NavigatorApp { }) .collect(); - // Inline per-column filters (AND across columns), then natural-sort by the active column. + // The inline filter of each column (AND across the columns), then a natural sort by the + // active column. for col in 0..SUBJECT_COLS.len() { let f = self.subjects_table_ctl.filter_norm(col); if !f.is_empty() { @@ -213,8 +221,9 @@ impl NavigatorApp { return; } - // Display text per cell — the basis for inline filtering and natural sort (the body renders - // from the live report rows so the lite badge + action buttons stay rich). + // The display text of each cell. The inline filter and the natural sort work on it. The + // body draws from the live report rows, so that the lite badge and the action buttons stay + // rich. let texts: Vec<[String; 15]> = self .project_report .iter() @@ -286,7 +295,8 @@ mod tests { use super::*; use std::cell::Cell; - /// Count how many entries the predicate was asked about, so "did it rescan?" is observable. + /// Count how many entries the code offered to the predicate, so that "did it scan again?" is + /// observable. fn scan(cache: &mut VariantRows, epoch: u64, query: &str, data: &[i32], seen: &Cell) -> Vec { cache .get(epoch, None, query, data, |v| { @@ -324,8 +334,8 @@ mod tests { ); } - /// Indices are only ever handed back for a collection of the length they were derived from — - /// otherwise a `select_project`-style clear would leave them pointing past the end. + /// The cache hands back indices only for a collection of the length they came from. If not, a + /// clear in the style of `select_project` would leave them past the end. #[test] fn variant_rows_never_index_past_a_shrunken_collection() { let data: Vec = (0..6).collect(); @@ -333,8 +343,8 @@ mod tests { let seen = Cell::new(0); assert_eq!(scan(&mut cache, 1, "", &data, &seen), vec![0, 2, 4]); - // Same epoch, same filter, same query — only the collection shrank (mutated outside the - // event loop). The rows must still be valid indices into it. + // Same epoch, same filter, same query. Only the collection shrank, because something + // changed it outside the event loop. The rows must still be valid indices into it. let shrunk: Vec = vec![4]; let rows = scan(&mut cache, 1, "", &shrunk, &seen); assert!( diff --git a/crates/navigator-ui/src/ui/simple.rs b/crates/navigator-ui/src/ui/simple.rs index 0f11632a..0ecd27b1 100644 --- a/crates/navigator-ui/src/ui/simple.rs +++ b/crates/navigator-ui/src/ui/simple.rs @@ -1,34 +1,38 @@ -//! Simple mode's subject view: a left section rail and the dedicated panels it switches between. +//! The subject view of Simple mode: a section rail on the left, and the panels it switches between. //! -//! Simple mode began as a single vertical scroll of every brief section stacked in a row. That grew -//! past the point where a casual reader could find anything in it — the paternal line, the ancestry -//! donut, the relatives list and the chat all lived in one column several screens tall. This module -//! splits that column into panels reached from a persistent rail, so the vertical extent of any one -//! screen is bounded and the rail itself doubles as the summary (each item carries its own headline -//! value: the terminal haplogroup, the top ancestry, the match count). +//! Simple mode began as one vertical scroll, with every brief section stacked in a row. That grew +//! past the point where a casual reader could find anything in it. The paternal line, the ancestry +//! donut, the relatives list and the chat all lived in one column, and that column was some screens +//! tall. //! -//! The panels are ordered as a story rather than by subsystem: [`SimplePanel::Story`] is the landing -//! synopsis, the two lineage panels reach the furthest back in time, [`SimplePanel::Ancestry`] runs -//! deep origins → recent populations in chronological order, and [`SimplePanel::Relatives`] lands in -//! the present day. Nothing here computes anything: it renders the precomputed [`SubjectBrief`] plus -//! the live (online) relative suggestions, exactly as the old scroll did. +//! This module splits that column into panels that a permanent rail reaches. So no one screen goes +//! past a limit from top to bottom, and the rail itself is also the summary. Each item on the rail +//! carries its own main value: the terminal haplogroup, the top ancestry, and the match count. +//! +//! The panels follow a story, and not the subsystems. [`SimplePanel::Story`] is the first synopsis. +//! The two lineage panels reach the furthest back in time. [`SimplePanel::Ancestry`] runs from deep +//! origins → recent populations, in time order. [`SimplePanel::Relatives`] arrives in the present +//! day. Nothing here computes: it draws the [`SubjectBrief`] that already exists, plus the live +//! relative suggestions from the network, exactly as the old scroll did. use super::*; -/// Width of the section rail. Wide enough for a two-line item (label over its value) at the default -/// text size without wrapping the haplogroup names, which are the longest values it carries. +/// Width of the section rail. It is wide enough for a two-line item, with the label over its value, +/// at the default text size. A haplogroup name then fits on one line, and those are the longest +/// values the rail carries. const RAIL_WIDTH: f32 = 208.0; -/// Fixed size of one at-a-glance tile. Fixed rather than content-sized so `horizontal_wrapped` can -/// reflow the row (see [`NavigatorApp::simple_glance_grid`]); tall enough for a two-line value. +/// The fixed size of one summary tile. The size does not follow its content, so that +/// `horizontal_wrapped` can reflow the row (see [`NavigatorApp::simple_glance_grid`]). It is tall +/// enough for a value on two lines. const TILE_SIZE: egui::Vec2 = egui::vec2(186.0, 76.0); impl NavigatorApp { /// The whole Simple-mode subject body: the rail on the left, the selected panel on the right. /// - /// The reference-download and "not analyzed yet" prompts render *above* the split rather than in - /// a panel — both block every panel equally, so burying either one behind a rail click would let - /// a user wander an empty view without being told why it is empty. + /// The reference-download prompt and the "not analyzed yet" prompt draw *above* the split, and + /// not inside a panel. Both block every panel equally. To put either one behind a rail click + /// would let a user walk an empty view, and nothing would say why it is empty. pub(crate) fn simple_subject_view(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { ui.separator(); self.reference_prompt(ui); @@ -67,8 +71,8 @@ impl NavigatorApp { // The rail // --------------------------------------------------------------------------------------------- - /// The section rail: one clickable item per panel, each showing its own headline value so the - /// rail reads as a summary of the whole subject without opening anything. + /// The section rail: one clickable item for each panel. Each item shows its own main value, so + /// that the rail reads as a summary of the whole subject, and the user opens nothing. fn simple_rail(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { // Values first (immutable reads), so the item loop can mutate `simple_panel` freely. let items: Vec<(SimplePanel, &'static str, &'static str, Option)> = SimplePanel::ALL @@ -99,8 +103,9 @@ impl NavigatorApp { egui::RichText::new(*label) }); }); - // A missing value is stated, not hidden: an item with no line under it reads as - // a rendering gap, where "Not available yet" reads as a fact about the data. + // Say that a value is missing, and do not hide it. An item with no line under + // it reads as a gap where something failed to draw. "Not available yet" reads + // as a fact about the data. if let Some(v) = value { ui.label(egui::RichText::new(v).weak().small()); } else if *panel != SimplePanel::Story { @@ -119,8 +124,8 @@ impl NavigatorApp { self.simple_panel = panel; } - // The bridge to the full power-user view lives at the foot of the rail, where it is reachable - // from every panel rather than only from the bottom of one long scroll. + // The bridge to the full power-user view sits at the foot of the rail. Every panel can + // reach it there, and not only the bottom of one long scroll. ui.add_space(12.0); ui.separator(); ui.add_space(6.0); @@ -133,12 +138,12 @@ impl NavigatorApp { } } - /// The one-line value the rail shows under a panel's name, or `None` when that panel has nothing - /// yet (the rail then says so explicitly). + /// The one-line value the rail shows under the name of a panel. `None` when that panel has + /// nothing yet, and the rail then says so. /// - /// Gated on the brief belonging to `guid`: the brief is rebuilt asynchronously after a subject - /// switch, and an ungated read would spend those frames labelling the new person with the - /// previous one's haplogroups. + /// A gate holds this to the brief that belongs to `guid`. The brief builds again + /// asynchronously after a subject switch. A read with no gate would spend those frames with the + /// haplogroups of the previous person under the name of the new one. fn simple_rail_value(&self, panel: SimplePanel, guid: SampleGuid) -> Option { let brief = self.subject_brief.as_ref().filter(|(g, _)| *g == guid).map(|(_, b)| b); match panel { @@ -157,9 +162,9 @@ impl NavigatorApp { } } - /// How many distinct people the Relatives panel would list: network suggestions plus any - /// completed exchange that has no suggestion behind it (a confirmed relative is still a relative - /// after the suggestion that introduced them has aged out of the AppView's list). + /// How many distinct people the Relatives panel would list: the network suggestions, plus any + /// exchange that completed with no suggestion behind it. A confirmed relative is still a + /// relative after the suggestion that introduced them drops off the list of the AppView. fn simple_relative_count(&self) -> usize { let suggested: std::collections::HashSet<&str> = self .ibd_suggestions @@ -175,16 +180,16 @@ impl NavigatorApp { } // --------------------------------------------------------------------------------------------- - // Panel: Your story (landing) + // Panel: Your story (the first panel) // --------------------------------------------------------------------------------------------- - /// The landing synopsis: who this person is in one or two sentences, an at-a-glance grid that - /// jumps into the other panels, and the results chat. + /// The first synopsis: who this person is, in one or two sentences, a summary grid that jumps + /// into the other panels, and the results chat. /// /// When the local AI assistant has narrated the brief, that narration *replaces* the one-line - /// synopsis as the story — it says the same thing at more length and in better prose. The - /// structured one-liner stays reachable under "Plain summary" so the model's version is never - /// the only account of the data on the screen. + /// synopsis as the story. It says the same thing at more length, and in better prose. The + /// structured one-liner stays reachable under "Plain summary". The version of the model is then + /// never the only account of the data on the screen. fn simple_story_panel(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { let Some(brief) = self.subject_brief.as_ref().filter(|(g, _)| *g == guid).map(|(_, b)| b) else { self.simple_brief_placeholder(ui); @@ -213,10 +218,11 @@ impl NavigatorApp { self.simple_chat_section(ui, guid); } - /// The synopsis card — the AI narration when there is one, the structured one-liner otherwise, - /// plus the generate/regenerate control when the local assistant is enabled. + /// The synopsis card. It holds the AI narration when there is one, and the structured one-liner + /// when there is not. It also holds the control that makes the narration, or makes it again, + /// when the local assistant is on. fn simple_synopsis_card(&mut self, ui: &mut egui::Ui, guid: SampleGuid, summary: &str) { - // Prefer the live stream while generating; fall back to the finalized narration. + // Prefer the live stream while the model writes. Fall back to the final narration. let live = self .narration_stream .as_ref() @@ -292,9 +298,10 @@ impl NavigatorApp { } } - /// The at-a-glance grid: one tile per remaining panel, each showing its headline value and - /// opening that panel when clicked. This is the landing screen's index — it is why the story - /// panel does not need to restate the paternal line, the ancestry donut, and the match list. + /// The summary grid: one tile for each of the other panels. Each tile shows the main value of + /// its panel, and a click opens that panel. This is the index of the first screen. It is why + /// the story panel does not have to restate the paternal line, the ancestry donut, and the + /// match list. fn simple_glance_grid(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { let tiles: Vec<(SimplePanel, &'static str, &'static str, Option)> = SimplePanel::ALL .iter() @@ -307,9 +314,9 @@ impl NavigatorApp { ui.label(egui::RichText::new(heading).strong().size(15.0)); ui.add_space(6.0); let mut pick = None; - // Each tile is allocated at a fixed size *before* its frame draws. `Frame::show` reserves no - // space up front, so a frame built straight into `horizontal_wrapped` gives the layout no - // size to test against and the row never wraps — it just runs off the right edge. Allocating + // Each tile takes a fixed size *before* its frame draws. `Frame::show` reserves no space + // first, so a frame built straight into `horizontal_wrapped` gives the layout no size to + // test against. The row then never wraps, and it runs off the right edge. To take the size // first is what makes the reflow work. ui.horizontal_wrapped(|ui| { for (panel, icon, label, value) in &tiles { @@ -325,7 +332,8 @@ impl NavigatorApp { ui.label(egui::RichText::new(format!("{icon} {label}")).weak().small()); ui.add_space(4.0); // Values run from "U5a1b1g" to "high-quality (28× average - // depth)", so they wrap inside the tile rather than widening it. + // depth)", so they wrap inside the tile, and do not make it + // wider. match value { Some(v) => ui.label(egui::RichText::new(v).size(15.0).strong()), None => ui.label(egui::RichText::new(empty).size(14.0).weak().italics()), @@ -350,9 +358,10 @@ impl NavigatorApp { // Panel: paternal / maternal line // --------------------------------------------------------------------------------------------- - /// One lineage panel. Renders the brief's lineage card (haplogroup, age, origin, story, - /// confidence, descent trail) or, when this subject has no such line, an explanation of *why* - /// there is not one — "no data" with no reason is the complaint this redesign exists to fix. + /// One lineage panel. It draws the lineage card of the brief: the haplogroup, the age, the + /// origin, the story, the confidence, and the descent trail. When this subject has no such + /// line, it explains *why* there is none. "No data" with no reason is the complaint this + /// redesign exists to fix. fn simple_lineage_panel(&mut self, ui: &mut egui::Ui, guid: SampleGuid, kind: LineageKind) { let Some(brief) = self.subject_brief.as_ref().filter(|(g, _)| *g == guid).map(|(_, b)| b) else { self.simple_brief_placeholder(ui); @@ -379,14 +388,14 @@ impl NavigatorApp { // Panel: ancestry (deep origins → recent populations) // --------------------------------------------------------------------------------------------- - /// The autosomal ancestry panel, ordered chronologically rather than by method: the ancient - /// source populations first, then the archaic (Neanderthal) trace, then the continental and - /// fine-grained populations of the last few thousand years, then the parent-split painting, then - /// the runs-of-homozygosity read on the two parental lines meeting again. + /// The autosomal ancestry panel, in time order and not by method. First the ancient source + /// populations, then the archaic (Neanderthal) trace. Then the continental and fine-grained + /// populations of the last few thousand years, then the parent-split painting. Last comes the + /// runs-of-homozygosity read on the two parental lines that come together again. /// - /// Reading it top to bottom is meant to be reading forwards in time. Sections with no data are - /// skipped silently — each depends on a different optional analysis, and an absent one is a step - /// not yet run, not a finding. + /// To read it from top to bottom is to read forwards in time. A section with no data goes, and + /// nothing says so. Each one depends on a different optional analysis, and an absent one is a + /// step that has not run, and not a result. fn simple_ancestry_panel(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { let Some(brief) = self.subject_brief.as_ref().filter(|(g, _)| *g == guid).map(|(_, b)| b) else { self.simple_brief_placeholder(ui); @@ -444,8 +453,8 @@ impl NavigatorApp { ui.add_space(4.0); ui.label(&arch.summary_phrase); ui.add_space(4.0); - // The count, framed as copies-of-copies-assayed exactly as the Advanced card does — - // never a "percent Neanderthal" (archaic-ancestry design S1/S7). + // The count, as copies over the copies assayed, exactly as the Advanced card puts + // it. It is never a "percent Neanderthal" (archaic-ancestry design S1 and S7). ui.label(format!( "{} of {} marker copies", arch.total_copies, arch.possible_copies @@ -490,9 +499,9 @@ impl NavigatorApp { }); ui.add_space(10.0); - // The fine breakdown gets its own card rather than a collapsed row inside the one above: - // the panel has the vertical room the old single scroll did not, and it is the section most - // readers came for. + // The fine breakdown gets its own card, and not a collapsed row inside the one above. The + // panel has the room from top to bottom that the old single scroll did not, and this is the + // section most readers came for. if !a.fine_pops.is_empty() { card(ui, detail_title, |ui| { for (name, pct) in a.fine_pops.iter().filter(|(_, p)| *p >= 0.5) { @@ -529,13 +538,15 @@ impl NavigatorApp { // Panel: relatives // --------------------------------------------------------------------------------------------- - /// Genetic relatives, grouped by how close the relationship looks. Two kinds of row land here: - /// a **confirmed** relative, where an encrypted segment exchange has actually run and produced a - /// shared-cM total, and a **candidate**, where the AppView has only scored the signals we both - /// published. They are grouped by the same three bands, but the band is chosen from measured cM - /// where we have it and from the suggestion's tier where we do not — and the row says which, - /// because "close family" inferred from a score is a much weaker claim than the same words - /// backed by 1,800 shared centimorgans. + /// Genetic relatives, in groups by how close the relationship looks. Two kinds of row arrive + /// here. A **confirmed** relative comes from an encrypted segment exchange that ran and gave a + /// shared-cM total. A **candidate** comes from the AppView, which only scored the signals we + /// both published. + /// + /// Both use the same three bands. The band comes from measured cM where we have it, and from + /// the tier of the suggestion where we do not. The row says which one it used, because "close + /// family" from a score is a much weaker claim than the same words behind 1,800 shared + /// centimorgans. fn simple_relatives_panel(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { ui.heading(self.tr("brief.relatives")); ui.add_space(4.0); @@ -543,8 +554,8 @@ impl NavigatorApp { ui.add_space(10.0); if self.account.is_none() { - // The heading is already on screen, so this is the hint alone rather than a full - // empty-state block repeating it. + // The heading is already on the screen, so this is the hint alone, and not a full + // empty-state block that says it again. ui.label(self.tr("brief.relativesSignIn")); return; } @@ -621,7 +632,7 @@ impl NavigatorApp { } } - /// One relative row. Returns the sample guid when its Connect button was pressed. + /// One relative row. Returns the sample guid after the user presses its Connect button. fn simple_relative_row(&self, ui: &mut egui::Ui, row: &RelativeRow) -> Option { let mut introduce = None; egui::Frame::group(ui.style()) @@ -649,8 +660,8 @@ impl NavigatorApp { } }); }); - // The evidence line. Measured sharing beats a score, so it is what is shown when we - // have it; the tier note above already said which kind of row this is. + // The evidence line. A measured total wins over a score, so it appears when we + // have one. The tier note above already tells which kind of row this is. match &row.shared { Some(s) => { ui.label( @@ -748,7 +759,7 @@ impl NavigatorApp { )); } - // Strongest first inside each tier: measured sharing, then the composite score. + // Strongest first inside each tier: the measured total, then the composite score. rows.sort_by(|a, b| { let key = |r: &RelativeRow| (r.shared.as_ref().map(|s| s.total_cm).unwrap_or(0.0), r.score); key(b).partial_cmp(&key(a)).unwrap_or(std::cmp::Ordering::Equal) @@ -760,8 +771,9 @@ impl NavigatorApp { // Panel: your test // --------------------------------------------------------------------------------------------- - /// The test the whole brief rests on: what it is, what it can and can't tell, its quality, the - /// global caveats, how fresh the narrative content is, and the export. + /// The test the whole brief rests on. It says what the test is, and what it can tell and what + /// it can not. It also gives the quality, the global caveats, how fresh the narrative content + /// is, and the export. fn simple_test_panel(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { let Some(brief) = self.subject_brief.as_ref().filter(|(g, _)| *g == guid).map(|(_, b)| b) else { self.simple_brief_placeholder(ui); @@ -828,32 +840,35 @@ impl NavigatorApp { ui.label(egui::RichText::new(footer).weak().small()); } - /// The offer to rebuild this person's genome against the complete reference, and the progress of - /// that job once it is running. + /// The offer to build the genome of this person again, against the complete reference, and the + /// progress of that job while it runs. + /// + /// It lives under "Your test", because that is the honest place for it. It is a statement about + /// the limits of the data, beside the depth and quality phrases. A reader here must never have + /// to know what a reference build is. They need only know that the reference itself was not + /// complete, and that is a fact about the reference, and not about their test. /// - /// Lives under "Your test" because that is where this belongs honestly: it is a statement about - /// the limits of the data, alongside the depth and quality phrases. A reader here should never - /// need to know what a reference build is — only that the reference itself was unfinished, which - /// is a fact about the reference and not about their test. + /// The copy is careful on two points that are easy to get wrong. The first is that the complete + /// assembly is **genome-wide**: a full autosomal sequence, and the first complete Y. Y + /// discovery is only what Navigator puts it to work on today, and to write it as a + /// paternal-line feature would misdescribe what T2T delivered. /// - /// The copy is careful on two points that are easy to get wrong. The completed assembly is - /// **genome-wide** — a full autosomal sequence as well as the first complete Y — and Y discovery - /// is merely what Navigator puts it to work on today; writing it as a paternal-line feature - /// would misdescribe what T2T actually delivered. And the finished genome is one donor's: of - /// European ancestry, carrying a J1a Y. Whose DNA a reference represents is exactly the sort of - /// thing that goes unsaid and should not. + /// The second is that the finished genome belongs to one donor, of European ancestry, with a + /// J1a Y. Whose DNA a reference represents is exactly the kind of thing that goes unsaid, and + /// must not. /// - /// Whether to offer it at all was decided in the app layer, on the brief; see + /// The app layer decided whether to offer it at all, on the brief. See /// `navigator_domain::brief::RealignOffer`. fn simple_realign_card(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { let offer = self.subject_brief.as_ref().and_then(|(_, b)| b.realign_offer.clone()); - // A run in progress wins over the offer, so the card a user just started reports itself - // rather than continuing to invite the thing it is already doing. + // A run in progress wins over the offer. The card a user just started then reports itself, + // and does not go on to invite the thing it already does. // - // Matched on the *subject*, not the alignment. Comparing alignment ids meant this card had - // nothing to compare against once the offer was gone — and it went on to claim any running - // job, so a page open on one person announced another person's realignment as their own. + // The match is on the *subject*, and not on the alignment. A compare of alignment ids left + // this card with nothing to compare against once the offer was gone. It then claimed any + // job that ran, so a page open on one person announced the realignment of another person as + // their own. let running = self.realign.clone().filter(|state| state.biosample_guid == Some(guid)); if let Some(state) = running { @@ -902,13 +917,14 @@ impl NavigatorApp { ui.add_space(6.0); ui.label(self.tr("simple.realign.use")); ui.add_space(6.0); - // Whose genome the finished reference actually is, and whose Y — stated rather than - // left for a reader to assume it represents everyone equally. + // Whose genome the finished reference is, and whose Y. Say it, and do not leave a + // reader to assume that it represents everybody equally. ui.label(egui::RichText::new(self.tr("simple.realign.note")).weak().small()); ui.add_space(6.0); ui.label(egui::RichText::new(self.tr("simple.realign.cost")).weak().small()); ui.add_space(8.0); - // Opens the confirmation rather than starting: see `simple_realign_confirm_modal`. + // This opens the confirmation, and does not start the job. See + // `simple_realign_confirm_modal`. if ui.button(self.tr("simple.realign.action")).clicked() { self.simple_realign_confirm = Some(offer.clone()); } @@ -917,8 +933,8 @@ impl NavigatorApp { // --------------------------------------------------------------------------------------------- - /// Shown by every panel while the brief for this subject is still being built (or when there is - /// none to build), so no panel renders as blank. + /// Every panel shows this while the brief for this subject still builds, and when there is none + /// to build. So no panel draws as blank. fn simple_brief_placeholder(&self, ui: &mut egui::Ui) { if self.subject_brief_loading { ui.horizontal(|ui| { @@ -938,9 +954,9 @@ fn simple_era_divider(ui: &mut egui::Ui, label: &str) { ui.add_space(6.0); } -/// How close a relative looks. Note this is a *presentation* band, not a relationship estimate: it -/// decides which heading a person is listed under, and each heading states what the band was read -/// from (see [`NavigatorApp::simple_relatives_panel`]). +/// How close a relative looks. This is a band for *presentation*, and not an estimate of the +/// relationship. It decides which heading a person appears under, and each heading says where the +/// band came from (see [`NavigatorApp::simple_relatives_panel`]). #[derive(Clone, Copy, PartialEq, Eq)] enum RelativeTier { Close, @@ -968,10 +984,11 @@ impl RelativeTier { ), ]; - /// Measured sharing decides the band when there is any; the composite score's tier decides it - /// otherwise. The cM cutoffs are the conservative ends of the standard bands — 1,200 cM is about - /// the floor for an aunt/uncle-or-closer relationship, 200 cM about the floor for a range that - /// still resolves to a nameable cousin rather than "somewhere back there". + /// A measured total decides the band when there is one, and the tier of the composite score + /// decides it when there is not. The cM cutoffs are the conservative ends of the standard + /// bands. A total of 1,200 cM is about the floor for an aunt or uncle, or a closer + /// relationship. A total of 200 cM is about the floor for a range that still resolves to a + /// cousin with a name. Below that it is "somewhere back there". fn of(shared: Option<&SharedDna>, strength: MatchStrength) -> Self { match shared { Some(s) if s.total_cm >= 1200.0 => RelativeTier::Close, @@ -986,7 +1003,7 @@ impl RelativeTier { } } -/// The measured half of a relative row: what a completed segment exchange actually found. +/// The measured half of a relative row: what a completed segment exchange found. struct SharedDna { total_cm: f64, segments: i64, @@ -1003,7 +1020,8 @@ impl From<&navigator_app::StoredIbdExchange> for SharedDna { } } -/// One row of the relatives panel, pre-resolved so rendering does no interpretation. +/// One row of the relatives panel. The code resolves it first, so that the draw reads nothing +/// into it. struct RelativeRow { /// Short pseudonymous handle shown in the list. handle: String, diff --git a/crates/navigator-ui/src/ui/sources.rs b/crates/navigator-ui/src/ui/sources.rs index 72024244..407c5b11 100644 --- a/crates/navigator-ui/src/ui/sources.rs +++ b/crates/navigator-ui/src/ui/sources.rs @@ -31,8 +31,9 @@ impl NavigatorApp { } }); - // Local copy so the table's row clicks can update the histogram selection without - // borrowing `self` mutably while `&self.coverage` is held; written back after the match. + // A local copy. A row click in the table can then update the histogram selection with no + // mutable borrow of `self` while the code holds `&self.coverage`. It writes the copy back + // after the match. let mut sel = self.coverage_hist_contig; match &self.coverage { None if !self.running => { @@ -78,7 +79,8 @@ impl NavigatorApp { ui.weak("or click a contig row"); }); - // Per-contig table: stats joined with the GATK/callable breakdown by header order. + // A table for each contig: the stats joined with the GATK and callable breakdown, + // by header order. egui::ScrollArea::vertical() .max_height(520.0) .id_salt("cov_contig_table") @@ -179,10 +181,10 @@ impl NavigatorApp { /// The realignment card for one alignment: offer, progress, or result. /// - /// One card that changes state rather than a modal. A realignment runs for hours; a dialog - /// owning the screen for that long would stop the user working with a workspace they can - /// perfectly well keep using, and would have nowhere to live once the job finished. This sits - /// with the alignment it belongs to and rewrites itself in place. + /// One card that changes state, and not a modal. A realignment runs for hours. A dialog that + /// owned the screen for that long would stop the user from a workspace they can perfectly well + /// keep open. It would also have nowhere to live once the job ended. This card sits with the + /// alignment it belongs to, and rewrites itself in place. pub(crate) fn realign_section(&mut self, ui: &mut egui::Ui, alignment_id: i64) { let Some(alignment) = self.alignments.iter().find(|a| a.id == alignment_id).cloned() else { return; @@ -200,7 +202,7 @@ impl NavigatorApp { return; } - // The state of *this* alignment's job, if the running one belongs to it. + // The state of the job of *this* alignment, if the job that runs belongs to it. let state = self .realign .as_ref() @@ -234,7 +236,7 @@ impl NavigatorApp { self.realign = None; } } - // ---- running ---- + // ---- in progress ---- None if state.is_some() => { let state = state.expect("checked"); let fraction = if state.total > 0 { @@ -273,15 +275,16 @@ impl NavigatorApp { ui.label("This alignment has already been realigned."); return; } - // `is_target_build` rather than a comparison spelled here: it trims where - // `eq_ignore_ascii_case` alone does not, so a stored " chm13v2.0" was offered by - // this card and then refused by the job. The rule belongs to the app. + // `is_target_build`, and not a comparison written out here. It trims where + // `eq_ignore_ascii_case` alone does not. A stored " chm13v2.0" once got an offer + // from this card, and then the job refused it. The rule belongs to the app. if navigator_app::is_target_build(&alignment.reference_build) { ui.label(format!("Already on {target} — there is nothing to realign.")); return; } - // A row with no file can not be re-mapped; the job fails at `MissingPaths`. The app's - // `realignable_for_subject` has always excluded these — this card did not. + // Nothing can map a row that has no file again, and the job fails at + // `MissingPaths`. The `realignable_for_subject` of the app has always left these + // out, and this card did not. if alignment.bam_path.is_none() { ui.label("This alignment has no file to re-map."); return; @@ -461,9 +464,9 @@ impl NavigatorApp { }); } - /// One DNA type's consensus row plus its override controls, audit log, and publish - /// button. The consensus/audit are cloned up front so the form fields can be borrowed - /// mutably for the override inputs. + /// The consensus row of one DNA type, plus its override controls, its audit log, and its + /// publish button. The code clones the consensus and the audit first, so that the form fields + /// can take a mutable borrow for the override inputs. pub(crate) fn consensus_block(&mut self, ui: &mut egui::Ui, label: &str, dna_type: DnaType) { let cons = match dna_type { DnaType::Y => self.consensus_y.clone(), @@ -672,8 +675,9 @@ impl NavigatorApp { } }); - // The persisted donor consensus (reloaded on select) — so the haplogroup stays visible - // without re-running. The fresh per-run assignment, when present, adds the SNP detail. + // The persisted donor consensus, which a select loads again, so the haplogroup stays + // visible with no second run. The fresh assignment of each run, when there is one, adds + // the SNP detail. if let Some(c) = &self.consensus_y { ui.label( egui::RichText::new(format!( @@ -785,9 +789,9 @@ impl NavigatorApp { } } - /// The full Y-haplogroup placement report (gap §8): the ranked candidate haplogroups + the - /// defining-SNP evidence along the reported lineage. Shown once "Full report" is run, flowing into - /// the page scroll (no nested ScrollArea). + /// The full Y-haplogroup placement report (gap §8): the ranked candidate haplogroups, and the + /// evidence from the SNPs that define each node along the reported lineage. It appears after + /// "Full report" runs, and it goes into the page scroll, with no nested ScrollArea. fn haplo_report_section(&self, ui: &mut egui::Ui, alignment_id: i64) { let Some(r) = &self.y_report else { return }; if r.alignment_id != alignment_id { diff --git a/crates/navigator-ui/src/widgets.rs b/crates/navigator-ui/src/widgets.rs index 8ff50fac..d72919d5 100644 --- a/crates/navigator-ui/src/widgets.rs +++ b/crates/navigator-ui/src/widgets.rs @@ -1,24 +1,27 @@ -//! Generic, reusable UI widgets and small formatters extracted from the view shell — leaf functions -//! over `egui` with no `App`/`self` state (tables, cards, chips, stat tiles, dropdowns, and the -//! number/guid formatters they share). The view code in `ui.rs` composes these. +//! Generic, reusable UI widgets and small formatters, taken out of the view shell. They are leaf +//! functions over `egui` with no `App` or `self` state. They cover tables, cards, chips, stat +//! tiles, dropdowns, and the number and guid formatters they share. The view code in `ui.rs` +//! composes these. use eframe::egui; use navigator_app::{CallState, HaploAssignment}; use navigator_domain::variants::VariantCall; -/// Click-to-sort + inline per-column filter state for one sticky-header table. Persisted on the -/// `App` so the chosen sort column/direction and the typed filters survive across frames. +/// Click-to-sort state, and the inline filter state of each column, for one sticky-header table. +/// The `App` holds it, so that the chosen sort column, the direction, and the typed filters survive +/// across frames. #[derive(Default)] pub(crate) struct TableControls { sort_col: Option, ascending: bool, - /// Per-column filter text, indexed by column. Grown on demand; empty entries are ignored. + /// The filter text of each column, with the column as the index. It grows on demand, and it + /// drops an empty entry. filters: Vec, } impl TableControls { - /// Start sorted by `col` ascending (natural order) before the user clicks any header, so the - /// table opens in a sensible order (e.g. numbered FTDNA kits flow 1, 2, 10, 100). + /// Start with a sort by `col`, from small to large (natural order), before the user clicks any + /// header. The table then opens in a sensible order: numbered FTDNA kits flow 1, 2, 10, 100. pub(crate) fn sorted_by(col: usize) -> Self { Self { sort_col: Some(col), @@ -27,7 +30,7 @@ impl TableControls { } } - /// Mutable handle to column `col`'s filter text (growing the backing store as needed). + /// A mutable handle to the filter text of column `col`. It grows the backing store as needed. pub(crate) fn filter_mut(&mut self, col: usize) -> &mut String { if self.filters.len() <= col { self.filters.resize(col + 1, String::new()); @@ -35,13 +38,15 @@ impl TableControls { &mut self.filters[col] } - /// The raw per-column filter text, for change-detection by the row caches (comparing this is - /// allocation-free, unlike calling [`Self::filter_norm`] per column every frame). + /// The raw filter text of each column, so that the row caches can detect a change. A compare of + /// this makes no allocation, and a call to [`Self::filter_norm`] for each column on every frame + /// does. pub(crate) fn filters_raw(&self) -> &[String] { &self.filters } - /// Trimmed, lower-cased filter text for `col` (empty when unset) — ready for `contains`. + /// Trimmed, lower-case filter text for `col`, empty when nothing sets it. It is ready for + /// `contains`. pub(crate) fn filter_norm(&self, col: usize) -> String { self.filters .get(col) @@ -57,7 +62,8 @@ impl TableControls { self.ascending } - /// First click on a column sorts it ascending; clicking the active column flips direction. + /// The first click on a column sorts it from small to large. A click on the active column + /// flips the direction. pub(crate) fn toggle_sort(&mut self, col: usize) { if self.sort_col == Some(col) { self.ascending = !self.ascending; @@ -70,8 +76,9 @@ impl TableControls { fn arrow(&self, col: usize) -> &'static str { match self.sort_col { Some(c) if c == col => { - // ⏶/⏷ (U+23F6/U+23F7), not ▲/▼ (U+25B2/U+25BC): only the former pair has glyphs in - // egui's Proportional family — see `every_source_string_literal_is_renderable`. + // ⏶/⏷ (U+23F6/U+23F7), not ▲/▼ (U+25B2/U+25BC). Only the first pair has glyphs in + // the Proportional family of egui. See + // `every_source_string_literal_is_renderable`. if self.ascending { " ⏶" } else { @@ -83,9 +90,9 @@ impl TableControls { } } -/// Render one sticky-header cell: a clickable sort label (click to sort, click again to flip) over -/// an inline per-column filter input. Pass `filterable = false` for columns that can't be filtered -/// (e.g. an actions column) to draw the label only. +/// Draw one sticky-header cell: a clickable sort label (click to sort, click again to flip) over an +/// inline filter input for that column. Pass `filterable = false` for a column that takes no filter +/// (for example an actions column), to draw the label only. pub(crate) fn sortable_header(ui: &mut egui::Ui, ctl: &mut TableControls, col: usize, label: &str, filterable: bool) { ui.vertical(|ui| { let title = format!("{label}{}", ctl.arrow(col)); @@ -107,9 +114,10 @@ pub(crate) fn sortable_header(ui: &mut egui::Ui, ctl: &mut TableControls, col: u }); } -/// Natural ("human") ordering: compare strings so embedded digit runs sort by numeric value, not -/// lexically — e.g. `Kit-2` < `Kit-10` < `Kit-100`. Non-digit runs compare case-insensitively. -/// Numeric runs are compared by trimmed length then digits, so arbitrarily long numbers are safe. +/// Natural ("human") ordering. It compares strings so that a run of digits sorts by numeric value, +/// and not lexically: `Kit-2` < `Kit-10` < `Kit-100`. A run that is not digits compares without +/// regard to case. For a run of digits it compares the trimmed length first, then the digits, so a +/// number of any length is safe. pub(crate) fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { use std::cmp::Ordering; let mut ai = a.chars().peekable(); @@ -128,7 +136,8 @@ pub(crate) fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { if ord != Ordering::Equal { return ord; } - // Equal value — keep ordering stable by leading-zero count (e.g. `01` before `1`). + // Equal value. Keep the order stable by the count of zeros at the start, so `01` + // comes before `1`. let ord = (na.len() - ta.len()).cmp(&(nb.len() - tb.len())); if ord != Ordering::Equal { return ord; @@ -194,9 +203,9 @@ pub(crate) fn chip(ui: &mut egui::Ui, text: &str, bg: egui::Color32, fg: egui::C response } -/// Whether a button embedded in a clickable row was activated: either it was clicked directly, or -/// the row swallowed the press while the pointer was over the button. Both selectable-row lists (runs -/// and their alignments) need the same rule, so it has one definition. +/// True when the user activated a button inside a clickable row. Either the user clicked the button +/// directly, or the row took the press while the pointer was over the button. Both lists of +/// selectable rows (runs, and their alignments) need the same rule, so it has one definition. pub(crate) fn button_hit(button: &Option, row_clicked: bool) -> bool { button .as_ref() @@ -246,18 +255,18 @@ pub(crate) fn stat_card(ui: &mut egui::Ui, label: &str, value: usize) { }); } -/// Format an optional mean/median depth (one decimal), "—" when not computed. +/// Format an optional mean or median depth (one decimal). `"—"` when nothing computed it. pub(crate) fn fmt_depth(o: Option) -> String { o.map(|v| format!("{v:.1}")).unwrap_or_else(|| "—".into()) } -/// Format an optional fraction (0–1) as a percentage, "—" when not computed. +/// Format an optional fraction (0–1) as a percentage. `"—"` when nothing computed it. pub(crate) fn fmt_pct(o: Option) -> String { o.map(|v| format!("{:.1}%", v * 100.0)).unwrap_or_else(|| "—".into()) } -/// Render a haplogroup assignment: terminal + lineage + alternatives, then the child -/// branches with per-SNP evidence that explains why descent stopped. +/// Draw a haplogroup assignment: the terminal, the lineage and the alternatives. Then draw the +/// child branches, with the evidence at each SNP that explains why descent stopped. pub(crate) fn show_assignment(ui: &mut egui::Ui, a: &HaploAssignment) { let Some(top) = a.ranked.first() else { ui.label("No match."); // free helper (no `self`); i18n when it takes a `lang` param @@ -307,8 +316,8 @@ pub(crate) fn show_assignment(ui: &mut egui::Ui, a: &HaploAssignment) { } } -/// A readable "change" string for a variant call, covering the indel forms the mtDNA -/// derivation stores (one allele empty). +/// A readable "change" string for a variant call. It also covers the indel forms that the mtDNA +/// derivation stores, where one allele is empty. pub(crate) fn variant_change(c: &VariantCall) -> String { if c.alternate.is_empty() { format!("{}del", c.reference) // deletion @@ -319,7 +328,7 @@ pub(crate) fn variant_change(c: &VariantCall) -> String { } } -/// Trim a string, returning `None` when empty. +/// Trim a string. Returns `None` when the result is empty. pub(crate) fn opt(s: &str) -> Option { let t = s.trim(); (!t.is_empty()).then(|| t.to_string()) diff --git a/crates/navigator-ui/src/worker.rs b/crates/navigator-ui/src/worker.rs index e7184d05..91453a8a 100644 --- a/crates/navigator-ui/src/worker.rs +++ b/crates/navigator-ui/src/worker.rs @@ -1,11 +1,11 @@ -//! Sync↔async bridge. egui runs the immediate-mode loop on the main thread; the -//! `App` (tokio + sqlx) runs on a dedicated worker thread with its own runtime. The UI -//! sends [`Command`]s and drains [`Event`]s each frame — no DB calls or domain -//! decisions on the UI thread (plan §5). +//! The bridge between sync and async. egui runs the immediate-mode loop on the main thread. The +//! `App`, which is tokio and sqlx, runs on its own worker thread, with its own runtime. The UI +//! sends [`Command`] values and drains [`Event`] values on each frame. No DB call, and no domain +//! decision, happens on the UI thread (plan §5). //! -//! Each command is handled on its own task so a long analysis run never blocks quick -//! queries. The command→event mapping ([`handle`]) is pure and unit-tested; [`spawn`] -//! is the thread/runtime/channel glue. +//! Each command runs on its own task, so a long analysis never blocks a quick query. The map from +//! command to event ([`handle`]) is pure, and it has unit tests. [`spawn`] is the glue for the +//! thread, the runtime and the channels. use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; @@ -41,12 +41,12 @@ pub enum YMask { SelfReferential, /// An external callable BED (e.g. the Poznik/1KG `b38_sites.bed`). Bed(PathBuf), - /// No mask (noisy — every off-backbone de-novo call). + /// No mask. It is noisy, because it takes every off-backbone de-novo call. None, } -/// Fields for adding a biosample (the app assigns its `SampleGuid`). `project_id` is -/// optional — biosamples are first-class and need not belong to a project. +/// The fields to add a biosample. The app assigns its `SampleGuid`. `project_id` is optional, +/// because a biosample stands on its own, and it does not have to belong to a project. #[derive(Debug, Clone)] pub struct NewBiosample { pub project_id: Option, @@ -64,7 +64,7 @@ pub enum Command { /// Survey the workspace chores (what each would do if run). Deliberately on demand: two of the /// three cost real work to measure, one of them a multi-MB tree fetch. SurveyMaintenance, - /// Run one workspace chore, streaming `ChoreProgress` and finishing with `ChoreDone`. + /// Run one workspace chore. It streams `ChoreProgress`, and ends with `ChoreDone`. RunChore { chore: navigator_app::Chore, /// Recompute what is already cached (private-Y only; the others have nothing to force). @@ -74,12 +74,12 @@ pub enum Command { CheckForUpdate, CreateProject(NewProject), LoadSamples(i64), - /// Load the per-sample coverage/haplogroup report for a project. + /// Load the coverage and haplogroup report of each sample, for a project. LoadProjectReport(i64), - /// Load (precompute) the per-member Y-STR overview (FTDNA-style chart) for a project. + /// Compute and load the Y-STR overview of each member (the FTDNA-style chart) for a project. LoadProjectStrChart(i64), - /// Build the cohort Y **block tree** for a project. Fetches + parses a multi-MB haplotree, so the - /// UI sends this lazily on first view of the Tree tab rather than on project select. + /// Build the cohort Y **block tree** for a project. It reads and parses a multi-MB haplotree. + /// So the UI sends it lazily, on the first view of the Tree tab, and not on a project select. LoadProjectBlockTree(i64), /// Build (off the UI thread) the plain-language Subject Brief for a subject (Simple mode). LoadSubjectBrief(SampleGuid), @@ -88,14 +88,16 @@ pub enum Command { guid: SampleGuid, dna: DnaType, }, - /// Build (off the UI thread) a per-marker branch report over `node`'s subtree for a subject. + /// Build a branch report for each marker, over the subtree of `node`, for a subject. It runs off + /// the UI thread. LoadBranchReport { guid: SampleGuid, dna: DnaType, node: String, depth: Option, }, - /// Narrate a subject's brief via the local LLM ("Polish with AI"); falls back on any failure. + /// Narrate the brief of a subject through the local LLM ("Polish with AI"). It falls back on any + /// failure. NarrateBrief(SampleGuid), /// Ask the local LLM a question about a subject's results (grounded in the brief). AskQuestion { @@ -103,26 +105,28 @@ pub enum Command { history: Vec, question: String, }, - /// Explain a single result signal in plain language (per-tab "Explain this", M5). + /// Explain one result signal in plain language (the "Explain this" of a tab, M5). 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`]. - /// (The one-shot `App::analyze_project` is still used headless/by tests.) + /// Deep-analyze every sample in a project, as a background job the user can cancel. It streams + /// a `DeepAnalyzeProgress` for each sample, and gives up the thread between samples, so that + /// the UI stays responsive. It steps over what the fast path already filled. + /// [`Command::CancelAnalysis`] cancels it. The headless path, and the tests, still use the + /// one-shot `App::analyze_project`. DeepAnalyzeProject(i64), /// Load every biosample (subjects list), regardless of project. LoadAllBiosamples, /// Load donor-level Y/mt terminal haplogroups for every subject (fills the list columns). LoadHaploSummary, - /// Load per-subject analysis status (Pending/Complete) for the subjects-list Status column. + /// Load the analysis status of each subject (Pending or Complete), for the Status column of the + /// subjects list. LoadSubjectStatus, AddBiosample(NewBiosample), - /// Batch-import a NAS project directory (scan → Project/Biosample/Run/Alignment). - /// `reference` is optional: `None` lets the gateway resolve each build from the cache - /// (and report `ReferenceNeeded` if a download is required); `Some` pins a FASTA. + /// Batch-import a NAS project directory: scan → Project, Biosample, Run, Alignment. + /// `reference` is optional. With `None`, the gateway resolves each build from the cache, and + /// reports `ReferenceNeeded` when a download is necessary. `Some` pins a FASTA. ImportProjectDir { dir: PathBuf, reference: Option, @@ -138,7 +142,7 @@ pub enum Command { maternal: Option, ystr: Option, }, - /// Commit a reviewed FTDNA import plan with the admin's per-kit resolutions. + /// Commit a reviewed FTDNA import plan, with the resolution the admin chose for each kit. CommitFtdnaImport { plan: FtdnaImportPlan, resolutions: std::collections::BTreeMap, @@ -161,8 +165,9 @@ pub enum Command { StrConcordance { biosample_guid: SampleGuid, }, - /// Rank every other workspace subject against this one by Y relatedness (gap §2). One-vs-all - /// over the workspace, or one project when `project_id` is set. Consumes cached profiles. + /// Rank every other subject in the workspace against this one by Y relatedness (gap §2). It is + /// one against all over the workspace, or over one project when `project_id` has a value. It + /// reads cached profiles. YMatches { biosample_guid: SampleGuid, project_id: Option, @@ -215,8 +220,8 @@ pub enum Command { YHaploReport { alignment_id: i64, }, - /// Assign a Y haplogroup from the subject's imported BISDNA / Y-SNP-panel calls (no - /// alignment) — records a donor call. + /// Assign a Y haplogroup from the imported BISDNA or Y-SNP-panel calls of the subject, with no + /// alignment. It records a donor call. AssignYBisdna { biosample_guid: SampleGuid, }, @@ -224,12 +229,14 @@ pub enum Command { AssignMtdnaHaplogroupFromAlignment { alignment_id: i64, }, - /// Estimate autosomal ancestry from the subject's CONSENSUS (no BAM walk) — the default path. + /// Estimate autosomal ancestry from the CONSENSUS of the subject, with no BAM walk. This is the + /// default path. EstimateAncestryFromConsensus { biosample_guid: SampleGuid, }, - /// Estimate **deep (ancient) ancestry** via qpAdm — genotypes the subject's best CHM13 alignment - /// at the full 1240k. Heavy (~1-2 min); explicit, on-demand. + /// Estimate **deep (ancient) ancestry** through qpAdm. It genotypes the best CHM13 alignment of + /// the subject at the full 1240k. It is heavy, at about 1 to 2 minutes, and it runs only on an + /// explicit request. EstimateDeepAncestry { biosample_guid: SampleGuid, }, @@ -237,7 +244,8 @@ pub enum Command { PaintAncestryFromConsensus { biosample_guid: SampleGuid, }, - /// Load the cached chromosome painting (if current for the consensus signature) — cheap. + /// Load the cached chromosome painting, when it is current for the consensus signature. The + /// cost is low. LoadPainting { biosample_guid: SampleGuid, }, @@ -245,7 +253,7 @@ pub enum Command { ComputeRohFromConsensus { biosample_guid: SampleGuid, }, - /// Load the cached ROH result (if current for the consensus signature) — cheap. + /// Load the cached ROH result, when it is current for the consensus signature. The cost is low. LoadRoh { biosample_guid: SampleGuid, }, @@ -253,7 +261,8 @@ pub enum Command { ComputeArchaicFromConsensus { biosample_guid: SampleGuid, }, - /// Load the cached archaic marker count (if current for the consensus signature) — cheap. + /// Load the cached archaic marker count, when it is current for the consensus signature. The + /// cost is low. LoadArchaic { biosample_guid: SampleGuid, }, @@ -261,7 +270,7 @@ pub enum Command { CallArchaicSegments { biosample_guid: SampleGuid, }, - /// Load the cached Tier B segment result — cheap. + /// Load the cached Tier B segment result. The cost is low. LoadArchaicSegments { biosample_guid: SampleGuid, }, @@ -279,15 +288,16 @@ pub enum Command { LoadPrivateY { alignment_id: i64, }, - /// Unified import: multiple files and/or folders (folders walked for data files), each - /// auto-detected + routed; returns one [`Event::DataBatchImported`] summary. + /// Unified import: more than one file, or folder, or both. It walks a folder for data files. It + /// detects the type of each one and sends it to the right place, then returns one + /// [`Event::DataBatchImported`] summary. AddDataBatch { biosample_guid: SampleGuid, paths: Vec, }, - /// First-run convenience: create a subject and import `paths` into it in one step, so the - /// Simple-mode empty state can go from nothing to a populated brief with a single file pick. - /// Returns [`Event::SubjectCreatedAndImported`] (the new guid + the import summary). + /// A convenience for a first run: make a subject, and import `paths` into it, in one step. The + /// empty state of Simple mode can then go from nothing to a full brief with one file pick. It + /// returns [`Event::SubjectCreatedAndImported`], with the new guid and the import summary. CreateSubjectAndImport { donor_identifier: String, sex: Option, @@ -307,12 +317,13 @@ pub enum Command { LoadDonorPrivateY { biosample_guid: SampleGuid, }, - /// Load the subject's multi-source Y-variant profile (concordance across all Y sources). - /// Load the persisted Y-profile snapshot (cheap; no genotyping). + /// Load the persisted snapshot of the multi-source Y-variant profile of the subject, which is + /// the concordance over all Y sources. The cost is low, and nothing genotypes. LoadYProfile { biosample_guid: SampleGuid, }, - /// Recompute the Y-profile from all sources and persist the snapshot (expensive — re-genotypes). + /// Compute the Y-profile again from all sources, and persist the snapshot. The cost is high, + /// because it genotypes again. BuildYProfile { biosample_guid: SampleGuid, }, @@ -321,19 +332,23 @@ pub enum Command { biosample_guid: SampleGuid, positions: Vec, }, - /// Load the persisted mtDNA consensus-profile snapshot (cheap; no genotyping). + /// Load the persisted mtDNA consensus-profile snapshot. The cost is low, and nothing + /// genotypes. LoadMtProfile { biosample_guid: SampleGuid, }, - /// Recompute the mtDNA consensus profile from all sources and persist (expensive — re-places). + /// Compute the mtDNA consensus profile again from all sources, and persist it. The cost is + /// high, because it places again. BuildMtProfile { biosample_guid: SampleGuid, }, - /// Load the persisted autosomal consensus-profile snapshot (cheap; no genotyping). + /// Load the persisted autosomal consensus-profile snapshot. The cost is low, and nothing + /// genotypes. LoadAutosomalProfile { biosample_guid: SampleGuid, }, - /// Recompute the autosomal consensus from all sources and persist (expensive — panel-genotypes). + /// Compute the autosomal consensus again from all sources, and persist it. The cost is high, + /// because it genotypes at the panel. BuildAutosomalProfile { biosample_guid: SampleGuid, }, @@ -342,7 +357,7 @@ pub enum Command { path: PathBuf, }, LoadCoverage(i64), - /// Cached coverage for several alignments at once (Data Sources alignment rows). + /// Cached coverage for more than one alignment at a time (the alignment rows of Data Sources). LoadCoverageBulk(Vec), /// Genome-region metadata (cytoband ideogram) for an alignment's build (Ideogram tab). LoadGenomeRegions { @@ -365,19 +380,19 @@ pub enum Command { contig: String, }, LoadAllAlignments, - /// How many of one project's alignments a realignment would actually act on. + /// How many alignments of one project a realignment would act on. /// - /// Asked of the app rather than counted in the UI. The card that shows this number used to - /// filter `all_alignments`, which is the whole workspace — so a project whose alignments were - /// every one already on the target build was still told 35 of them "in this project" could be - /// re-mapped. The batch it would have started was project-scoped and correct; only the number - /// was wrong, which is the more misleading way round. + /// This asks the app, and the UI does not count them. The card that shows this number used to + /// filter `all_alignments`, which is the whole workspace. So a project whose alignments were + /// every one already on the target build still read that 35 of them "in this project" could + /// map again. The batch it would have started covered the project, and was correct. Only the + /// number was wrong, and that is the worse way round. LoadRealignableInProject { project_id: i64, target_build: String, }, - /// Compare two samples (each a WGS alignment or an imported chip) over the chip-compatible IBD - /// panel — the volume-case path (chip↔chip / chip↔WGS). + /// Compare two samples over the IBD panel that works with a chip. Each sample is a WGS alignment + /// or an imported chip. This is the volume path, for chip↔chip and chip↔WGS. CompareIbdSources { a: navigator_app::IbdSource, b: navigator_app::IbdSource, @@ -387,7 +402,8 @@ pub enum Command { a: SampleGuid, b: SampleGuid, }, - /// Verify two SUBJECTS are the same individual over their pooled autosomal consensus (no panel). + /// Check whether two SUBJECTS are the same individual, over their pooled autosomal consensus, + /// with no panel. VerifyIdentityConsensus { a: SampleGuid, b: SampleGuid, @@ -395,21 +411,22 @@ pub enum Command { /// Federated IBD step 1: fetch the AppView's pseudonymous match suggestions for the /// signed-in account (registers the device key on first use). LoadIbdSuggestions, - /// Ask to be introduced to a candidate, recording the conversation in the matching ledger. + /// Ask for an introduction to a candidate, and record the conversation in the matching ledger. RequestIntroduction { suggestion: IbdSuggestion, biosample_guid: Option, }, - /// Tell the AppView to stop suggesting a candidate. + /// Tell the AppView to drop a candidate from its suggestions. DismissCandidate { suggested_sample_guid: String, }, - /// Adopt a local self-certifying did:key identity (desktop bootstrap — no PDS/OAuth). + /// Adopt a local did:key identity that certifies itself. It is the desktop bootstrap, with no + /// PDS and no OAuth. UseLocalIdentity, /// Reconcile the matching ledger against the broker (inbound requests + consent-ready sessions) /// and return every conversation. RefreshMatching, - /// Consent to (or decline) an inbound exchange request, recording the decision durably. + /// Consent to an inbound exchange request, or decline it, and record the decision durably. MatchingConsent { request_uri: String, given: bool, @@ -419,8 +436,9 @@ pub enum Command { ForgetMatchingRequest { request_uri: String, }, - /// Run a full IBD exchange for a subject over a consent-ready session (handshake → dosage - /// exchange → signed attestations → persist). Long-running; needs the peer online. + /// Run a full IBD exchange for a subject, over a session that has consent: handshake → dosage + /// exchange → signed attestations → persist. It takes a long time, and the peer must be + /// online. RunIbdExchange { info: ExchangeSessionInfo, biosample_guid: SampleGuid, @@ -433,7 +451,8 @@ pub enum Command { DmInitiate { partner_did: String, }, - /// Poll the DM inbox: inbound DM requests awaiting consent + consent-ready (not-yet-connected) sessions. + /// Poll the DM inbox: the inbound DM requests that wait for consent, and the sessions that have + /// consent and no connection yet. LoadDmInbox, /// Consent to (or decline) an inbound DM request. DmConsent { @@ -455,7 +474,7 @@ pub enum Command { session_id: String, text: String, }, - /// Pull + decrypt + persist any messages waiting on a conversation. + /// Pull, decrypt and persist any message that waits on a conversation. DmSync { session_id: String, }, @@ -466,14 +485,15 @@ pub enum Command { campaign_id: i64, accept: bool, }, - /// Resolve the sequencing lab for runs that have an inferred instrument id but no facility, - /// via the AppView instrument→lab map (best-effort, cached). Sent on startup + after imports. + /// Resolve the sequencing lab for a run that has an inferred instrument id and no facility. It + /// goes through the instrument→lab map of the AppView, which is best-effort and cached. The UI + /// sends it on startup, and after an import. BackfillLabs, - /// Report who's signed in (no side effects) — sent on startup. + /// Report who signed in. It has no side effect, and the UI sends it on startup. AuthStatus, /// Report the current online/offline state (no side effects). SyncStatus, - /// Sign in to a PDS via OAuth (opens a browser); `handle` is a handle or DID. + /// Sign in to a PDS through OAuth, which opens a browser. `handle` is a handle or a DID. Login { handle: String, }, @@ -483,16 +503,18 @@ pub enum Command { alignment_id: i64, contig: String, }, - /// Publish the subject's consensus ancestry breakdown (one record per method) to the signed-in PDS. + /// Publish the consensus ancestry breakdown of the subject, one record for each method, to the + /// PDS that signed in. PublishAncestry { biosample_guid: SampleGuid, }, - /// Publish the subject anchor — the anonymized biosample summary + its sequence runs — to the - /// signed-in PDS. The record every derived record (coverage/ancestry) ties back to. + /// Publish the subject anchor to the PDS that signed in. That anchor is the anonymized biosample + /// summary, plus its sequence runs. Every derived record, for coverage or ancestry, ties back to + /// it. PublishBiosample { biosample_guid: SampleGuid, }, - /// Attempt to push the ready outbox rows now (also runs periodically + after a publish). + /// Try to push the ready outbox rows now. It also runs at intervals, and after a publish. DrainOutbox, /// PULL reconcile: fetch the account's PDS records and reconcile against local (gap §5-p2). PullSync, @@ -538,35 +560,37 @@ pub enum Command { heteroplasmy: Vec, identity: Option, }, - /// Run the full per-alignment analysis pipeline (coverage → sex → metrics → variant calling → - /// Y haplogroup → ancestry), streaming `AnalysisProgress` per step. Each step's own result - /// event is forwarded too, so the detail tabs fill in as it runs. Structural variants are - /// **not** part of this — see [`Command::RunSv`], which the Sources tab dispatches on request. + /// Run the full analysis pipeline of one alignment: coverage → sex → metrics → variant calling + /// → Y haplogroup → ancestry. It streams an `AnalysisProgress` for each step. It also forwards + /// the result event of each step, so that the detail tabs fill in while it runs. Structural + /// variants are **not** part of this. See [`Command::RunSv`], which the Sources tab dispatches + /// on request. RunFullAnalysis { alignment_id: i64, }, - /// Run the full analysis on a subject's representative alignment, resolving it from the guid - /// (see `default_alignment_for_subject`). The Simple "My DNA" view uses this so a casual user can - /// analyze without first drilling into the Advanced sources table to find an alignment id. + /// Run the full analysis on the representative alignment of a subject. It resolves that + /// alignment from the guid (see `default_alignment_for_subject`). The Simple "My DNA" view uses + /// it. A casual user can then analyze with no visit to the Advanced sources table to find an + /// alignment id. AnalyzeSubject { biosample_guid: SampleGuid, }, /// Request cancellation of the in-flight full analysis (checked between steps). CancelAnalysis, - /// Realign an off-build alignment onto another reference (design/realignment-module.md). - /// Hours of work; streams `RealignProgress` per stage, then `RealignDone`. + /// Realign an off-build alignment onto another reference (design/realignment-module.md). It + /// takes hours. It streams a `RealignProgress` for each stage, then `RealignDone`. StartRealign { alignment_id: i64, target_build: String, }, - /// Stop a running realignment. Shares the cancellation registry with the analysis pipeline — - /// only one long job runs at a time, by design. + /// Stop a realignment in progress. It shares the cancellation registry with the analysis + /// pipeline, because only one long job runs at a time, by design. CancelRealign, /// Realign every eligible alignment in a project, one after another. /// - /// Sequential, not parallel: each job already saturates the machine's cores and wants ~12 GB, - /// so running two would be slower than running them in turn and might exhaust memory. Cancel - /// stops the current job and abandons the rest of the queue. + /// One after another, and not in parallel. Each job already fills the cores of the machine, and + /// it wants ~12 GB. Two at a time would be slower than two in turn, and they could exhaust the + /// memory. A cancel stops the current job, and abandons the rest of the queue. StartProjectRealign { project_id: i64, target_build: String, @@ -605,9 +629,11 @@ pub enum Command { }, /// Delete a subject. Refused by the app layer if it still has dependent data. DeleteBiosample(SampleGuid), - /// Clear all sequencing + derived/imported analysis data for a subject, keeping the subject. + /// Clear all the sequencing data of a subject, and all the analysis data, whether derived or + /// imported. The subject itself stays. ClearBiosampleData(SampleGuid), - /// Reset only the subject's haplogroup placement (stale-lineage cleanup), keeping other data. + /// Reset the haplogroup placement of the subject, and nothing else. It is the cleanup for a + /// stale lineage, and the other data stays. ClearHaplogroupData(SampleGuid), /// Delete a sequence run (cascades to its alignments + artifacts). `biosample_guid` is the /// owner, so the UI can refresh that subject's run list. @@ -682,19 +708,23 @@ pub enum Command { aligner: String, variant_caller: Option, }, - /// Load per-build reference-genome settings + cache status for the Settings dialog. + /// Load the reference-genome settings of each build, and the cache status, for the Settings + /// dialog. LoadReferenceSettings, - /// Force-refresh the cached haplotrees (clear the session memo + on-disk cache) so a corrected - /// AppView tree is picked up without an app restart; profiles re-interpret against it on reload. + /// Force a refresh of the cached haplotrees. It clears the session memo and the on-disk cache, + /// so that a corrected AppView tree arrives with no restart of the app. A profile then + /// interprets against it again on the next load. RefreshTrees, /// Health-check a local-LLM server at `base_url` (Settings "Test connection"): lists its models. TestLlmConnection { base_url: String, }, - /// Persist **all** reference-source overrides at once (the Settings "References" table). One - /// command → one atomic write; per-row commands raced the config file into corruption (#26). + /// Persist **all** the reference-source overrides at one time (the "References" table of + /// Settings). One command → one atomic write. One command for each row raced the config file + /// into corruption (#26). SetReferenceOverrides(Vec), - /// Re-hash a cached reference against its integrity sidecar (Settings "Verify"). + /// Hash a cached reference again, and compare it with its integrity sidecar. The integrity-check + /// button of Settings sends this. VerifyReference { build: String, }, @@ -706,8 +736,8 @@ pub enum Command { out_vcf: PathBuf, filter_par: bool, }, - // ---- social (Community tab — signed AppView Edge API) ------------------- - /// List the signed-in account's support threads (team↔tester). + // ---- social (the Community tab, over the signed AppView Edge API) ------- + /// List the support threads of the account that signed in (team↔tester). LoadSupportThreads, /// Read one support thread's messages (marks it read server-side). LoadSupportThread { @@ -725,8 +755,8 @@ pub enum Command { }, /// Read the community feed (announcements + community + federated). LoadCommunityFeed, - /// Post to the community feed (optionally tagged with a topic). When `publish_pds` is set, the - /// post is *also* published to the signed-in PDS as a federated `feed.post` record (roadmap 3b). + /// Post to the community feed, with an optional topic tag. When `publish_pds` has a value, the + /// post *also* goes to the PDS that signed in, as a federated `feed.post` record (roadmap 3b). PostCommunity { content: String, topic: Option, @@ -743,7 +773,7 @@ pub enum Command { /// A result/notification from the worker to the UI. #[derive(Debug, Clone)] pub enum Event { - /// Nothing to report (e.g. a cache-load that missed) — the UI ignores it. + /// Nothing to report, for example a cache load that missed. The UI ignores it. Noop, Overview(Vec), /// Ancestry/IBD asset presence + integrity (the "data sources" transparency line). @@ -751,13 +781,13 @@ pub enum Event { /// Haplotrees were force-refreshed (N cached files cleared); the UI re-loads open profiles. TreesRefreshed(usize), ProjectCreated(Project), - /// A project was updated or deleted; reload the overview. + /// Something changed or deleted a project, so load the overview again. ProjectsChanged, /// A batch project-directory import completed. ProjectImported(ProjectImportSummary), /// A dry-run FTDNA import plan, ready for the review modal. FtdnaPlan(FtdnaImportPlan), - /// The result of committing an FTDNA import. + /// The result of a commit of an FTDNA import. FtdnaImported(FtdnaImportSummary), /// A subject's imported genealogy bundle for the detail card. Genealogy { @@ -781,38 +811,43 @@ pub enum Event { received: u64, total: Option, }, - /// A reference build finished resolving (cached + indexed). + /// A reference build resolved. It is now in the cache, and it has an index. ReferenceReady { build: String, path: PathBuf, }, - /// Building a BAM/CRAM coordinate index (`.bai`/`.crai`) so region queries work. `total` is the - /// compressed file size for a BAM (byte fraction) and `None` for a CRAM (indeterminate spinner). + /// A build of a BAM or CRAM coordinate index (`.bai`/`.crai`) is in progress, so that a region + /// query works. `total` is the compressed file size for a BAM, which gives a byte fraction. It + /// is `None` for a CRAM, which gives a spinner with no fraction. IndexProgress { file: String, done: u64, total: Option, }, - /// A coordinate index finished building (or none was needed — `built` is the written path, if any). + /// A coordinate index build ended, or nothing needed one. `built` is the path the code wrote, + /// when there is one. IndexReady { built: Option, }, - /// A newer installer is available on GitHub Releases (the user is notified; no auto-update). + /// A newer installer exists on GitHub Releases. The app tells the user, and it never updates + /// itself. UpdateAvailable(Box), - /// The installer-update check ran and the app is already current (or the check was skipped). + /// The installer-update check ran, and the app is already current. This also covers a check + /// that did not run. UpToDate, - /// Per-sample coverage/haplogroup report for a project. + /// The coverage and haplogroup report of each sample, for a project. ProjectReport { project_id: i64, rows: Vec, }, - /// Precomputed per-member Y-STR overview (FTDNA-style chart) for a project. + /// The Y-STR overview of each member (the FTDNA-style chart) for a project, computed first. ProjectStrChart { project_id: i64, chart: ProjectStrChart, }, - /// The cohort Y block tree for a project. `tree` is `None` when the project has no members at - /// all — distinct from a tree with no placed members, which comes back with everyone `unplaced`. + /// The cohort Y block tree for a project. `tree` is `None` when the project has no member at + /// all. That differs from a tree where placement reached nobody, which comes back with everybody + /// `unplaced`. ProjectBlockTree { project_id: i64, tree: Box>, @@ -829,15 +864,16 @@ pub enum Event { dna: DnaType, result: Result, String>, }, - /// A per-marker branch report for a subject's Y/mtDNA subtree (`None` = no alignment; `Err` = a - /// load/lookup failure, e.g. node not found, surfaced to the status line). + /// A branch report for each marker, over the Y or mtDNA subtree of a subject. `None` means there + /// is no alignment. `Err` is a failure of the load or the lookup, for example a node nothing + /// found, and it goes to the status line. BranchReportLoaded { guid: SampleGuid, dna: DnaType, result: Result, String>, }, - /// A streamed slice of narration text as it is generated (live preview; the final BriefNarration - /// is authoritative). + /// A slice of narration text from the stream, while the model writes it. It is a live preview, + /// and the final BriefNarration is the authoritative one. BriefNarrationChunk { guid: SampleGuid, text: String, @@ -847,7 +883,7 @@ pub enum Event { guid: SampleGuid, result: Result, }, - /// A streamed slice of a chat answer as it is generated (live preview). + /// A slice of a chat answer from the stream, while the model writes it. It is a live preview. ChatAnswerChunk { guid: SampleGuid, text: String, @@ -857,7 +893,8 @@ pub enum Event { guid: SampleGuid, result: Result, }, - /// A streamed slice of a per-signal "Explain this" narration (live preview). + /// A slice of an "Explain this" narration for one signal, from the stream. It is a live + /// preview. SignalNarrationChunk { guid: SampleGuid, kind: SignalKind, @@ -869,8 +906,9 @@ pub enum Event { kind: SignalKind, result: Result, }, - /// A project-wide analyze pass finished (coverage + Y per sample). `cancelled` is true when a - /// streaming deep-analyze was stopped early (counts reflect what completed before the stop). + /// An analyze pass over the whole project ended, with coverage and Y for each sample. + /// `cancelled` is true when something stopped a streaming deep-analyze early, and the counts + /// then cover only what ended before the stop. ProjectAnalyzed { project_id: i64, samples: usize, @@ -881,8 +919,9 @@ pub enum Event { errors: usize, cancelled: bool, }, - /// Per-sample progress of a streaming deep-analyze pass: `done` of `total` samples processed, - /// `sample` is the donor id currently being analyzed, `fraction` drives the bar (0..1). + /// The progress of a streaming deep-analyze pass, one sample at a time: `done` of `total` + /// samples so far. `sample` is the donor id under analysis now, and `fraction` drives the bar + /// (0..1). DeepAnalyzeProgress { project_id: i64, done: usize, @@ -892,7 +931,8 @@ pub enum Event { }, /// The workspace-chore survey: what each chore would do if run now. MaintenanceSurvey(Vec), - /// Per-item progress of a running chore. `label` is the subject currently being worked. + /// The progress of a chore in progress, one item at a time. `label` is the subject it works on + /// now. ChoreProgress { chore: navigator_app::Chore, done: usize, @@ -900,13 +940,14 @@ pub enum Event { label: String, fraction: f32, }, - /// A chore finished (or was cancelled after doing `outcome.done` items). + /// A chore ended, or a cancel stopped it after `outcome.done` items. ChoreDone { chore: navigator_app::Chore, outcome: navigator_app::ChoreOutcome, }, - /// Per-sample progress of a streaming project-directory import: `done` of `total` samples - /// written, `sample` is the sample id currently being imported, `fraction` drives the bar. + /// The progress of a streaming project-directory import, one sample at a time: `done` of + /// `total` samples written. `sample` is the sample id the import writes now, and `fraction` + /// drives the bar. ImportProgress { done: usize, total: usize, @@ -919,18 +960,22 @@ pub enum Event { }, /// All biosamples (the project-independent subjects list). AllBiosamples(Vec), - /// Per-subject Y/mt terminal haplogroups for the subjects list (`guid → (Y, mt)`). + /// The terminal Y and mt haplogroups of each subject, for the subjects list + /// (`guid → (Y, mt)`). HaploSummary(std::collections::HashMap, Option)>), - /// Per-subject analysis status (Pending/Complete) for the subjects-list Status column. + /// The analysis status of each subject (Pending or Complete), for the Status column of the + /// subjects list. SubjectStatus(std::collections::HashMap), - /// A biosample was added/changed; reload the subjects list (and any open project view). + /// Something added or changed a biosample. Load the subjects list again, and any open project + /// view. BiosamplesChanged, Runs { biosample_guid: SampleGuid, runs: Vec, }, RunsChanged(SampleGuid), - /// A subject's analysis data was cleared (the UI fully reloads that subject + the list columns). + /// Something cleared the analysis data of a subject. The UI then loads that subject again in + /// full, and the list columns too. BiosampleDataCleared(SampleGuid), /// A subject's haplogroup placement was reset (the UI reloads that subject; other data stays). HaplogroupDataReset(SampleGuid), @@ -977,8 +1022,9 @@ pub enum Event { biosample_guid: SampleGuid, matches: Vec, }, - /// A batch import finished; the summary lists per-file imported/skipped outcomes. The UI - /// shows it in a modal and reloads the subject's data sections. + /// A batch import ended. The summary lists, for each file, whether the import took it or + /// stepped over it. The UI shows that in a modal, and loads the data sections of the subject + /// again. DataBatchImported { biosample_guid: SampleGuid, summary: BatchImportSummary, @@ -1015,7 +1061,8 @@ pub enum Event { alignment_id: i64, assignment: HaploAssignment, }, - /// Local-ancestry painting per chromosome (the "DNA painting"): per-side segments + side labels. + /// The local-ancestry painting of each chromosome (the "DNA painting"): the segments of each + /// side, and the side labels. AncestryPainting { alignment_id: i64, result: PaintingResult, @@ -1099,7 +1146,8 @@ pub enum Event { alignment_id: i64, result: Option, }, - /// Cached coverage for several alignments (Data Sources rows): `(alignment_id, result)`. + /// Cached coverage for more than one alignment (the Data Sources rows): + /// `(alignment_id, result)`. CoverageBulk(Vec<(i64, Option)>), /// Genome-region metadata (cytoband ideogram) for an alignment's build. GenomeRegions { @@ -1123,8 +1171,8 @@ pub enum Event { contig: String, result: Option>, }, - /// Full-analysis pipeline progress: starting `step` of `total` (1-based), with a `label` - /// + `detail` and the bar `fraction` (0..1). + /// The progress of the full-analysis pipeline: `step` of `total` begins, and both count from 1. + /// It carries a `label`, a `detail`, and the bar `fraction` (0..1). AnalysisProgress { step: usize, total: usize, @@ -1132,7 +1180,7 @@ pub enum Event { detail: String, fraction: f32, }, - /// The full-analysis pipeline finished (or was cancelled). + /// The full-analysis pipeline ended, or a cancel stopped it. AnalysisDone { cancelled: bool, }, @@ -1147,16 +1195,16 @@ pub enum Event { label: String, detail: String, }, - /// A project-wide realignment finished. Separate from `RealignDone` (which fires per sample) - /// because a batch has its own outcome: how many of the queue actually completed, and whether - /// the rest were abandoned. An empty queue reports `queued: 0` rather than saying nothing. + /// A realignment over the whole project ended. It is separate from `RealignDone`, which fires + /// for each sample, because a batch has its own outcome. That outcome is how many of the queue + /// ended, and whether the rest went. An empty queue reports `queued: 0`, and never nothing. RealignBatchDone { queued: usize, completed: usize, cancelled: bool, }, - /// A realignment finished, was cancelled, or failed. `new_alignment_id` is present only on - /// success — the row is inserted last, so its absence means nothing was registered. + /// A realignment ended, a cancel stopped it, or it failed. `new_alignment_id` has a value only + /// on success. The insert of that row comes last, so an absence means nothing registered. RealignDone { alignment_id: i64, biosample_guid: Option, @@ -1165,9 +1213,9 @@ pub enum Event { summary: String, }, AllAlignments(Vec), - /// The alignments in `project_id` a realignment to the target build would act on. Carries the - /// project id so a reply arriving after the user has moved on is discarded rather than shown - /// against the wrong project. + /// The alignments in `project_id` that a realignment to the target build would act on. It + /// carries the project id. A reply that comes back after the user moves on then goes away, and + /// it does not appear against the wrong project. RealignableInProject { project_id: i64, ids: Vec, @@ -1175,23 +1223,23 @@ pub enum Event { Ibd(IbdComparison), /// Federated IBD match suggestions from the AppView (may be empty in a single-user dev AppView). IbdSuggestions(Vec), - /// The matching ledger — every conversation with its result attached. Emitted by the refresh - /// and by every mutation, so the panel never has to re-poll the broker to see its own action. + /// The matching ledger: every conversation, with its result. The refresh emits it, and so does + /// every mutation, so the panel never has to poll the broker again to see its own action. Matching(Vec), - /// A candidate was dismissed; the UI drops its row. + /// Something dismissed a candidate, so the UI drops its row. CandidateDismissed { suggested_sample_guid: String, }, - /// A DM request was opened to a partner DID (the UI refreshes the inbox). + /// A DM request went out to a partner DID. The UI refreshes the inbox. DmInitiated, /// The DM inbox: inbound DM requests + consent-ready sessions to connect. DmInbox { incoming: Vec, ready: Vec, }, - /// A DM consent was recorded (the UI refreshes the inbox). + /// The store took a DM consent. The UI refreshes the inbox. DmConsented, - /// A DM session was connected (key persisted); the UI refreshes the conversation list. + /// A DM session connected, and the key is on disk. The UI refreshes the conversation list. DmConnected, /// The persisted DM conversation list. DmConversations(Vec), @@ -1200,7 +1248,7 @@ pub enum Event { session_id: String, rows: Vec, }, - /// A DM was sent (the UI reloads the open transcript). + /// A DM went out. The UI loads the open transcript again. DmSent { session_id: String, }, @@ -1211,7 +1259,8 @@ pub enum Event { }, /// The signed-in account's open recruitment invitations. RecruitmentInvitations(Vec), - /// A recruitment invitation response was recorded (the UI refreshes invitations + notifications). + /// The store took a response to a recruitment invitation. The UI refreshes the invitations and + /// the notifications. RecruitmentResponded, /// A full IBD exchange completed for a subject (the UI reloads its results). IbdExchangeDone { @@ -1246,25 +1295,26 @@ pub enum Event { }, /// Current signed-in account (DID), or `None` when signed out. Authenticated(Option), - /// A record was published; `kind` is a human label, `uri` the `at://` URI. + /// A record went out. `kind` is a human label, and `uri` is the `at://` URI. Published { kind: String, uri: String, }, - /// A publish was enqueued to the durable outbox (it'll send now if online, else on reconnect). + /// A publish went into the durable outbox. It sends now if the app is online, and on the next + /// connection if it is not. Queued { kind: String, }, - /// Outbox rows still awaiting a successful push (the "N pending" indicator). + /// The outbox rows that still wait for a push to succeed (the "N pending" indicator). SyncPending(i64), - /// A result was exported to `path`; `label` is the human kind (e.g. "coverage (TSV)"). + /// A result went to `path`. `label` is the human kind, for example "coverage (TSV)". Exported { label: String, path: PathBuf, }, /// Whether the last PDS write reached the server (offline indicator). SyncOnline(bool), - /// A PULL reconcile finished (gap §5-p2): the per-action tallies. + /// A PULL reconcile ended (gap §5-p2). It carries the tally of each action. PullDone { in_sync: usize, applied: usize, @@ -1272,7 +1322,8 @@ pub enum Event { repushed: usize, conflicts: usize, }, - /// Source-file accessibility re-check finished; `missing` files are moved/deleted. + /// A second check on whether the source files are reachable ended. A `missing` file moved, or + /// something deleted it. SourceFilesVerified { missing: usize, }, @@ -1285,11 +1336,11 @@ pub enum Event { LabsResolved(usize), /// Local-LLM "Test connection" result: the models the server reports, or a plain-language error. LlmConnection(Result, String>), - /// Per-build reference-genome settings + cache status for the Settings dialog. + /// The reference-genome settings of each build, and the cache status, for the Settings dialog. ReferenceSettings(Vec), - /// A reference override was saved; the UI may reload the settings rows. + /// The store took a reference override. The UI can load the settings rows again. ReferenceSettingsChanged, - /// Result of a reference integrity check (a short human-readable status per build). + /// The result of a reference integrity check: one short human-readable status for each build. ReferenceVerified { build: String, status: String, @@ -1306,7 +1357,8 @@ pub enum Event { conversation_id: String, messages: Vec, }, - /// A support thread was opened/replied; reload the list (+ the open thread). + /// Somebody opened a support thread, or replied to one. Load the list again, and the open + /// thread. SupportThreadPosted { conversation_id: String, }, @@ -1319,41 +1371,41 @@ pub enum Event { items: Vec, unread: i64, }, - /// Notifications were marked read; reload them. + /// Something marked the notifications read. Load them again. NotificationsMarked, Error(String), - /// A command failed **and** a file-level preflight found a concrete cause. `message` is the - /// original error (still shown in the status bar); `report` is the pasteable diagnosis naming - /// the file actually at fault. + /// A command failed, **and** a preflight at file level found a concrete cause. `message` is the + /// original error, and the status bar still shows it. `report` is the diagnosis to paste, and it + /// names the file at fault. /// - /// 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. + /// It is separate from [`Event::Error`], so that the UI can offer the report. The UI does not + /// have to guess from a string whether an error has one. It appears only when the preflight + /// failed a check. A tree download, or a network error, must not raise a file report. 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 - /// requesting control spinning forever — a standalone SV/de-novo run has no `AnalysisDone` to - /// clear its in-flight flag). + /// It differs from `Error`, because this is not a failure. It also differs from `Noop`, which + /// would leave the control that asked for it in a spin for ever. A standalone SV or de-novo run + /// has no `AnalysisDone` to clear its flag. Cancelled, } -/// How a cancellation reads once it has been flattened to a string by an event. +/// How a cancellation reads after an event flattens it to a string. const CANCELLED_MESSAGE: &str = "cancelled"; -/// The token for whichever cancellable run is in flight, so `CancelAnalysis` can reach it. +/// The token of whichever cancellable run is in progress, so that `CancelAnalysis` can reach it. /// -/// Replaces a single shared `AtomicBool` that each run reset to `false` at its own entry. Because -/// every command is `tokio::spawn`ed, that reset raced the click: a cancel landing between the -/// spawn and the reset was silently wiped, and a second run starting concurrently wiped the first -/// one's pending cancel too. A [`CancelToken`] is created once per run and never un-cancelled, so -/// there is no window in which a cancel can be lost. +/// It replaces one shared `AtomicBool` that each run reset to `false` at its own start. Every +/// command goes through `tokio::spawn`, so that reset raced the click. A cancel that arrived between +/// the spawn and the reset went away with no message. A second run that started at the same time +/// also wiped the cancel the first one still needed. A [`CancelToken`] comes into existence one time +/// for each run, and nothing ever un-cancels it, so there is no window where a cancel can go. /// -/// The generation counter keeps a finishing run from clearing a *newer* run's registration — the -/// same stale-write bug in a different costume. +/// The generation counter stops a run that ends from a clear of the registration of a *newer* run. +/// That is the same stale-write fault in different clothes. #[derive(Clone, Default)] struct CancelRegistry { current: Arc>>, @@ -1361,7 +1413,7 @@ struct CancelRegistry { } impl CancelRegistry { - /// Register a fresh token for a starting run. Returns its generation and the token. + /// Register a fresh token for a run that starts. Returns its generation and the token. fn begin(&self) -> (u64, CancelToken) { let gen = self.next_gen.fetch_add(1, Ordering::Relaxed); let token = CancelToken::new(); @@ -1369,7 +1421,7 @@ impl CancelRegistry { (gen, token) } - /// Retire this run's registration — but only if a newer run has not already replaced it. + /// Retire the registration of this run, but only when no newer run already replaced it. fn end(&self, gen: u64) { let mut slot = self.current.lock().unwrap(); if slot.as_ref().is_some_and(|(g, _)| *g == gen) { @@ -1377,8 +1429,8 @@ impl CancelRegistry { } } - /// Cancel whatever is running now. A no-op when nothing is, which is what makes a stray click - /// harmless rather than something that poisons the next run. + /// Cancel whatever runs now. It does nothing when nothing runs, and that is what makes a stray + /// click harmless, and not something that poisons the next run. fn cancel_current(&self) { if let Some((_, token)) = self.current.lock().unwrap().as_ref() { token.cancel(); @@ -1386,30 +1438,32 @@ impl CancelRegistry { } } -/// Settle a finished alignment command into the event the UI should actually see. +/// Settle a finished alignment command into the event the UI must see. /// -/// Two things have to happen between a command failing and a user reading about it, and both are -/// about not reporting the wrong thing: +/// Two things have to happen between a command that fails and a user who reads about it. Both are +/// there to keep the wrong thing off the screen. /// -/// 1. A **cancellation** is swallowed. It travels as an error so it can unwind the walk from deep -/// inside a walker, but the user asked for it — surfacing "Error: cancelled" would report their -/// own click back to them as a failure. The run's `AnalysisDone { cancelled }` already says so. -/// 2. A **genuine** failure gets a file-level diagnosis attached, because: +/// 1. This drops a **cancellation**. It travels as an error, so that it can unwind the walk from +/// deep inside a walker. But the user asked for it, and "Error: cancelled" would report their +/// own click back to them as a failure. The `AnalysisDone { cancelled }` of the run already says +/// it. +/// 2. A **genuine** failure gets a diagnosis at file level. /// -/// The errors this upgrades are the ones that name a path but not the *right* path: the reader -/// helpers report whichever path the failing call was handed, so a bad index, an unreadable -/// reference or a privacy-denied file all surface as `io error on `. Running -/// [`App::diagnose_alignment`] probes each of those files separately and says which one it is. +/// The errors this upgrades are the ones that name a path, and not the *right* path. The reader +/// helpers report whatever path the call that failed received. So a bad index, a reference nothing +/// can read, and a file behind a privacy denial all come out as `io error on `. A +/// run of [`App::diagnose_alignment`] probes each of those files on its own, and says which one it +/// is. /// -/// Errors with no file-level cause pass through untouched — if every preflight check passes, the -/// failure is genuinely elsewhere (tree fetch, liftover, appview) and a clean bill of health would -/// be worse than saying nothing. +/// An error with no cause at file level passes through untouched. If every preflight check passes, +/// the failure is truly somewhere else, in a tree fetch, a liftover, or the appview. A clean bill of +/// health would then be worse than nothing. async fn settle_alignment_command(app: &App, alignment_id: i64, event: Event) -> Event { let Event::Error(message) = event else { return event; }; - // A cancelled walk is not a file problem, and not a failure: diagnosing it would be a slow lie - // and reporting it would contradict the user's own action. + // A walk the user cancelled is not a file problem, and not a failure. A diagnosis of it would + // be a slow lie, and a report of it would contradict the action of the user. if message == CANCELLED_MESSAGE { return Event::Cancelled; } @@ -1422,10 +1476,9 @@ async fn settle_alignment_command(app: &App, alignment_id: i64, event: Event) -> } } -/// Execute one command against the app, mapping success/failure to an [`Event`]. -/// Re-read a subject's genealogy bundle (vendor ids + FTDNA member + MDKA) and wrap it as a -/// [`Event::Genealogy`] — the refresh emitted after any genealogy mutation so the detail card -/// reflects the new state without a separate "changed" round-trip. +/// Read the genealogy bundle of a subject again (the vendor ids, the FTDNA member, and the MDKA), +/// and wrap it as an [`Event::Genealogy`]. This is the refresh that follows any genealogy mutation, +/// so that the detail card shows the new state, with no separate "changed" round-trip. async fn reload_genealogy(app: &App, guid: SampleGuid) -> Event { ev(app.subject_genealogy(guid).await, |data| Event::Genealogy { guid, @@ -1433,17 +1486,20 @@ async fn reload_genealogy(app: &App, guid: SampleGuid) -> Event { }) } -/// Map a fallible app call to an [`Event`]: `ok` names the success event, and **any** error becomes -/// `Event::Error` carrying the error's `Display` text — the one place that policy is written down. +/// Map an app call that can fail to an [`Event`]. `ok` names the success event, and **any** error +/// becomes `Event::Error`, with the `Display` text of that error. This is the one place that writes +/// the policy down. /// -/// Nearly every arm of [`handle`] has this shape, so spelling it here keeps ~120 call sites from each -/// restating it. `ok` is usually just the event constructor (`ev(app.refresh_trees().await, -/// Event::TreesRefreshed)`); a closure covers the struct-variant and extra-field cases. Generic over -/// the error type so store/analysis/io errors all route the same way; `FnOnce` so the closure may move -/// captured values into the event. +/// Almost every arm of [`handle`] has this shape, so one definition here keeps ~120 call sites from +/// each writing it out. `ok` is usually the event constructor alone +/// (`ev(app.refresh_trees().await, Event::TreesRefreshed)`), and a closure covers a struct variant, +/// and a case with extra fields. It is generic over the error type, so that a store error, an +/// analysis error and an io error all take the same route. It is `FnOnce`, so that the closure can +/// move a captured value into the event. /// -/// Arms that need more than this — a three-way `Ok(Some)`/`Ok(None)` split, an `.await` on the success -/// path, or a non-`Display` message — keep their explicit `match`. +/// An arm that needs more than this keeps its explicit `match`. That covers a three-way split of +/// `Ok(Some)` and `Ok(None)`, an `.await` on the success path, and a message that is not +/// `Display`. fn ev(result: Result, ok: impl FnOnce(T) -> Event) -> Event { match result { Ok(value) => ok(value), @@ -1451,6 +1507,7 @@ fn ev(result: Result, ok: impl FnOnce(T) -> Event } } +/// Do one command against the app, and map its success or failure to an [`Event`]. pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { match cmd { Command::LoadOverview => ev(app.project_overview().await, Event::Overview), @@ -1462,7 +1519,8 @@ 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. + // ImportProjectDir streams ImportProgress from the spawn loop, so a path that arrives here + // is a fault. Command::ImportProjectDir { .. } => Event::Error("internal: unrouted ImportProjectDir".into()), Command::PlanFtdnaImport { project_id, @@ -1494,8 +1552,8 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { 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. + // The spawn loop controls ResolveReference, because it streams progress events. A path that + // arrives here would be a fault in the routes. 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, @@ -1530,16 +1588,19 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { .await .map_err(|e| e.to_string()), }, - // NarrateBrief / AskQuestion stream from the spawn loop; reaching here is a bug. + // NarrateBrief and AskQuestion stream from the spawn loop, so a path that arrives here is + // a fault. Command::NarrateBrief(guid) => Event::Error(format!("internal: unrouted NarrateBrief {guid}")), Command::AskQuestion { guid, .. } => Event::Error(format!("internal: unrouted AskQuestion {guid}")), Command::NarrateSignal { guid, .. } => Event::Error(format!("internal: unrouted NarrateSignal {guid}")), - // DeepAnalyzeProject streams DeepAnalyzeProgress from the spawn loop; reaching here is a bug. + // DeepAnalyzeProject streams DeepAnalyzeProgress from the spawn loop, so a path that + // arrives here is a fault. Command::DeepAnalyzeProject(project_id) => { Event::Error(format!("internal: unrouted DeepAnalyzeProject {project_id}")) } Command::SurveyMaintenance => ev(app.maintenance_survey().await, Event::MaintenanceSurvey), - // RunChore streams ChoreProgress from the spawn loop; reaching here is a bug. + // RunChore streams ChoreProgress from the spawn loop, so a path that arrives here is a + // fault. Command::RunChore { chore, .. } => Event::Error(format!("internal: unrouted RunChore {}", chore.key())), Command::LoadSubjectStatus => ev(app.subject_analysis_status().await, Event::SubjectStatus), Command::LoadHaploSummary => ev(app.haplogroup_terminals().await, Event::HaploSummary), @@ -1856,7 +1917,8 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { }) } Command::PaintAncestryFromConsensus { biosample_guid } => { - // Painting from the consensus needs no genotyping pass — fast, no progress stream. + // A painting from the consensus needs no genotyping pass. It is fast, and it streams no + // progress. ev( app.paint_local_ancestry_from_consensus(biosample_guid).await, |result| Event::AncestryPainting { @@ -1872,7 +1934,8 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { } }), Command::ComputeRohFromConsensus { biosample_guid } => { - // ROH from the consensus needs no genotyping pass — fast, no progress stream. + // ROH from the consensus needs no genotyping pass. It is fast, and it streams no + // progress. ev(app.compute_roh_from_consensus(biosample_guid).await, |result| { Event::RohResultReady { biosample_guid, @@ -1887,7 +1950,7 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { }) } Command::ComputeArchaicFromConsensus { biosample_guid } => { - // A pure read over the cached consensus + the marker panel — no genotyping pass. + // A pure read over the cached consensus and the marker panel, with no genotyping pass. ev(app.estimate_archaic_from_consensus(biosample_guid).await, |result| { Event::ArchaicResultReady { biosample_guid, @@ -1923,8 +1986,9 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { .await .unwrap_or(None) .map(Box::new); - // Only ANCIENT_ADMIXTURE is read: the retired PCA_PROJECTION_GMM / G25_NMONTE rows may - // still exist in databases written before the rebuild, and must never be shown again. + // This reads ANCIENT_ADMIXTURE only. The retired PCA_PROJECTION_GMM and G25_NMONTE + // rows can still sit in a database from before the rebuild, and nothing must show them + // again. let ancient = if navigator_app::ANCIENT_ANCESTRY_ENABLED { app.consensus_ancestry(biosample_guid, navigator_app::ANCIENT_ADMIXTURE) .await @@ -1939,8 +2003,8 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { ancient, } } - // RunFullAnalysis streams AnalysisProgress from the spawn loop; CancelAnalysis sets the - // shared cancel flag there. Reaching here would mean a routing bug. + // RunFullAnalysis streams AnalysisProgress from the spawn loop, and CancelAnalysis sets the + // shared cancel flag there. A path that arrives here would be a fault in the routes. Command::RunFullAnalysis { alignment_id } => { Event::Error(format!("internal: unrouted RunFullAnalysis {alignment_id}")) } @@ -2163,8 +2227,9 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { }, Command::RunIbdExchange { info, biosample_guid } => { let cfg = IbdDetectorConfig::default(); - // A failure is recorded on the conversation rather than only surfaced as a transient - // toast — otherwise the request sits at READY and the user can not tell it was tried. + // The store records a failure on the conversation, and it is not only a toast that + // goes away. If not, the request sits at READY, and the user can not tell that anything + // tried it. let outcome = match app.open_exchange_session(&info).await { Ok(session) => { app.exchange_ibd_for_subject(&session, biosample_guid, &info.request_uri, None, cfg) @@ -2234,8 +2299,9 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { }), 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 - // multiple events: Queued + per-row Published + SyncPending). Reaching here is a routing bug. + // A publish goes into the durable outbox, and then drains. The spawn loop controls that, + // because it emits more than one event: Queued, then a Published for each row, then a + // pending-count update. A path that arrives here is a fault in the routes. Command::PublishCoverage(id) => Event::Error(format!("internal: unrouted PublishCoverage {id}")), Command::PublishVariants { alignment_id, .. } => { Event::Error(format!("internal: unrouted PublishVariants {alignment_id}")) @@ -2331,8 +2397,9 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { topic, publish_pds, } => match app.post_community(&content, topic.as_deref(), None).await { - // The native post landed. If the user opted into federation, also publish the durable - // `feed.post` record; a publish failure is surfaced but the post itself is not lost. + // The native post landed. When the user opted into federation, also publish the + // durable `feed.post` record. A publish that fails reaches the screen, and the post + // itself stays. Ok(_) => { if publish_pds { ev(app.publish_feed_post(&content, topic.as_deref()).await, |_| { @@ -2354,17 +2421,18 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { } } -/// Resolve a reference build, emitting throttled `ReferenceProgress` events (and waking the -/// UI) as bytes arrive, then a final `ReferenceReady`/`Error`. Run from the spawn loop so it -/// can stream — `handle` returns only a single event. +/// Resolve a reference build. It emits a throttled `ReferenceProgress` event as bytes arrive, and +/// wakes the UI, then a final `ReferenceReady` or `Error`. It runs from the spawn loop, so that it +/// can stream, because `handle` returns one event only. async fn resolve_reference_streaming( app: &App, build: String, evt_tx: &Sender, wake: &(dyn Fn() + Send + Sync), ) { - // The progress closure must be Send (it runs in a task) — capture an owned Sender clone - // and a label, not borrows. Throttle to ~every 25 MB so a multi-GB pull does not flood. + // The progress closure must be Send, because it runs in a task. So capture an owned Sender + // clone and a label, and not a borrow. Throttle it to about every 25 MB, so that a multi-GB + // download does not flood the channel. let tx = evt_tx.clone(); let label = build.clone(); let mut last_sent = 0u64; @@ -2386,12 +2454,15 @@ async fn resolve_reference_streaming( wake(); } -/// Resolve each not-yet-cached build with a visible `ReferenceProgress` bar (via -/// [`resolve_reference_streaming`]). Cached builds are skipped silently. The reference FASTA is a -/// required artifact for any BAM/CRAM analysis and is fetched on demand (cache-first, else a -/// multi-GB download) — without this the download runs deep inside a pure `App` method with a no-op -/// callback, so the UI shows nothing and a first import looks like it "did not register". Call this -/// from the worker after an import and before a reference-needing analysis so the pull is visible. +/// Resolve each build that the cache does not hold, with a visible `ReferenceProgress` bar, through +/// [`resolve_reference_streaming`]. A build the cache holds goes by with no message. +/// +/// Any BAM or CRAM analysis needs the reference FASTA. The code reads it on demand: the cache +/// first, and a multi-GB download if that misses. Without this, the download runs deep inside a +/// pure +/// `App` method, behind a callback that does nothing. The UI then shows nothing, and a first import +/// looks as though it "did not register". Call this from the worker after an import, and before an +/// analysis that needs a reference, so that the download is visible. async fn ensure_references_streaming( app: &App, builds: &[String], @@ -2406,14 +2477,16 @@ async fn ensure_references_streaming( } } -/// Build the alignment's coordinate index (`.bai`/`.crai`) if missing, emitting throttled -/// `IndexProgress` then a final `IndexReady`. Query-driven analyses need the index to seek by region -/// (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). +/// Build the coordinate index (`.bai`/`.crai`) of the alignment when it is missing. It emits a +/// throttled `IndexProgress`, then a final `IndexReady`. An analysis that runs queries needs the +/// index to seek by region. Without it, that analysis errors, or it falls back to a scan of the +/// whole file. A build up front, with a visible bar, keeps a file that just came in from a look of +/// being stuck on its first analysis. A file that already has an index returns at once with +/// `built: None`, and no progress noise. 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. + // Progress runs on a thread that blocks, so the callback must be Send. Capture owned clones, + // and not borrows. The analysis layer already throttles it, to about every 32 MB, so forward + // each tick. let tx = evt_tx.clone(); let label = app .reference_build_of_alignment(alignment_id) @@ -2429,9 +2502,10 @@ async fn ensure_index_streaming(app: &App, alignment_id: i64, evt_tx: &Sender Event::IndexReady { built }, Err(e) => settle_alignment_command(app, alignment_id, Event::Error(e.to_string())).await, @@ -2458,10 +2532,11 @@ async fn ensure_indexes_for_subject_streaming( } } -/// Run a realignment, emitting `RealignProgress` as each stage begins and `RealignDone` at the end. +/// Run a realignment. It emits a `RealignProgress` as each stage begins, and `RealignDone` at the +/// end. /// -/// The reference has to be resolved before the job starts — realigning to a build whose FASTA is -/// not cached would otherwise stall silently at the index stage while gigabytes download. +/// The reference must resolve before the job starts. A realignment to a build whose FASTA is not in +/// the cache would otherwise stop at the index stage, with no message, while gigabytes download. async fn run_realign_streaming( app: &App, alignment_id: i64, @@ -2470,11 +2545,11 @@ async fn run_realign_streaming( evt_tx: &Sender, wake: Arc, ) { - // Whose job this is, resolved once up front rather than per event. + // Whose job this is. The code resolves it one time at the start, and not on each event. let biosample_guid = app.subject_of_alignment(alignment_id).await.ok().flatten(); - // Resolve (downloading if needed) the reference we are mapping to, streaming its progress the - // same way every other reference-dependent command does. + // Resolve the reference we map to, with a download when that is necessary. Stream its progress + // the same way as every other command that needs a reference. ensure_references_streaming(app, std::slice::from_ref(&target_build), evt_tx, &*wake).await; let reference = match app.cached_reference_path(&target_build) { @@ -2511,11 +2586,11 @@ async fn run_realign_streaming( target_reference: reference, preset: None, scratch_root: None, - // Safe to opt in here because the scratch path is derived from this source alignment and - // this target build, so anything found in it belongs to the job about to run. Intermediates - // only survive at all when a previous attempt was killed outright — the machine went down, - // the session was torn down, the process was force-quit — and in that case a user who - // presses Realign again means "carry on", not "spend four hours re-deriving the same file". + // It is safe to opt in here, because the scratch path comes from this source alignment and + // this target build. So anything in it belongs to the job about to run. An intermediate + // survives at all only when something killed an earlier try outright. The machine went + // down, the session went down, or somebody force-quit the process. In that case a user who + // presses Realign again means "carry on", and not "spend four hours on the same file". resume: true, }; @@ -2525,8 +2600,8 @@ async fn run_realign_streaming( biosample_guid, new_alignment_id: Some(outcome.alignment.id), cancelled: false, - // A resumed job skips the stages that count these, so a figure may be genuinely - // unknown; saying so beats printing a zero the user would read as a result. + // A job that resumes steps over the stages that count these, so a figure can be truly + // unknown. To say so is better than a zero the user would read as a result. summary: { let count = |n: Option| { n.map(|n| n.to_string()) @@ -2556,9 +2631,10 @@ async fn run_realign_streaming( wake(); } -/// Run the full per-alignment analysis pipeline, emitting `AnalysisProgress` before each step -/// and forwarding each step's own result event (so the detail tabs fill in live). `cancel` is -/// checked between steps. Per-step errors are forwarded but do not abort the pipeline (best-effort). +/// Run the full analysis pipeline of one alignment. It emits an `AnalysisProgress` before each +/// step, and forwards the result event of each step, so that the detail tabs fill in live. It checks +/// `cancel` between steps. It also forwards the error of a step, and that error does not stop the +/// pipeline, which is best-effort. async fn run_full_analysis_streaming( app: &App, alignment_id: i64, @@ -2567,22 +2643,25 @@ async fn run_full_analysis_streaming( evt_tx: &Sender, wake: Arc, ) { - // Which steps run — and which are skipped because a trusted external caller already placed this - // alignment, or because it has no chrM reads — is decided by `App::plan_full_analysis`, the one - // definition shared with the CLI. This fn only turns those steps into progress + result events. - // Planned twice: the mitochondrial decision is a guess until step 1 has produced coverage. - // `include_sv = false`: SV is experimental and costs hours per whole-genome sample, so it is - // never folded into a Full Analysis. The Sources tab's "Call SV" button runs it on request. + // `App::plan_full_analysis` decides which steps run, and which ones go. A step goes when a + // trusted external caller already placed this alignment, or when it has no chrM reads. That + // plan is the one definition, and the CLI shares it. This function only turns those steps into + // progress and result events. + // + // It plans twice, because the decision on the mitochondrion is a guess until step 1 gives + // coverage. `include_sv = false`, because SV is experimental and costs hours for one + // whole-genome sample, so no Full Analysis ever takes it in. The "Call SV" button on the + // Sources tab runs it on request. let mut steps = app .plan_full_analysis(alignment_id, include_ancestry, false, None) .await .unwrap_or_else(|_| vec![AnalysisStep::QualityMetrics]); let mut total = steps.len(); - // Step 1: unified quality metrics — coverage + callable, read-level QC, and sex inference in - // ONE pass over the alignment (was three separate steps reading the file 2–3×). The slow - // whole-genome read; stream per-contig sub-progress so the bar advances chromosome by - // chromosome instead of sitting at 0% for minutes. + // Step 1: unified quality metrics. It does coverage and callable, read-level QC, and sex + // inference in ONE pass over the alignment. Three separate steps used to read the file 2 to 3 + // times. This is the slow whole-genome read, so stream the sub-progress of each contig. The bar + // then advances chromosome by chromosome, and does not stay at 0% for minutes. if !cancel.is_cancelled() { let _ = evt_tx.send(Event::AnalysisProgress { step: 1, @@ -2592,10 +2671,11 @@ async fn run_full_analysis_streaming( fraction: 0.0, }); wake(); - // Reuse cached sub-results instead of re-scanning the whole genome (minutes) — only when - // all three are present, since they are persisted together by the unified walker. The - // coverage must also be at the right scope (a stale whole-genome result for a targeted-Y - // test reads as a miss) so it is recomputed restricted to the target contigs. + // Reuse the cached sub-results, instead of a second scan of the whole genome, which costs + // minutes. Do it only when all three are there, because the unified walker persists them + // together. The coverage must also cover the right scope: a stale whole-genome result for a + // targeted-Y test reads as a miss. So the code computes it again, over the target contigs + // only. let cached = match ( app.cached_coverage_for_analysis(alignment_id).await, app.cached_read_metrics(alignment_id).await, @@ -2607,8 +2687,8 @@ async fn run_full_analysis_streaming( let outcome = match cached { Some(triple) => Ok(triple), None => { - // The parallel walker invokes progress from worker threads, so the callback must - // be Fn + Sync; the event Sender is !Sync, so guard it with a Mutex. + // The parallel walker calls progress from worker threads, so the callback must 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( @@ -2632,21 +2712,25 @@ async fn run_full_analysis_streaming( .map(|r| (r.coverage, r.read_metrics, r.sex)) } }; - // Emit the same per-result events the old separate steps did, so the UI updates identically. + // Emit the same result events as the old separate steps, so that the UI updates the same + // way. match outcome { Ok((cov, rm, sex)) => { - // Re-plan from the just-computed coverage: a Big Y with zero chrM reads now - // correctly drops the chrM de-novo + mt-placement steps (the pre-flight plan above - // ran before this coverage existed). Adjusts the remaining-step total. + // Plan again from the coverage that just ran. A Big Y with zero chrM reads then + // correctly drops the chrM de-novo step and the mt-placement step. The pre-flight + // plan above ran before this coverage existed. This also adjusts the total of the + // steps that remain. steps = app .plan_full_analysis(alignment_id, include_ancestry, false, Some(&cov)) .await .unwrap_or_else(|_| std::mem::take(&mut steps)); total = steps.len(); - // Pin a generic FTDNA Targeted-Y to Big Y-500 vs -700 from its callable-chrY - // footprint. Done here (not only inside the metrics walker) so it also fires on the - // cached fast-path above, where the walker — and its in-method refine — is skipped. - // When the generation changed, reload the run card so it shows the new label. + // Pin a generic FTDNA Targeted-Y to Big Y-500 or Big Y-700, from its callable-chrY + // footprint. It happens here, and not inside the metrics walker alone. It then + // also fires on the cached fast path above, which steps over the walker and its + // internal refine. When the generation changed, load the run card again, so that + // it + // shows the new label. if let Ok(Some(_)) = app.refine_big_y_generation_for_alignment(alignment_id, &cov).await { if let Ok(guid) = app.biosample_of_alignment(alignment_id).await { let _ = evt_tx.send(Event::RunsChanged(guid)); @@ -2666,8 +2750,9 @@ async fn run_full_analysis_streaming( }); } Err(e) => { - // Persist the failure (corrupt/undecodable file) so the project report shows - // "Failed" rather than a silent blank, matching the CLI and batch paths. + // Persist the failure, from a file that is corrupt or that the decoder can not + // read. The project report then shows "Failed", and not a blank with no message. + // This matches the CLI path and the batch path. app.record_analysis_error(alignment_id, "metrics", &e.to_string()).await; let _ = evt_tx.send(Event::Error(e.to_string())); } @@ -2675,9 +2760,10 @@ async fn run_full_analysis_streaming( wake(); } - // The remaining steps run via `handle`, which forwards each one's own result events. Y variant - // discovery is the callable-masked "private Y" pass, NOT a raw whole-chrY de-novo (which is - // enormous + mostly artifacts); chrM de-novo is fine (small, fully callable). + // The steps that remain run through `handle`, which forwards the result events of each one. Y + // variant discovery is the "private Y" pass behind the callable mask. It is NOT a raw + // whole-chrY de-novo, which is enormous and mostly artifacts. chrM de-novo is acceptable, + // because it is small and fully callable. let command_for = |step: &AnalysisStep| match step { // Step 1 ran above, with sub-progress; it is never dispatched as a command. AnalysisStep::QualityMetrics => None, @@ -2698,8 +2784,9 @@ async fn run_full_analysis_streaming( biosample_guid: *biosample_guid, }), }; - // Carry each step's 1-based position in the plan, so the progress numbering stays right no - // matter which steps the plan included or which are dispatched here rather than above. + // Carry the position of each step in the plan, counted from 1. The progress numbers then stay + // right, whatever steps the plan took in, and whichever ones this place dispatches instead of + // the code above. let steps: Vec<(usize, String, String, Command)> = steps .iter() .enumerate() @@ -2717,9 +2804,9 @@ async fn run_full_analysis_streaming( fraction: (step as f32 - 1.0) / total as f32, }); wake(); - // Runs to completion; we may cancel before the next step. Steps here bypass the outer - // match's per-command pre-flight, so this is also the only place their failures can pick up - // a file-level diagnosis. + // This runs to the end, and we can cancel before the next step. A step here goes around the + // pre-flight that the outer match does for each command. So this is also the only place + // where its failure can take up a diagnosis at file level. let ev = settle_alignment_command(app, alignment_id, handle(app, cmd, &cancel).await).await; let _ = evt_tx.send(ev); wake(); @@ -2731,16 +2818,11 @@ async fn run_full_analysis_streaming( wake(); } -/// Deep-analyze every sample in a project one at a time, emitting `DeepAnalyzeProgress` before -/// each sample (so the bar advances sample by sample) and a final `ProjectAnalyzed`. `cancel` is -/// checked before each sample — a stop leaves the already-computed artifacts in place (the pass is -/// additive and idempotent). Each `analyze_biosample` awaits internally, so the worker runtime -/// stays free for quick UI queries between samples. -/// Run one workspace chore, emitting progress per item. +/// Run one workspace chore, and emit progress for each item. /// -/// The loop lives here rather than in `navigator-app` for the same reason `deep_analyze_project`'s -/// does: the worker owns the event channel and the cancel token, and `App` stays free of UI -/// plumbing. What each item *does* is an `App` method, shared with the CLI. +/// The loop lives here, and not in `navigator-app`, for the same reason as the loop of +/// `deep_analyze_project`. The worker owns the event channel and the cancel token, and `App` stays +/// free of UI glue. What each item *does* is an `App` method, and the CLI shares it. async fn run_chore_streaming( app: &App, chore: navigator_app::Chore, @@ -2789,8 +2871,8 @@ async fn run_chore_streaming( }; let total = targets.len(); let (mut calls_replaced, mut calls_failed, mut calls_skipped) = (0usize, 0usize, 0usize); - // One lookup for the whole batch rather than a query per subject just to label a - // progress line. + // One lookup for the whole batch. It does not run a query for each subject only to + // label a progress line. let names: std::collections::HashMap<_, _> = app .list_all_biosamples() .await @@ -2804,11 +2886,11 @@ async fn run_chore_streaming( } let label = names.get(guid).cloned().unwrap_or_else(|| guid.0.to_string()); progress(chore, i, total, &label, evt_tx, &wake); - // Re-place the per-alignment calls *and* rebuild the pooled profiles — see - // `App::replace_against_current_tree`. Rebuilding only the profiles (what this used - // to do) left every `haplogroup_call` row on its old tree, which is both the - // "sources diverge" conflicts on the Y card and the reason a swept subject stayed - // due forever. + // Place the calls of each alignment again, *and* build the pooled profiles again. + // See `App::replace_against_current_tree`. A build of the profiles alone, which is + // what this used to do, left every `haplogroup_call` row on its old tree. That is + // both the "sources diverge" conflicts on the Y card, and the reason a subject the + // sweep touched stayed due for ever. match app.replace_against_current_tree(*guid).await { Ok(r) => { outcome.done += 1; @@ -2823,9 +2905,9 @@ async fn run_chore_streaming( } } } - // Skips are reported separately from failures: "file gone" is expected in a workspace - // whose vendor downloads have been cleaned out, and folding it into the error count - // makes a healthy run look broken. + // A skip is separate from a failure in the report. "file gone" is normal in a + // workspace where somebody cleaned out the vendor downloads. To put it into the error + // count makes a healthy run look broken. outcome.summary = format!( "{} subject(s) re-placed against the current tree · {calls_replaced} call(s) re-placed{}{}", outcome.done, @@ -2877,8 +2959,8 @@ fn progress( wake(); } -/// A chore that could not start at all — surface the reason and close it out, so the UI never -/// leaves a spinner running on a job that never began. +/// A chore that could not start at all. Show the reason, and close it out, so that the UI never +/// leaves a spinner on a job that never began. fn fail_chore(chore: navigator_app::Chore, err: String, evt_tx: &Sender, wake: &Arc) { let _ = evt_tx.send(Event::Error(err.clone())); let _ = evt_tx.send(Event::ChoreDone { @@ -2891,6 +2973,11 @@ fn fail_chore(chore: navigator_app::Chore, err: String, evt_tx: &Sender, wake(); } +/// Deep-analyze every sample in a project, one at a time. It emits a `DeepAnalyzeProgress` before +/// each sample, so that the bar advances sample by sample, then a final `ProjectAnalyzed`. It checks +/// `cancel` before each sample, and a stop leaves the artifacts that already exist in place, because +/// the pass is additive and idempotent. Each `analyze_biosample` awaits inside, so the worker +/// runtime stays free for a quick UI query between samples. async fn deep_analyze_project_streaming( app: &App, project_id: i64, @@ -2954,9 +3041,10 @@ async fn deep_analyze_project_streaming( wake(); } -/// Import a NAS project directory, emitting `ImportProgress` per sample (so a 1000-sample import -/// shows a live status instead of appearing frozen), then a final `ProjectImported`. A missing -/// reference build surfaces as `ReferenceNeeded` (download prompt); other failures as `Error`. +/// Import a NAS project directory. It emits an `ImportProgress` for each sample, so that an import +/// of 1000 samples shows a live status, and does not look frozen. It ends with a final +/// `ProjectImported`. A reference build that is missing comes back as `ReferenceNeeded`, which +/// prompts a download, and any other failure comes back as `Error`. async fn import_project_dir_streaming( app: &App, dir: PathBuf, @@ -3077,8 +3165,8 @@ async fn narrate_signal_streaming( wake(); } -/// Drain the outbox once and emit the outcome: a `Published` per sent row, the online flag, and the -/// remaining pending count. +/// Drain the outbox one time, and emit the outcome: one `Published` for each row it sent, the online +/// flag, and how many rows still wait. async fn emit_drain(app: &App, evt_tx: &Sender, wake: &(dyn Fn() + Send + Sync)) { match app.drain_outbox().await { Ok(outcome) => { @@ -3095,10 +3183,10 @@ async fn emit_drain(app: &App, evt_tx: &Sender, wake: &(dyn Fn() + Send + wake(); } -/// Spawn the worker thread: open the workspace at `db_path` inside the worker's runtime -/// (so the connection pool lives there), then serve commands. `wake` is called after -/// each event so the UI can `request_repaint`. Returns the command sender and event -/// receiver the UI holds. +/// Spawn the worker thread. It opens the workspace at `db_path` inside the runtime of the worker, +/// so that the connection pool lives there, then it serves commands. It calls `wake` after each +/// event, so that the UI can `request_repaint`. It returns the command sender, and the event +/// receiver, that the UI holds. pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (UnboundedSender, Receiver) { let (cmd_tx, mut cmd_rx) = unbounded_channel::(); let (evt_tx, evt_rx) = std::sync::mpsc::channel::(); @@ -3107,12 +3195,16 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo std::thread::Builder::new() .name("navigator-worker".into()) .spawn(move || { - // 64 MiB stacks. Two independent deep-recursion sources overflow tokio's default 2 MiB - // worker/blocking stack and abort the whole app mid-batch: (1) the Y/mt tree parse + - // placement recurse to the haplotree's depth (`flatten_du_node`, descent traversal) on - // deep lineages / the large FTDNA tree; (2) noodles' CRAM decoder recurses on - // `spawn_blocking` decode paths, deepest on CRAM 3.1 files (new range/fqzcomp/tokenizer - // codecs). Whole-genome record decode runs on `reader::decode_pool` instead; this covers + // 64 MiB stacks. Two independent sources of deep recursion overflow the default 2 MiB + // stack of a tokio thread. They abort the whole app in mid-batch. + // + // The first is the Y and mt tree parse, and the placement. Those recurse to the depth of + // the haplotree (`flatten_du_node`, and the descent traversal), on a deep lineage, or on + // the large FTDNA tree. The second is the CRAM decoder of noodles. That recurses on the + // `spawn_blocking` decode paths, and goes deepest on a CRAM 3.1 file, with its new + // range, fqzcomp and tokenizer codecs. + // + // A whole-genome record decode runs on `reader::decode_pool` instead, and this covers // the targeted decodes. See `NAVIGATOR_DECODE_STACK_MB`. let rt = match tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -3167,9 +3259,10 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo Command::ResolveReference { build } => { resolve_reference_streaming(&app, build, &evt_tx, &*wake).await; } - // Import, then eagerly resolve the imported alignments' reference(s) with a - // visible progress bar — a first CRAM/BAM that needs a multi-GB reference - // download otherwise looks like it did not register (§ ensure_references_streaming). + // Import, then resolve the references of the imported alignments at + // once, with a visible progress bar. A first CRAM or BAM that needs a + // multi-GB reference download otherwise looks as though it did not + // register. See `ensure_references_streaming`. Command::AddDataBatch { biosample_guid, paths } => { let event = handle( &app, @@ -3215,9 +3308,10 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo ensure_indexes_for_subject_streaming(&app, guid, &evt_tx, &*wake).await; } } - // Pre-resolve the subject's / alignment's reference and coordinate index (with a - // progress bar) so a query-driven analysis does not trigger a silent download or - // index build partway through. + // Resolve the reference and the coordinate index of the subject, or of + // the alignment, first, with a progress bar. An analysis that runs + // queries then does not start a download, or an index build, with no + // message, part of the way through. Command::BuildAutosomalProfile { biosample_guid } => { if let Ok(builds) = app.reference_builds_for_subject(biosample_guid).await { ensure_references_streaming(&app, &builds, &evt_tx, &*wake).await; @@ -3401,8 +3495,9 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo let _ = evt_tx.send(event); wake(); } - // Streams AnalysisProgress per step (+ each step's result), then AnalysisDone. - // Ensure the coordinate index first (step 1's per-contig walker seeks by region). + // This streams an AnalysisProgress for each step, plus the result of + // each step, then AnalysisDone. Make sure the coordinate index exists + // first, because the walker of step 1 seeks by region on each contig. Command::RunFullAnalysis { alignment_id } => { ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; // Advanced: haplogroups/coverage only; ancestry is a separate action. @@ -3445,23 +3540,25 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo } } } - // Streams DeepAnalyzeProgress per sample, then a final ProjectAnalyzed. + // This streams a DeepAnalyzeProgress for each sample, then a final + // ProjectAnalyzed. Command::DeepAnalyzeProject(project_id) => { let (gen, cancel) = cancels.begin(); deep_analyze_project_streaming(&app, project_id, cancel, &evt_tx, wake.clone()).await; cancels.end(gen); } - // Workspace chores stream ChoreProgress per item, then ChoreDone. They - // walk the whole workspace, so running them inline would freeze the UI - // for minutes with no sign of life. + // A workspace chore streams a ChoreProgress for each item, then + // ChoreDone. It walks the whole workspace, so a run inline would freeze + // the UI for minutes, with no sign of life. Command::RunChore { chore, force } => { let (gen, cancel) = cancels.begin(); run_chore_streaming(&app, chore, force, cancel, &evt_tx, wake.clone()).await; cancels.end(gen); } - // Streams ImportProgress per sample, then a final ProjectImported (or - // ReferenceNeeded / Error). Large NAS imports (1000s of samples) otherwise - // appear frozen until the whole batch completes. + // This streams an ImportProgress for each sample, then a final + // ProjectImported, or ReferenceNeeded, or Error. A large NAS import, of + // thousands of samples, otherwise looks frozen until the whole batch + // ends. Command::ImportProjectDir { dir, reference } => { import_project_dir_streaming(&app, dir, reference, &evt_tx, wake.clone()).await; } @@ -3499,8 +3596,9 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo .await; let was_cancelled = cancel.is_cancelled(); cancels.end(gen); - // Cancel abandons the queue, not just the current sample — - // someone stopping a multi-day batch means all of it. + // A cancel abandons the queue, and not only the current + // sample. Somebody who stops a multi-day batch means all of + // it. if was_cancelled { abandoned = true; break; @@ -3521,8 +3619,9 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo Command::CancelAnalysis => { cancels.cancel_current(); } - // Publishes enqueue durably, then drain (send-now-if-online). The drain - // emits Published per row + SyncPending; we emit Queued for instant feedback. + // A publish goes into a durable queue, then drains, and it sends now + // if the app is online. The drain emits one Published for each row, and + // a pending count. We emit Queued for immediate feedback. Command::PublishCoverage(id) => { publish_then_drain( &app, @@ -3567,11 +3666,13 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo Command::DrainOutbox => { emit_drain(&app, &evt_tx, &*wake).await; } - // Streams narration text as it is generated, then a final BriefNarration. + // This streams the narration text while the model writes it, then a + // final BriefNarration. Command::NarrateBrief(guid) => { narrate_brief_streaming(&app, guid, &evt_tx, &*wake).await; } - // Streams a chat answer as it is generated, then a final ChatAnswer. + // This streams a chat answer while the model writes it, then a final + // ChatAnswer. Command::AskQuestion { guid, history, @@ -3579,7 +3680,8 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo } => { ask_question_streaming(&app, guid, history, question, &evt_tx, &*wake).await; } - // Streams a per-signal explanation as it is generated, then a final SignalNarration. + // This streams the explanation of one signal while the model writes + // it, then a final SignalNarration. Command::NarrateSignal { guid, kind } => { narrate_signal_streaming(&app, guid, kind, &evt_tx, &*wake).await; } @@ -3676,7 +3778,7 @@ mod tests { other => panic!("expected Genealogy, got {other:?}"), } - // Binding a kit already owned by another subject is reported as an error. + // A bind of a kit that another subject already owns comes back as an error. let c = app.add_biosample(None, "OTHER", None, None).await.unwrap(); app.add_external_id(c.guid, "FTDNA", "B9999").await.unwrap(); let ev = handle( @@ -3764,7 +3866,7 @@ mod tests { other => panic!("expected Coverage(None), got {other:?}"), } - // run + persist (uses the alignment's stored paths, via spawn_blocking) + // run and persist (it uses the stored paths of the alignment, through `spawn_blocking`) match handle(&app, Command::RunCoverage(aln.id), &CancelToken::none()).await { Event::Coverage { alignment_id, result } => { assert_eq!(alignment_id, aln.id); @@ -3779,7 +3881,7 @@ mod tests { other => panic!("expected cached Coverage, got {other:?}"), } - // de-novo on the fixture contig (cold -> run -> cached), per-contig keyed + // de-novo on the fixture contig (cold -> run -> cached), keyed on the contig match handle( &app, Command::LoadDenovo { @@ -3981,7 +4083,7 @@ mod tests { other => panic!("got {other:?}"), } - // adding dependent data makes delete refuse with a conflict + // new dependent data makes the delete refuse, with a conflict match handle( &app, Command::AddRun(NewSequenceRun::new(guid, "ILLUMINA", "WGS")), @@ -4002,7 +4104,7 @@ mod tests { other => panic!("got {other:?}"), } - // removing the run clears the conflict, so the subject can then be deleted (the + // a remove of the run clears the conflict, so the delete of the subject then works (the // end-to-end 'remove data first' path) let run_id = match handle(&app, Command::LoadRuns(guid), &CancelToken::none()).await { Event::Runs { runs, .. } => runs[0].id, @@ -4116,7 +4218,7 @@ mod tests { other => panic!("got {other:?}"), } - // assigning to a non-existent project is refused + // an assign to a project that does not exist gets a refusal match handle( &app, Command::AssignBiosampleProject { @@ -4131,7 +4233,7 @@ mod tests { other => panic!("expected Error, got {other:?}"), } - // clearing the project (None) removes it from the project list + // a clear of the project (None) removes it from the project list match handle( &app, Command::AssignBiosampleProject { guid, project_id: None }, @@ -4192,8 +4294,8 @@ mod tests { other => panic!("got {other:?}"), } - // A project with members can now be deleted — its members are detached (the subjects - // survive), rather than the delete being refused. + // A delete of a project with members now works. Its members detach, and the subjects stay. + // The delete no longer gets a refusal. match handle( &app, Command::AddBiosample(NewBiosample { @@ -4271,7 +4373,7 @@ mod tests { other => panic!("got {other:?}"), }; - // edit the run's descriptive fields; the read metric is preserved + // edit the descriptive fields of the run, and the read metric stays match handle( &app, Command::UpdateSequenceRun { @@ -4343,9 +4445,10 @@ mod tests { } } - /// The streaming deep-analyze emits one progress event per sample and a final `ProjectAnalyzed`. - /// Samples without a BAM-bearing alignment are walked (so the bar advances) but not counted — - /// keeping the test free of any reference/network/tree dependency. + /// The streaming deep-analyze emits one progress event for each sample, then a final + /// `ProjectAnalyzed`. A sample with no alignment that carries a BAM still goes through the walk, + /// so that the bar advances, and the count leaves it out. That keeps the test free of any + /// dependency on a reference, a network, or a tree. #[tokio::test] async fn deep_analyze_streams_progress_then_a_final_summary() { let app = app().await; @@ -4402,10 +4505,10 @@ mod tests { app.add_biosample(Some(p.id), "S1", None, None).await.unwrap(); app.add_biosample(Some(p.id), "S2", None, None).await.unwrap(); - // Cancel is raised from a wake hook fired on the first progress emission, simulating the - // user clicking Cancel once the first sample is under way. Note there is no re-arming here: - // the run no longer resets its own token at entry, which is precisely the race that used to - // swallow a cancel arriving between the spawn and the reset. + // A wake hook raises Cancel on the first progress event. That is what a user does when + // they click Cancel after the first sample starts. Nothing arms the token again here. The + // run no longer resets its own token at its start. That reset is exactly the race that used + // to swallow a cancel between the spawn and the reset. let (tx, rx) = std::sync::mpsc::channel::(); let cancel = CancelToken::new(); let armed = cancel.clone(); diff --git a/documents/STE-dictionary.md b/documents/STE-dictionary.md index 1b510b0b..7bb81ba3 100644 --- a/documents/STE-dictionary.md +++ b/documents/STE-dictionary.md @@ -47,11 +47,25 @@ adverb that means *earlier*. `prior to` stays forbidden, and the checker still r *version drift* and *drift out of step*. A Technical Name wins over the idiom list, so write those two meanings a different way. -dispersion · drift · emission · likelihood · loading · posterior · prior · state +clustering · dispersion · drift · emission · inbreeding · likelihood · loading · posterior · +prior · state ### File and data formats -BAM · BED · CRAM · FASTA · gVCF · index · JSON · masterVar · sidecar · TSV · VCF +BAM · BED · CRAM · FASTA · gVCF · index · JSON · masterVar · SAM · sidecar · TSV · VCF + +### Read mapping + +`navigator-align` maps reads with a pure-Rust minimap2. These are the nouns of that method and of +the SAM record it writes. + +CIGAR · mapper · mate · minimizer · part · preset · template + +### Desktop UI + +`navigator-ui` is an egui shell. These are the nouns of that shell. + +chip · frame · modal · scroll area · tab · tooltip · viewport · widget ### Application concepts @@ -60,7 +74,7 @@ query · realignment · record · row · schema · store · table · workspace ### Local LLM -chat completion · context · fact sheet · model · model server · narration · prompt · +chat completion · context · fact sheet · grounding · model · model server · narration · prompt · reasoning model · token ### Federation @@ -77,6 +91,15 @@ tracking · caching · processing · operating system · pacing · sampling · s spilling · phasing · binning · masking · trimming · clipping · calling · sorting · merging · reasoning model · streaming · copying · loading +Read mapping adds two more. `chaining` is the middle step of minimap2's published seed-chain-align +method, and `pairing` is the step that makes two mapped ends into one template: chaining · pairing + +Four more come from the sections above. `clustering` is the statistical method, `inbreeding` is +the population-genetics term that names the F coefficient, `coding` is the genetics term for a +region that codes for protein (never *colour coding*, which is a colour code), and `grounding` is +the LLM term for the facts that hold a model to the data: +clustering · coding · grounding · inbreeding + GangSTR names its read classes `enclosing`, `spanning` and `flanking`. Those are the published names of the method, so they are Technical Names here: enclosing · spanning · flanking @@ -141,8 +164,8 @@ Do not use a contraction. Write `do not`, not `don't`. ## How to convert a file -This is the method that converted `navigator-resource` and 31 of the 33 files of `navigator-app`. -Follow it, and a file needs about three passes. +This is the method that converted every crate in the workspace. Follow it, and a file needs about +three passes. ### The two tools @@ -262,12 +285,10 @@ and deletion counts are equal, and every other difference must have a reason you ### Where the work stands -Converted to zero: `navigator-resource`, `navigator-store/src/sig_cache.rs`, and 31 of the 33 files -of `navigator-app`. - -Remaining in `navigator-app`: `src/lib.rs` (the type documentation at the top is converted, the -`impl App` body is not) and `src/haplogroup.rs`. +**The whole workspace is at zero.** Every crate under `crates/` has been converted: +`navigator-align`, `navigator-analysis`, `navigator-app`, `navigator-domain`, +`navigator-panelbuild`, `navigator-refgenome`, `navigator-resource`, `navigator-store`, +`navigator-sync` and `navigator-ui`. -Not started: `navigator-analysis`, `navigator-ui`, `navigator-domain`, `navigator-align`, -`navigator-panelbuild`, `navigator-store` beyond `sig_cache`, `navigator-refgenome`, -`navigator-sync`. Run `python3 scripts/ste-check.py` for the current count. +Run `python3 scripts/ste-check.py` after any change. The check is advisory, so it will not stop a +commit that reintroduces a violation; run it before you push. diff --git a/scripts/ste-check.py b/scripts/ste-check.py index c950b3f5..ef0e3a89 100755 --- a/scripts/ste-check.py +++ b/scripts/ste-check.py @@ -131,6 +131,12 @@ def _technical_names(): "operating", "genotyping", "reasoning", "pacing", "sampling", "scaling", "streaming", "spilling", "phasing", "binning", "masking", "trimming", "clipping", "calling", "sorting", "merging", "reading", "writing", "counting", "timing", "build", "backing", "copying", "loading", + # Read-mapping Technical Names from documents/STE-dictionary.md. + "chaining", "pairing", + # Statistics and Local LLM Technical Names from the same file. + "clustering", "coding", "grounding", "inbreeding", + # Nouns that only end in the three letters of the rule. "sibling" is not a form of "to sibl". + "sibling", "siblings", "substring", "substrings", # Surnames in a citation. "Busing et al. 1999" is the jackknife paper, not a verb. "busing", "balding", # GangSTR's published read-class names.