From 7d845dbc957a3fe05e766ea2cc5afd2879916c73 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 11:01:58 +0300 Subject: [PATCH 01/59] feat(lineage): tree data structures (LineageNode, LineageTree) --- engine_rs/src/lib.rs | 1 + engine_rs/src/lineage/mod.rs | 6 ++ engine_rs/src/lineage/tree.rs | 108 ++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 engine_rs/src/lineage/mod.rs create mode 100644 engine_rs/src/lineage/tree.rs diff --git a/engine_rs/src/lib.rs b/engine_rs/src/lib.rs index 3a6de57..2c385ed 100644 --- a/engine_rs/src/lib.rs +++ b/engine_rs/src/lib.rs @@ -46,6 +46,7 @@ pub mod event; pub mod feasibility; pub mod ir; pub mod junction; +pub mod lineage; pub mod live_call; pub mod pass; pub mod passes; diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs new file mode 100644 index 0000000..de29074 --- /dev/null +++ b/engine_rs/src/lineage/mod.rs @@ -0,0 +1,6 @@ +//! Clonal lineage simulation: grow a real mutation tree from one founder +//! `Simulation` via a generation-synchronous birth–death process. + +pub mod tree; + +pub use tree::{LineageNode, LineageTree}; diff --git a/engine_rs/src/lineage/tree.rs b/engine_rs/src/lineage/tree.rs new file mode 100644 index 0000000..a852a54 --- /dev/null +++ b/engine_rs/src/lineage/tree.rs @@ -0,0 +1,108 @@ +//! Clonal lineage tree data structures. +//! +//! A `LineageTree` is a flat arena of `LineageNode`s addressed by `id`. The +//! root is the founder (naive rearrangement); every other node is a somatic +//! descendant produced by one cell division. `genotype` is the node's +//! nucleotide sequence (pool bases) captured at creation, used for +//! genotype-collapse and downstream export. + +/// One cell in a clonal lineage. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineageNode { + /// Stable index of this node within `LineageTree::nodes` (also its arena position). + pub id: u32, + /// Parent node id; `None` only for the root founder. + pub parent_id: Option, + /// Generation depth from the root (root == 0). + pub generation: u32, + /// Nucleotide bases of this cell's `Simulation` pool at creation time. + pub genotype: Vec, + /// Mutations introduced on the edge from the parent to this node + /// (`child.mutation_count - parent.mutation_count`). 0 for the root. + pub mutations_from_parent: u32, + /// Observation count after sampling + genotype-collapse. 0 until sampled. + pub abundance: u32, + /// Whether this node was observed (sampled, or a collapsed-into ancestor). + pub observed: bool, +} + +/// A clonal lineage as a flat arena of nodes (node `id` == index into `nodes`). +#[derive(Clone, Debug, Default)] +pub struct LineageTree { + pub nodes: Vec, +} + +impl LineageTree { + /// Total node count (founder + all descendants). + pub fn len(&self) -> usize { + self.nodes.len() + } + + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + /// The founder node (the unique node with no parent). + /// + /// Panics if the tree has no root — call only on a grown tree. + pub fn root(&self) -> &LineageNode { + self.nodes + .iter() + .find(|n| n.parent_id.is_none()) + .expect("LineageTree has no root node") + } + + /// Node by id, or `None` if out of range. + pub fn get(&self, id: u32) -> Option<&LineageNode> { + self.nodes.get(id as usize) + } + + /// Direct children of `id`, in ascending id order. + pub fn children_of(&self, id: u32) -> Vec<&LineageNode> { + self.nodes + .iter() + .filter(|n| n.parent_id == Some(id)) + .collect() + } + + /// Nodes with no children (tips), in ascending id order. + pub fn leaves(&self) -> Vec<&LineageNode> { + self.nodes + .iter() + .filter(|n| self.children_of(n.id).is_empty()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Build a tiny tree by hand: root(0) -> {1, 2}; 1 -> {3} + fn hand_tree() -> LineageTree { + LineageTree { + nodes: vec![ + LineageNode { id: 0, parent_id: None, generation: 0, genotype: b"AAAA".to_vec(), mutations_from_parent: 0, abundance: 0, observed: false }, + LineageNode { id: 1, parent_id: Some(0), generation: 1, genotype: b"AAAC".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, + LineageNode { id: 2, parent_id: Some(0), generation: 1, genotype: b"AAAG".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, + LineageNode { id: 3, parent_id: Some(1), generation: 2, genotype: b"AATC".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, + ], + } + } + + #[test] + fn tree_accessors_report_structure() { + let t = hand_tree(); + assert_eq!(t.root().id, 0); + assert_eq!(t.len(), 4); + + let root_children: Vec = t.children_of(0).iter().map(|n| n.id).collect(); + assert_eq!(root_children, vec![1, 2]); + + let leaves: Vec = t.leaves().iter().map(|n| n.id).collect(); + assert_eq!(leaves, vec![2, 3]); // nodes with no children + + assert_eq!(t.get(3).unwrap().parent_id, Some(1)); + assert!(t.get(99).is_none()); + } +} From 4a3610d5034272a8edfa3db6fd10a028dafe9b5a Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 11:05:28 +0300 Subject: [PATCH 02/59] refactor(lineage): enforce child/leaf id ordering, fix doc accuracy --- engine_rs/src/lineage/tree.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/engine_rs/src/lineage/tree.rs b/engine_rs/src/lineage/tree.rs index a852a54..0588ee8 100644 --- a/engine_rs/src/lineage/tree.rs +++ b/engine_rs/src/lineage/tree.rs @@ -18,7 +18,7 @@ pub struct LineageNode { /// Nucleotide bases of this cell's `Simulation` pool at creation time. pub genotype: Vec, /// Mutations introduced on the edge from the parent to this node - /// (`child.mutation_count - parent.mutation_count`). 0 for the root. + /// (the number of substitutions on the parent→child edge; 0 for the root). pub mutations_from_parent: u32, /// Observation count after sampling + genotype-collapse. 0 until sampled. pub abundance: u32, @@ -38,6 +38,7 @@ impl LineageTree { self.nodes.len() } + /// True when the tree has no nodes. pub fn is_empty(&self) -> bool { self.nodes.is_empty() } @@ -59,18 +60,22 @@ impl LineageTree { /// Direct children of `id`, in ascending id order. pub fn children_of(&self, id: u32) -> Vec<&LineageNode> { - self.nodes + let mut v: Vec<&LineageNode> = self.nodes .iter() .filter(|n| n.parent_id == Some(id)) - .collect() + .collect(); + v.sort_unstable_by_key(|n| n.id); + v } /// Nodes with no children (tips), in ascending id order. pub fn leaves(&self) -> Vec<&LineageNode> { - self.nodes + let mut v: Vec<&LineageNode> = self.nodes .iter() .filter(|n| self.children_of(n.id).is_empty()) - .collect() + .collect(); + v.sort_unstable_by_key(|n| n.id); + v } } From 64965e8da46d9d3dd0421312f764c5d0f76505ab Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 11:06:59 +0300 Subject: [PATCH 03/59] feat(lineage): deterministic Poisson sampler --- engine_rs/src/lineage/mod.rs | 1 + engine_rs/src/lineage/poisson.rs | 57 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 engine_rs/src/lineage/poisson.rs diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index de29074..fc09db2 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -2,5 +2,6 @@ //! `Simulation` via a generation-synchronous birth–death process. pub mod tree; +pub mod poisson; pub use tree::{LineageNode, LineageTree}; diff --git a/engine_rs/src/lineage/poisson.rs b/engine_rs/src/lineage/poisson.rs new file mode 100644 index 0000000..3a8bbcc --- /dev/null +++ b/engine_rs/src/lineage/poisson.rs @@ -0,0 +1,57 @@ +//! Self-contained Poisson sampler over the engine `Rng` (Knuth's algorithm). + +use crate::rng::Rng; + +/// Draw a Poisson(`lambda`) variate using Knuth's multiplicative method. +/// +/// Deterministic for a given `Rng` state. `lambda <= 0.0` always returns 0. +/// Suitable for the small lambdas used in clonal branching (offspring ~1.5, +/// mutations ~<1). Not optimized for very large lambda. +pub fn poisson_sample(rng: &mut Rng, lambda: f64) -> u32 { + if !(lambda > 0.0) { + return 0; + } + let l = (-lambda).exp(); + let mut k: u32 = 0; + let mut p: f64 = 1.0; + loop { + k += 1; + p *= rng.next_f64(); + if p <= l { + return k - 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rng::Rng; + + #[test] + fn poisson_zero_lambda_is_always_zero() { + let mut rng = Rng::new(7); + for _ in 0..100 { + assert_eq!(poisson_sample(&mut rng, 0.0), 0); + } + } + + #[test] + fn poisson_is_deterministic_for_a_seed() { + let mut a = Rng::new(123); + let mut b = Rng::new(123); + let xs: Vec = (0..50).map(|_| poisson_sample(&mut a, 1.5)).collect(); + let ys: Vec = (0..50).map(|_| poisson_sample(&mut b, 1.5)).collect(); + assert_eq!(xs, ys); + } + + #[test] + fn poisson_mean_is_near_lambda() { + let mut rng = Rng::new(99); + let n = 20_000u32; + let lambda = 1.5; + let total: u64 = (0..n).map(|_| poisson_sample(&mut rng, lambda) as u64).sum(); + let mean = total as f64 / n as f64; + assert!((mean - lambda).abs() < 0.1, "mean {mean} not near {lambda}"); + } +} From c9ef36286c8508064428741a163747f93982faa5 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 11:18:42 +0300 Subject: [PATCH 04/59] feat(lineage): neutral generation-synchronous branching topology --- engine_rs/src/lineage/branching.rs | 142 +++++++++++++++++++++++++++++ engine_rs/src/lineage/mod.rs | 2 + 2 files changed, 144 insertions(+) create mode 100644 engine_rs/src/lineage/branching.rs diff --git a/engine_rs/src/lineage/branching.rs b/engine_rs/src/lineage/branching.rs new file mode 100644 index 0000000..5f8696c --- /dev/null +++ b/engine_rs/src/lineage/branching.rs @@ -0,0 +1,142 @@ +//! Generation-synchronous clonal branching loop. + +use crate::ir::Simulation; +use crate::rng::Rng; + +use super::poisson::poisson_sample; +use super::tree::{LineageNode, LineageTree}; + +/// Parameters controlling one clonal family's growth (neutral mode). +#[derive(Clone, Debug)] +pub struct BranchingParams { + /// Base expected offspring per cell per generation (neutral λ). + pub lambda_base: f64, + /// Expected mutations introduced per cell division (used in a later task). + pub lambda_mut: f64, + /// Maximum number of generations to grow. + pub max_generations: u32, + /// Population cap; growth stops adding once the live set reaches this. + pub n_max: u32, + /// Number of cells to sample at the end (used in a later task). + pub n_sample: u32, + /// Master seed for the whole family (deterministic). + pub seed: u64, +} + +/// Extract a node's genotype (pool bases) from its `Simulation`. +fn genotype_of(sim: &Simulation) -> Vec { + sim.pool.as_slice().iter().map(|n| n.base).collect() +} + +/// Grow the lineage TOPOLOGY only: children are exact clones of their parent +/// `Simulation` (no mutation). Returns the tree; live cells are whatever +/// remained at the final generation. A later task layers mutation on top. +pub fn grow_topology(founder: &Simulation, params: &BranchingParams) -> LineageTree { + let mut nodes: Vec = Vec::new(); + let mut sims: Vec = Vec::new(); + let mut rng = Rng::new(params.seed); + + nodes.push(LineageNode { + id: 0, + parent_id: None, + generation: 0, + genotype: genotype_of(founder), + mutations_from_parent: 0, + abundance: 0, + observed: false, + }); + sims.push(founder.clone()); + + let mut live: Vec = vec![0]; + let mut next_id: u32 = 1; + + for gen in 1..=params.max_generations { + if live.is_empty() { + break; + } + let mut next_live: Vec = Vec::new(); + for &parent_id in &live { + let k = poisson_sample(&mut rng, params.lambda_base); + for _ in 0..k { + if next_id >= params.n_max { + break; + } + let parent_sim = sims[parent_id as usize].clone(); + let child_sim = parent_sim; // exact clone for now; mutated in a later task + nodes.push(LineageNode { + id: next_id, + parent_id: Some(parent_id), + generation: gen, + genotype: genotype_of(&child_sim), + mutations_from_parent: 0, + abundance: 0, + observed: false, + }); + sims.push(child_sim); + next_live.push(next_id); + next_id += 1; + } + } + live = next_live; + } + + LineageTree { nodes } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::{Nucleotide, Region, Segment, NucHandle}; + use crate::ir::Simulation; + + /// Minimal founder: a 4-base V region "AAAA". + fn founder() -> Simulation { + let mut sim = Simulation::new(); + for (i, b) in b"AAAA".iter().enumerate() { + let (next, _) = sim.with_nucleotide_pushed( + Nucleotide::germline(*b, i as u16, Segment::V), + ); + sim = next; + } + sim.with_region_added(Region::new(Segment::V, NucHandle::new(0), NucHandle::new(4))) + } + + fn neutral_params() -> BranchingParams { + BranchingParams { + lambda_base: 1.5, + lambda_mut: 0.0, + max_generations: 5, + n_max: 1000, + n_sample: 10, + seed: 42, + } + } + + #[test] + fn grows_root_and_children_with_monotonic_generations() { + let tree = grow_topology(&founder(), &neutral_params()); + assert_eq!(tree.nodes.iter().filter(|n| n.parent_id.is_none()).count(), 1); + let root = tree.root(); + assert_eq!(root.id, 0); + assert_eq!(root.generation, 0); + for n in &tree.nodes { + if let Some(pid) = n.parent_id { + let parent = tree.get(pid).unwrap(); + assert_eq!(n.generation, parent.generation + 1); + } + } + assert!(tree.len() > 1, "tree did not grow: {} nodes", tree.len()); + } + + #[test] + fn growth_is_deterministic_for_a_seed() { + let a = grow_topology(&founder(), &neutral_params()); + let b = grow_topology(&founder(), &neutral_params()); + assert_eq!(a.len(), b.len()); + for (x, y) in a.nodes.iter().zip(b.nodes.iter()) { + assert_eq!(x.id, y.id); + assert_eq!(x.parent_id, y.parent_id); + assert_eq!(x.generation, y.generation); + } + } +} diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index fc09db2..db8486a 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -3,5 +3,7 @@ pub mod tree; pub mod poisson; +pub mod branching; pub use tree::{LineageNode, LineageTree}; +pub use branching::{grow_topology, BranchingParams}; From 30558f507e1f5ba5e5151de141374f3786caab57 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 11:26:13 +0300 Subject: [PATCH 05/59] fix(lineage): exit generation loop cleanly when n_max cap is hit --- engine_rs/src/lineage/branching.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/engine_rs/src/lineage/branching.rs b/engine_rs/src/lineage/branching.rs index 5f8696c..bd73765 100644 --- a/engine_rs/src/lineage/branching.rs +++ b/engine_rs/src/lineage/branching.rs @@ -52,17 +52,16 @@ pub fn grow_topology(founder: &Simulation, params: &BranchingParams) -> LineageT for gen in 1..=params.max_generations { if live.is_empty() { - break; + break; // lineage went extinct (every cell drew 0 offspring) } let mut next_live: Vec = Vec::new(); - for &parent_id in &live { + 'generation: for &parent_id in &live { let k = poisson_sample(&mut rng, params.lambda_base); for _ in 0..k { if next_id >= params.n_max { - break; + break 'generation; } - let parent_sim = sims[parent_id as usize].clone(); - let child_sim = parent_sim; // exact clone for now; mutated in a later task + let child_sim = sims[parent_id as usize].clone(); // mutated in a later task nodes.push(LineageNode { id: next_id, parent_id: Some(parent_id), From 18f5a804829fc75be9173b56cfc41e58e718baa5 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 11:28:57 +0300 Subject: [PATCH 06/59] feat(lineage): logistic carrying-capacity damping with peak tracking --- engine_rs/src/lineage/branching.rs | 53 +++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/engine_rs/src/lineage/branching.rs b/engine_rs/src/lineage/branching.rs index bd73765..ed0313a 100644 --- a/engine_rs/src/lineage/branching.rs +++ b/engine_rs/src/lineage/branching.rs @@ -28,10 +28,12 @@ fn genotype_of(sim: &Simulation) -> Vec { sim.pool.as_slice().iter().map(|n| n.base).collect() } -/// Grow the lineage TOPOLOGY only: children are exact clones of their parent -/// `Simulation` (no mutation). Returns the tree; live cells are whatever -/// remained at the final generation. A later task layers mutation on top. -pub fn grow_topology(founder: &Simulation, params: &BranchingParams) -> LineageTree { +/// Like `grow_topology` but also returns the peak live-population size reached +/// during growth. Internal helper for capacity tests and metric-aware callers. +pub(crate) fn grow_topology_with_peak( + founder: &Simulation, + params: &BranchingParams, +) -> (LineageTree, usize) { let mut nodes: Vec = Vec::new(); let mut sims: Vec = Vec::new(); let mut rng = Rng::new(params.seed); @@ -49,17 +51,23 @@ pub fn grow_topology(founder: &Simulation, params: &BranchingParams) -> LineageT let mut live: Vec = vec![0]; let mut next_id: u32 = 1; + let mut peak_live: usize = live.len(); for gen in 1..=params.max_generations { if live.is_empty() { break; // lineage went extinct (every cell drew 0 offspring) } + // Logistic carrying-capacity damping: as the live population approaches + // n_max, the effective offspring rate falls toward zero (plateau). + let live_frac = (live.len() as f64) / (params.n_max as f64); + let eff_lambda = params.lambda_base * (1.0 - live_frac).max(0.0); + let mut next_live: Vec = Vec::new(); 'generation: for &parent_id in &live { - let k = poisson_sample(&mut rng, params.lambda_base); + let k = poisson_sample(&mut rng, eff_lambda); for _ in 0..k { - if next_id >= params.n_max { - break 'generation; + if next_live.len() as u32 >= params.n_max { + break 'generation; // keep the live set within capacity } let child_sim = sims[parent_id as usize].clone(); // mutated in a later task nodes.push(LineageNode { @@ -77,9 +85,18 @@ pub fn grow_topology(founder: &Simulation, params: &BranchingParams) -> LineageT } } live = next_live; + if live.len() > peak_live { + peak_live = live.len(); + } } - LineageTree { nodes } + (LineageTree { nodes }, peak_live) +} + +/// Grow the lineage TOPOLOGY only (children are exact clones of their parent +/// `Simulation`; no mutation — a later task layers mutation on top). +pub fn grow_topology(founder: &Simulation, params: &BranchingParams) -> LineageTree { + grow_topology_with_peak(founder, params).0 } #[cfg(test)] @@ -138,4 +155,24 @@ mod tests { assert_eq!(x.generation, y.generation); } } + + #[test] + fn carrying_capacity_bounds_live_population() { + let params = BranchingParams { + lambda_base: 4.0, // would explode unbounded + lambda_mut: 0.0, + max_generations: 40, + n_max: 200, + n_sample: 10, + seed: 7, + }; + let (_tree, peak_live) = grow_topology_with_peak(&founder(), ¶ms); + // hard cap: live population never exceeds n_max + assert!(peak_live <= params.n_max as usize, + "peak live {peak_live} exceeded n_max {}", params.n_max); + // damping plateau (equilibrium ~ (1 - 1/lambda_base) * n_max = 150) is + // comfortably above half capacity — confirms it grew, not died early + assert!(peak_live > (params.n_max as usize) / 2, + "peak live {peak_live} did not approach capacity"); + } } From a693171a8d11a1b10ae01423cbb8fd68a6643451 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 13:17:58 +0300 Subject: [PATCH 07/59] docs(lineage): clarify carrying-capacity cap rationale and n_max doc --- engine_rs/src/lineage/branching.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/engine_rs/src/lineage/branching.rs b/engine_rs/src/lineage/branching.rs index ed0313a..fdd72a5 100644 --- a/engine_rs/src/lineage/branching.rs +++ b/engine_rs/src/lineage/branching.rs @@ -15,7 +15,9 @@ pub struct BranchingParams { pub lambda_mut: f64, /// Maximum number of generations to grow. pub max_generations: u32, - /// Population cap; growth stops adding once the live set reaches this. + /// Carrying capacity: the live population per generation is capped at this, + /// and it is the pivot for logistic offspring-rate damping (the effective + /// rate falls to zero as the live set approaches `n_max`). pub n_max: u32, /// Number of cells to sample at the end (used in a later task). pub n_sample: u32, @@ -66,8 +68,10 @@ pub(crate) fn grow_topology_with_peak( 'generation: for &parent_id in &live { let k = poisson_sample(&mut rng, eff_lambda); for _ in 0..k { - if next_live.len() as u32 >= params.n_max { - break 'generation; // keep the live set within capacity + // Hard cap: a Poisson draw can still overshoot even when + // eff_lambda is small near saturation, so bound the live set. + if next_live.len() >= params.n_max as usize { + break 'generation; } let child_sim = sims[parent_id as usize].clone(); // mutated in a later task nodes.push(LineageNode { From ea76cc4b0495adff8c6cf4ef5a0df6a4e635607e Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 13:21:33 +0300 Subject: [PATCH 08/59] feat(lineage): per-division mutation via pluggable Pass mutator --- engine_rs/src/lineage/branching.rs | 120 ++++++++++++++++++++++++++--- engine_rs/src/lineage/mod.rs | 2 +- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/engine_rs/src/lineage/branching.rs b/engine_rs/src/lineage/branching.rs index fdd72a5..52a49b3 100644 --- a/engine_rs/src/lineage/branching.rs +++ b/engine_rs/src/lineage/branching.rs @@ -1,7 +1,9 @@ //! Generation-synchronous clonal branching loop. use crate::ir::Simulation; +use crate::pass::{Pass, PassContext}; use crate::rng::Rng; +use crate::trace::Trace; use super::poisson::poisson_sample; use super::tree::{LineageNode, LineageTree}; @@ -30,11 +32,32 @@ fn genotype_of(sim: &Simulation) -> Vec { sim.pool.as_slice().iter().map(|n| n.base).collect() } -/// Like `grow_topology` but also returns the peak live-population size reached -/// during growth. Internal helper for capacity tests and metric-aware callers. -pub(crate) fn grow_topology_with_peak( +/// Apply one division's mutations: run `mutator` against `parent` with a fresh, +/// deterministic per-division RNG seeded by `child_seed`. Returns the mutated child. +fn mutate_child(parent: &Simulation, mutator: &dyn Pass, child_seed: u64) -> Simulation { + let mut trace = Trace::new(); + let mut rng = Rng::new(child_seed); + let mut ctx = PassContext { + replay_cursor: None, + trace: &mut trace, + rng: &mut rng, + pass_index: 0, + refdata: None, + contracts: None, + feasibility: None, + reference_index: None, + event_log_sink: None, + }; + mutator.execute(parent, &mut ctx) +} + +/// Core generation-synchronous growth. With `mutator = None`, children are exact +/// clones (and NO extra RNG is consumed). With `Some(m)`, each division draws a +/// deterministic sub-seed and applies `m`. Returns (tree, peak_live_population). +fn grow_core( founder: &Simulation, params: &BranchingParams, + mutator: Option<&dyn Pass>, ) -> (LineageTree, usize) { let mut nodes: Vec = Vec::new(); let mut sims: Vec = Vec::new(); @@ -68,18 +91,27 @@ pub(crate) fn grow_topology_with_peak( 'generation: for &parent_id in &live { let k = poisson_sample(&mut rng, eff_lambda); for _ in 0..k { - // Hard cap: a Poisson draw can still overshoot even when - // eff_lambda is small near saturation, so bound the live set. + // Hard cap: a Poisson draw can still overshoot near saturation. if next_live.len() >= params.n_max as usize { break 'generation; } - let child_sim = sims[parent_id as usize].clone(); // mutated in a later task + let parent_mut_count = sims[parent_id as usize].mutation_count; + let child_sim = match mutator { + Some(m) => { + let child_seed = rng.next_u64(); + mutate_child(&sims[parent_id as usize], m, child_seed) + } + None => sims[parent_id as usize].clone(), + }; + let muts = child_sim + .mutation_count + .saturating_sub(parent_mut_count); nodes.push(LineageNode { id: next_id, parent_id: Some(parent_id), generation: gen, genotype: genotype_of(&child_sim), - mutations_from_parent: 0, + mutations_from_parent: muts, abundance: 0, observed: false, }); @@ -97,17 +129,37 @@ pub(crate) fn grow_topology_with_peak( (LineageTree { nodes }, peak_live) } +/// Grow a full clonal lineage with per-division mutation via `mutator`. +/// Deterministic for `params.seed`. +pub fn grow_lineage( + founder: &Simulation, + params: &BranchingParams, + mutator: &dyn Pass, +) -> LineageTree { + grow_core(founder, params, Some(mutator)).0 +} + +/// Like `grow_topology` but also returns the peak live-population size reached +/// during growth. Internal helper for capacity tests and metric-aware callers. +pub(crate) fn grow_topology_with_peak( + founder: &Simulation, + params: &BranchingParams, +) -> (LineageTree, usize) { + grow_core(founder, params, None) +} + /// Grow the lineage TOPOLOGY only (children are exact clones of their parent /// `Simulation`; no mutation — a later task layers mutation on top). pub fn grow_topology(founder: &Simulation, params: &BranchingParams) -> LineageTree { - grow_topology_with_peak(founder, params).0 + grow_core(founder, params, None).0 } #[cfg(test)] mod tests { use super::*; - use crate::ir::{Nucleotide, Region, Segment, NucHandle}; - use crate::ir::Simulation; + use crate::dist::{EmpiricalLengthDist, UniformBase}; + use crate::ir::{Nucleotide, NucHandle, Region, Segment, Simulation}; + use crate::passes::UniformMutationPass; /// Minimal founder: a 4-base V region "AAAA". fn founder() -> Simulation { @@ -179,4 +231,52 @@ mod tests { assert!(peak_live > (params.n_max as usize) / 2, "peak live {peak_live} did not approach capacity"); } + + /// A mutator that applies exactly 2 substitutions per division. + fn two_mut_mutator() -> UniformMutationPass { + UniformMutationPass::new( + Box::new(EmpiricalLengthDist::from_pairs(vec![(2, 1.0)])), + Box::new(UniformBase), + ) + } + + #[test] + fn children_accumulate_mutations_and_branch_lengths() { + let params = BranchingParams { + lambda_base: 1.2, lambda_mut: 0.0, max_generations: 4, + n_max: 500, n_sample: 10, seed: 11, + }; + let mutator = two_mut_mutator(); + let tree = grow_lineage(&founder(), ¶ms, &mutator); + + assert_eq!(tree.root().mutations_from_parent, 0); + + let mut saw_mutated_child = false; + for n in &tree.nodes { + if n.parent_id.is_some() { + assert!(n.mutations_from_parent <= 2); + if n.mutations_from_parent > 0 { + saw_mutated_child = true; + let parent = tree.get(n.parent_id.unwrap()).unwrap(); + assert_ne!(n.genotype, parent.genotype); + } + } + } + assert!(saw_mutated_child, "no child accumulated any mutation"); + } + + #[test] + fn mutated_growth_is_deterministic() { + let params = BranchingParams { + lambda_base: 1.2, lambda_mut: 0.0, max_generations: 4, + n_max: 500, n_sample: 10, seed: 11, + }; + let a = grow_lineage(&founder(), ¶ms, &two_mut_mutator()); + let b = grow_lineage(&founder(), ¶ms, &two_mut_mutator()); + assert_eq!(a.len(), b.len()); + for (x, y) in a.nodes.iter().zip(b.nodes.iter()) { + assert_eq!(x.genotype, y.genotype); + assert_eq!(x.mutations_from_parent, y.mutations_from_parent); + } + } } diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index db8486a..f89f2d9 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -6,4 +6,4 @@ pub mod poisson; pub mod branching; pub use tree::{LineageNode, LineageTree}; -pub use branching::{grow_topology, BranchingParams}; +pub use branching::{grow_lineage, grow_topology, BranchingParams}; From d89f54611fca2999ebe85987217e60c441376946 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 13:30:02 +0300 Subject: [PATCH 09/59] refactor(lineage): drop unused peak wrapper; test calls grow_core directly --- engine_rs/src/lineage/branching.rs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/engine_rs/src/lineage/branching.rs b/engine_rs/src/lineage/branching.rs index 52a49b3..76b234c 100644 --- a/engine_rs/src/lineage/branching.rs +++ b/engine_rs/src/lineage/branching.rs @@ -139,15 +139,6 @@ pub fn grow_lineage( grow_core(founder, params, Some(mutator)).0 } -/// Like `grow_topology` but also returns the peak live-population size reached -/// during growth. Internal helper for capacity tests and metric-aware callers. -pub(crate) fn grow_topology_with_peak( - founder: &Simulation, - params: &BranchingParams, -) -> (LineageTree, usize) { - grow_core(founder, params, None) -} - /// Grow the lineage TOPOLOGY only (children are exact clones of their parent /// `Simulation`; no mutation — a later task layers mutation on top). pub fn grow_topology(founder: &Simulation, params: &BranchingParams) -> LineageTree { @@ -222,7 +213,7 @@ mod tests { n_sample: 10, seed: 7, }; - let (_tree, peak_live) = grow_topology_with_peak(&founder(), ¶ms); + let (_tree, peak_live) = grow_core(&founder(), ¶ms, None); // hard cap: live population never exceeds n_max assert!(peak_live <= params.n_max as usize, "peak live {peak_live} exceeded n_max {}", params.n_max); @@ -254,6 +245,8 @@ mod tests { let mut saw_mutated_child = false; for n in &tree.nodes { if n.parent_id.is_some() { + // <= 2 (not == 2): on the 4-base "AAAA" founder the two uniform + // substitutions can draw the same position, yielding 1 net change. assert!(n.mutations_from_parent <= 2); if n.mutations_from_parent > 0 { saw_mutated_child = true; From a68d6653fd399e6520445763b6265d128dde81f9 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 13:31:44 +0300 Subject: [PATCH 10/59] feat(lineage): sample + genotype-collapse into abundances --- engine_rs/src/lineage/mod.rs | 2 + engine_rs/src/lineage/sampling.rs | 89 +++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 engine_rs/src/lineage/sampling.rs diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index f89f2d9..7dfb081 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -4,6 +4,8 @@ pub mod tree; pub mod poisson; pub mod branching; +pub mod sampling; pub use tree::{LineageNode, LineageTree}; pub use branching::{grow_lineage, grow_topology, BranchingParams}; +pub use sampling::sample_and_collapse; diff --git a/engine_rs/src/lineage/sampling.rs b/engine_rs/src/lineage/sampling.rs new file mode 100644 index 0000000..7c71687 --- /dev/null +++ b/engine_rs/src/lineage/sampling.rs @@ -0,0 +1,89 @@ +//! Sampling observed cells from a grown lineage and collapsing identical +//! genotypes into abundance-bearing observed nodes. + +use std::collections::HashMap; + +use crate::rng::Rng; + +use super::tree::LineageTree; + +/// Sample `n_sample` cells uniformly (with replacement) from the tree's leaves +/// and collapse identical genotypes: the first leaf id seen carrying a given +/// genotype becomes the observed representative and accumulates the abundance; +/// later draws of the same genotype fold into it. Mutates `tree` in place. +pub fn sample_and_collapse(tree: &mut LineageTree, n_sample: u32, rng: &mut Rng) { + let leaf_ids: Vec = { + let leaves = tree.leaves(); + leaves.iter().map(|n| n.id).collect() + }; + if leaf_ids.is_empty() || n_sample == 0 { + return; + } + + // genotype -> representative node id (first id seen with that genotype) + let mut rep_by_genotype: HashMap, u32> = HashMap::new(); + // representative node id -> accumulated abundance + let mut abundance: HashMap = HashMap::new(); + + for _ in 0..n_sample { + let idx = (rng.next_u64() % (leaf_ids.len() as u64)) as usize; + let leaf_id = leaf_ids[idx]; + let genotype = tree.get(leaf_id).unwrap().genotype.clone(); + let rep = *rep_by_genotype.entry(genotype).or_insert(leaf_id); + *abundance.entry(rep).or_insert(0) += 1; + } + + for (id, count) in abundance { + let node = &mut tree.nodes[id as usize]; + node.abundance = count; + node.observed = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lineage::tree::{LineageNode, LineageTree}; + use crate::rng::Rng; + + // Tree with duplicate genotypes among leaves to exercise collapse. + fn tree_with_dupes() -> LineageTree { + LineageTree { + nodes: vec![ + LineageNode { id: 0, parent_id: None, generation: 0, genotype: b"AAAA".to_vec(), mutations_from_parent: 0, abundance: 0, observed: false }, + LineageNode { id: 1, parent_id: Some(0), generation: 1, genotype: b"AAAC".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, + LineageNode { id: 2, parent_id: Some(0), generation: 1, genotype: b"AAAC".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, + LineageNode { id: 3, parent_id: Some(1), generation: 2, genotype: b"AAGC".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, + ], + } + } + + #[test] + fn sampling_sets_abundances_summing_to_n_sample() { + let mut tree = tree_with_dupes(); + let mut rng = Rng::new(5); + let n_sample = 3; + sample_and_collapse(&mut tree, n_sample, &mut rng); + + let total: u32 = tree.nodes.iter().map(|n| n.abundance).sum(); + assert_eq!(total, n_sample); + + assert!(tree.nodes.iter().any(|n| n.observed)); + for n in &tree.nodes { + assert_eq!(n.observed, n.abundance > 0); + } + } + + #[test] + fn identical_genotypes_collapse_into_one_observed_node() { + let mut tree = tree_with_dupes(); + let mut rng = Rng::new(1); + sample_and_collapse(&mut tree, 3, &mut rng); + use std::collections::HashSet; + let mut seen: HashSet> = HashSet::new(); + for n in tree.nodes.iter().filter(|n| n.observed) { + assert!(seen.insert(n.genotype.clone()), + "genotype observed in more than one node (collapse failed)"); + } + } +} From ce9358509085ca022873e5b6be73d3c77694d535 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 14:13:16 +0300 Subject: [PATCH 11/59] refactor(lineage): debug-assert id-index invariant on sample write-back --- engine_rs/src/lineage/sampling.rs | 6 ++++++ engine_rs/src/lineage/tree.rs | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/engine_rs/src/lineage/sampling.rs b/engine_rs/src/lineage/sampling.rs index 7c71687..2b9c041 100644 --- a/engine_rs/src/lineage/sampling.rs +++ b/engine_rs/src/lineage/sampling.rs @@ -34,6 +34,12 @@ pub fn sample_and_collapse(tree: &mut LineageTree, n_sample: u32, rng: &mut Rng) } for (id, count) in abundance { + // Write-back relies on the `node.id == index into nodes` invariant + // (held by construction in the branching loop); guard it in debug builds. + debug_assert_eq!( + tree.nodes[id as usize].id, id, + "id-index invariant violated during sample write-back" + ); let node = &mut tree.nodes[id as usize]; node.abundance = count; node.observed = true; diff --git a/engine_rs/src/lineage/tree.rs b/engine_rs/src/lineage/tree.rs index 0588ee8..a22badd 100644 --- a/engine_rs/src/lineage/tree.rs +++ b/engine_rs/src/lineage/tree.rs @@ -22,7 +22,8 @@ pub struct LineageNode { pub mutations_from_parent: u32, /// Observation count after sampling + genotype-collapse. 0 until sampled. pub abundance: u32, - /// Whether this node was observed (sampled, or a collapsed-into ancestor). + /// Whether this node was observed: the representative node a sampled + /// genotype collapses onto. (Internal-ancestor observation is a later concern.) pub observed: bool, } From 35328f2e62d9a49ec008c7198fe5635a636a8064 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 14:15:41 +0300 Subject: [PATCH 12/59] feat(lineage): tree validator + simulate_family one-call entry --- engine_rs/src/lineage/family.rs | 72 +++++++++++++++++++++++++++++++++ engine_rs/src/lineage/mod.rs | 2 + engine_rs/src/lineage/tree.rs | 46 +++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 engine_rs/src/lineage/family.rs diff --git a/engine_rs/src/lineage/family.rs b/engine_rs/src/lineage/family.rs new file mode 100644 index 0000000..c31f5cd --- /dev/null +++ b/engine_rs/src/lineage/family.rs @@ -0,0 +1,72 @@ +//! One-call clonal family simulation: grow a lineage and sample it. + +use crate::ir::Simulation; +use crate::pass::Pass; +use crate::rng::Rng; + +use super::branching::{grow_lineage, BranchingParams}; +use super::sampling::sample_and_collapse; +use super::tree::LineageTree; + +/// Salt mixed into `params.seed` to give sampling an independent RNG stream +/// from growth, while staying deterministic for a given seed. +const SAMPLE_SEED_SALT: u64 = 0x5341_4D50_4C45_0001; // "SAMPLE\0\1" + +/// Grow a clonal family from `founder` and sample observed cells. +/// +/// Deterministic for `params.seed`. Growth and sampling use separate RNG +/// streams (the sampling stream is `params.seed ^ SAMPLE_SEED_SALT`), so the +/// whole family reproduces byte-for-byte across runs. +pub fn simulate_family( + founder: &Simulation, + params: &BranchingParams, + mutator: &dyn Pass, +) -> LineageTree { + let mut tree = grow_lineage(founder, params, mutator); + let mut sample_rng = Rng::new(params.seed ^ SAMPLE_SEED_SALT); + sample_and_collapse(&mut tree, params.n_sample, &mut sample_rng); + tree +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dist::{EmpiricalLengthDist, UniformBase}; + use crate::ir::{Nucleotide, NucHandle, Region, Segment, Simulation}; + use crate::lineage::BranchingParams; + use crate::passes::UniformMutationPass; + + fn founder() -> Simulation { + let mut sim = Simulation::new(); + for (i, b) in b"AAAAAAAA".iter().enumerate() { + let (next, _) = sim.with_nucleotide_pushed( + Nucleotide::germline(*b, i as u16, Segment::V)); + sim = next; + } + sim.with_region_added(Region::new(Segment::V, NucHandle::new(0), NucHandle::new(8))) + } + + #[test] + fn simulate_family_returns_valid_sampled_tree() { + let params = BranchingParams { + lambda_base: 1.5, lambda_mut: 0.0, max_generations: 6, + n_max: 300, n_sample: 20, seed: 2024, + }; + let mutator = UniformMutationPass::new( + Box::new(EmpiricalLengthDist::from_pairs(vec![(1, 1.0)])), + Box::new(UniformBase), + ); + let tree = simulate_family(&founder(), ¶ms, &mutator); + + assert!(tree.validate().is_ok(), "invalid tree: {:?}", tree.validate()); + let total_abundance: u32 = tree.nodes.iter().map(|n| n.abundance).sum(); + // abundance equals n_sample unless the family went extinct early + assert!(total_abundance == params.n_sample || total_abundance == 0); + + let tree2 = simulate_family(&founder(), ¶ms, + &UniformMutationPass::new( + Box::new(EmpiricalLengthDist::from_pairs(vec![(1, 1.0)])), + Box::new(UniformBase))); + assert_eq!(tree.len(), tree2.len()); + } +} diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index 7dfb081..18c3656 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -5,7 +5,9 @@ pub mod tree; pub mod poisson; pub mod branching; pub mod sampling; +pub mod family; pub use tree::{LineageNode, LineageTree}; pub use branching::{grow_lineage, grow_topology, BranchingParams}; pub use sampling::sample_and_collapse; +pub use family::simulate_family; diff --git a/engine_rs/src/lineage/tree.rs b/engine_rs/src/lineage/tree.rs index a22badd..ce36bc2 100644 --- a/engine_rs/src/lineage/tree.rs +++ b/engine_rs/src/lineage/tree.rs @@ -69,6 +69,33 @@ impl LineageTree { v } + /// Check structural invariants: exactly one root; every non-root parent + /// exists; child generation == parent generation + 1 (acyclicity follows + /// from the strictly-increasing generation along any parent chain). + pub fn validate(&self) -> Result<(), String> { + if self.nodes.is_empty() { + return Err("empty lineage tree".to_string()); + } + let roots = self.nodes.iter().filter(|n| n.parent_id.is_none()).count(); + if roots != 1 { + return Err(format!("expected exactly 1 root, found {roots}")); + } + for n in &self.nodes { + if let Some(pid) = n.parent_id { + let parent = self + .get(pid) + .ok_or_else(|| format!("node {} references missing parent {pid}", n.id))?; + if n.generation != parent.generation + 1 { + return Err(format!( + "node {} generation {} != parent {} generation {} + 1", + n.id, n.generation, parent.id, parent.generation + )); + } + } + } + Ok(()) + } + /// Nodes with no children (tips), in ascending id order. pub fn leaves(&self) -> Vec<&LineageNode> { let mut v: Vec<&LineageNode> = self.nodes @@ -96,6 +123,25 @@ mod tests { } } + #[test] + fn validate_accepts_well_formed_tree() { + assert!(hand_tree().validate().is_ok()); + } + + #[test] + fn validate_rejects_two_roots() { + let mut t = hand_tree(); + t.nodes[1].parent_id = None; // second root + assert!(t.validate().is_err()); + } + + #[test] + fn validate_rejects_nonmonotonic_generation() { + let mut t = hand_tree(); + t.nodes[3].generation = 1; // child not parent.gen + 1 + assert!(t.validate().is_err()); + } + #[test] fn tree_accessors_report_structure() { let t = hand_tree(); From 39edfab9b1b138d3e9699497a7a96fe9cbaa70e8 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 14:24:08 +0300 Subject: [PATCH 13/59] feat(lineage): validate enforces id-index invariant --- engine_rs/src/lineage/tree.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/engine_rs/src/lineage/tree.rs b/engine_rs/src/lineage/tree.rs index ce36bc2..00065f8 100644 --- a/engine_rs/src/lineage/tree.rs +++ b/engine_rs/src/lineage/tree.rs @@ -69,13 +69,24 @@ impl LineageTree { v } - /// Check structural invariants: exactly one root; every non-root parent - /// exists; child generation == parent generation + 1 (acyclicity follows - /// from the strictly-increasing generation along any parent chain). + /// Check structural invariants: `node.id == index` for every node; exactly + /// one root; every non-root parent exists; child generation == parent + /// generation + 1 (acyclicity then follows from the strictly-increasing + /// generation along any parent chain, since `get(pid)` resolves ids as + /// arena indices). pub fn validate(&self) -> Result<(), String> { if self.nodes.is_empty() { return Err("empty lineage tree".to_string()); } + // id == index invariant: relied upon by id-based lookups/write-backs. + for (idx, n) in self.nodes.iter().enumerate() { + if n.id as usize != idx { + return Err(format!( + "node at index {idx} has id {} (id-index mismatch)", + n.id + )); + } + } let roots = self.nodes.iter().filter(|n| n.parent_id.is_none()).count(); if roots != 1 { return Err(format!("expected exactly 1 root, found {roots}")); @@ -142,6 +153,13 @@ mod tests { assert!(t.validate().is_err()); } + #[test] + fn validate_rejects_id_index_mismatch() { + let mut t = hand_tree(); + t.nodes[2].id = 99; // id no longer equals its arena index + assert!(t.validate().is_err()); + } + #[test] fn tree_accessors_report_structure() { let t = hand_tree(); From 9d2ed0a8c9568f96aaae6d26f439716cb49981dc Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 14:25:17 +0300 Subject: [PATCH 14/59] docs(lineage): module usage example --- engine_rs/src/lineage/mod.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index 18c3656..0e078e3 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -1,5 +1,21 @@ //! Clonal lineage simulation: grow a real mutation tree from one founder //! `Simulation` via a generation-synchronous birth–death process. +//! +//! ```ignore +//! use genairr_engine::lineage::{simulate_family, BranchingParams}; +//! use genairr_engine::passes::UniformMutationPass; +//! // build a founder Simulation + a mutator (S5F in production), then: +//! let params = BranchingParams { +//! lambda_base: 1.5, lambda_mut: 0.0, max_generations: 12, +//! n_max: 1000, n_sample: 60, seed: 42, +//! }; +//! let tree = simulate_family(&founder, ¶ms, &mutator); +//! assert!(tree.validate().is_ok()); +//! ``` +//! +//! Neutral mode only (no affinity selection — a later plan). The mutator is any +//! `Pass`; production wires the S5F pass, tests use `UniformMutationPass` (no +//! reference cartridge required). pub mod tree; pub mod poisson; From a574a0786e664b08e3b1141788e42e0857b7d878 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 14:31:42 +0300 Subject: [PATCH 15/59] docs(lineage): clarify representative-draw semantics and inert lambda_mut --- engine_rs/src/lineage/branching.rs | 4 +++- engine_rs/src/lineage/sampling.rs | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/engine_rs/src/lineage/branching.rs b/engine_rs/src/lineage/branching.rs index 76b234c..8a93822 100644 --- a/engine_rs/src/lineage/branching.rs +++ b/engine_rs/src/lineage/branching.rs @@ -13,7 +13,9 @@ use super::tree::{LineageNode, LineageTree}; pub struct BranchingParams { /// Base expected offspring per cell per generation (neutral λ). pub lambda_base: f64, - /// Expected mutations introduced per cell division (used in a later task). + /// Expected mutations per cell division. NOTE: currently inert — per-division + /// mutation count is driven by the injected `Pass` mutator; this field is + /// reserved for a later task and is not read yet. pub lambda_mut: f64, /// Maximum number of generations to grow. pub max_generations: u32, diff --git a/engine_rs/src/lineage/sampling.rs b/engine_rs/src/lineage/sampling.rs index 2b9c041..8f19408 100644 --- a/engine_rs/src/lineage/sampling.rs +++ b/engine_rs/src/lineage/sampling.rs @@ -8,7 +8,7 @@ use crate::rng::Rng; use super::tree::LineageTree; /// Sample `n_sample` cells uniformly (with replacement) from the tree's leaves -/// and collapse identical genotypes: the first leaf id seen carrying a given +/// and collapse identical genotypes: the first leaf *drawn* carrying a given /// genotype becomes the observed representative and accumulates the abundance; /// later draws of the same genotype fold into it. Mutates `tree` in place. pub fn sample_and_collapse(tree: &mut LineageTree, n_sample: u32, rng: &mut Rng) { @@ -20,7 +20,7 @@ pub fn sample_and_collapse(tree: &mut LineageTree, n_sample: u32, rng: &mut Rng) return; } - // genotype -> representative node id (first id seen with that genotype) + // genotype -> representative node id (first leaf drawn with that genotype) let mut rep_by_genotype: HashMap, u32> = HashMap::new(); // representative node id -> accumulated abundance let mut abundance: HashMap = HashMap::new(); From 114accc512c43a62949f0a0be544b55c450da772 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 14:44:02 +0300 Subject: [PATCH 16/59] feat(lineage): node-table TSV export --- engine_rs/src/lineage/export.rs | 62 +++++++++++++++++++++++++++++++++ engine_rs/src/lineage/mod.rs | 2 ++ 2 files changed, 64 insertions(+) create mode 100644 engine_rs/src/lineage/export.rs diff --git a/engine_rs/src/lineage/export.rs b/engine_rs/src/lineage/export.rs new file mode 100644 index 0000000..2be1db8 --- /dev/null +++ b/engine_rs/src/lineage/export.rs @@ -0,0 +1,62 @@ +//! Ground-truth export for a `LineageTree`: node-table TSV, FASTA of all node +//! (ancestral + observed) sequences, and a Newick tree with per-edge mutation +//! counts as branch lengths. Consumed by lineage-inference benchmark tools. + +use std::fmt::Write as _; + +use super::tree::LineageTree; + +/// Tab-separated node table, one row per node plus a header. `parent_id` is +/// `NA` for the root. Columns: +/// `node_id, parent_id, generation, mutations_from_parent, abundance, observed, sequence`. +pub fn to_node_table_tsv(tree: &LineageTree) -> String { + let mut out = String::new(); + out.push_str( + "node_id\tparent_id\tgeneration\tmutations_from_parent\tabundance\tobserved\tsequence\n", + ); + for n in &tree.nodes { + let parent = match n.parent_id { + Some(p) => p.to_string(), + None => "NA".to_string(), + }; + let seq = String::from_utf8_lossy(&n.genotype); + let _ = writeln!( + out, + "{}\t{}\t{}\t{}\t{}\t{}\t{}", + n.id, parent, n.generation, n.mutations_from_parent, n.abundance, n.observed, seq + ); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lineage::tree::{LineageNode, LineageTree}; + + // root(0,"AAAA") -> 1("AAAC", abundance 2, observed), 2("AAAG"); 1 -> 3("ATAC", abundance 1, observed) + fn sample_tree() -> LineageTree { + LineageTree { + nodes: vec![ + LineageNode { id: 0, parent_id: None, generation: 0, genotype: b"AAAA".to_vec(), mutations_from_parent: 0, abundance: 0, observed: false }, + LineageNode { id: 1, parent_id: Some(0), generation: 1, genotype: b"AAAC".to_vec(), mutations_from_parent: 1, abundance: 2, observed: true }, + LineageNode { id: 2, parent_id: Some(0), generation: 1, genotype: b"AAAG".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, + LineageNode { id: 3, parent_id: Some(1), generation: 2, genotype: b"ATAC".to_vec(), mutations_from_parent: 1, abundance: 1, observed: true }, + ], + } + } + + #[test] + fn node_table_tsv_has_header_and_one_row_per_node() { + let tsv = to_node_table_tsv(&sample_tree()); + let lines: Vec<&str> = tsv.lines().collect(); + assert_eq!(lines.len(), 5); // 1 header + 4 nodes + assert_eq!( + lines[0], + "node_id\tparent_id\tgeneration\tmutations_from_parent\tabundance\tobserved\tsequence" + ); + assert_eq!(lines[1], "0\tNA\t0\t0\t0\tfalse\tAAAA"); + assert_eq!(lines[2], "1\t0\t1\t1\t2\ttrue\tAAAC"); + assert_eq!(lines[4], "3\t1\t2\t1\t1\ttrue\tATAC"); + } +} diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index 0e078e3..3281dfa 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -22,8 +22,10 @@ pub mod poisson; pub mod branching; pub mod sampling; pub mod family; +pub mod export; pub use tree::{LineageNode, LineageTree}; pub use branching::{grow_lineage, grow_topology, BranchingParams}; pub use sampling::sample_and_collapse; pub use family::simulate_family; +pub use export::to_node_table_tsv; From b95cba875afe3bad91b9f4e94d57966eaf744dbc Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 14:46:23 +0300 Subject: [PATCH 17/59] feat(lineage): FASTA export of all node sequences --- engine_rs/src/lineage/export.rs | 29 +++++++++++++++++++++++++++++ engine_rs/src/lineage/mod.rs | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/engine_rs/src/lineage/export.rs b/engine_rs/src/lineage/export.rs index 2be1db8..9b7ee75 100644 --- a/engine_rs/src/lineage/export.rs +++ b/engine_rs/src/lineage/export.rs @@ -29,6 +29,23 @@ pub fn to_node_table_tsv(tree: &LineageTree) -> String { out } +/// FASTA of every node (ancestral + observed). Header carries node id, +/// generation, abundance, and observed flag so downstream tools can map a +/// record back to its ground-truth node. +pub fn to_fasta(tree: &LineageTree) -> String { + let mut out = String::new(); + for n in &tree.nodes { + let seq = String::from_utf8_lossy(&n.genotype); + let _ = writeln!( + out, + ">node{}|gen={}|abundance={}|observed={}", + n.id, n.generation, n.abundance, n.observed + ); + let _ = writeln!(out, "{}", seq); + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -46,6 +63,18 @@ mod tests { } } + #[test] + fn fasta_emits_every_node_with_metadata_header() { + let fasta = to_fasta(&sample_tree()); + let lines: Vec<&str> = fasta.lines().collect(); + assert_eq!(lines.len(), 8); // 4 nodes => header+seq each + assert_eq!(lines[0], ">node0|gen=0|abundance=0|observed=false"); + assert_eq!(lines[1], "AAAA"); + assert_eq!(lines[2], ">node1|gen=1|abundance=2|observed=true"); + assert_eq!(lines[3], "AAAC"); + assert!(fasta.contains(">node3|gen=2|abundance=1|observed=true")); + } + #[test] fn node_table_tsv_has_header_and_one_row_per_node() { let tsv = to_node_table_tsv(&sample_tree()); diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index 3281dfa..8858cff 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -28,4 +28,4 @@ pub use tree::{LineageNode, LineageTree}; pub use branching::{grow_lineage, grow_topology, BranchingParams}; pub use sampling::sample_and_collapse; pub use family::simulate_family; -pub use export::to_node_table_tsv; +pub use export::{to_fasta, to_node_table_tsv}; From af7f0187b8a73e789785db56e9af098e2782c61a Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 14:48:45 +0300 Subject: [PATCH 18/59] feat(lineage): Newick export with per-edge mutation branch lengths --- engine_rs/src/lineage/export.rs | 65 +++++++++++++++++++++++++++++++++ engine_rs/src/lineage/mod.rs | 2 +- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/engine_rs/src/lineage/export.rs b/engine_rs/src/lineage/export.rs index 9b7ee75..f4d8d4e 100644 --- a/engine_rs/src/lineage/export.rs +++ b/engine_rs/src/lineage/export.rs @@ -46,6 +46,45 @@ pub fn to_fasta(tree: &LineageTree) -> String { out } +/// Recursively render the subtree rooted at `id` in Newick form, with the edge +/// to this node labelled by its `mutations_from_parent` count as branch length. +/// Recursion depth is bounded by the number of generations (small), not node +/// count, so this is safe from stack overflow at realistic family sizes. +fn newick_subtree(tree: &LineageTree, id: u32) -> String { + let node = tree.get(id).expect("newick: node id out of range"); + let children = tree.children_of(id); + let label = format!("node{id}"); + if children.is_empty() { + format!("{label}:{}", node.mutations_from_parent) + } else { + let inner: Vec = children + .iter() + .map(|c| newick_subtree(tree, c.id)) + .collect(); + format!("({}){label}:{}", inner.join(","), node.mutations_from_parent) + } +} + +/// Newick string for the whole tree. Branch lengths are per-edge mutation +/// counts. The root carries a label but no branch length (it is the origin). +/// The entire tree body is wrapped in parentheses per the Newick convention +/// that the outermost node is a virtual root. Always terminated with `;`. +pub fn to_newick(tree: &LineageTree) -> String { + let root = tree.root(); + let children = tree.children_of(root.id); + let root_label = format!("node{}", root.id); + let inner_body = if children.is_empty() { + root_label + } else { + let inner: Vec = children + .iter() + .map(|c| newick_subtree(tree, c.id)) + .collect(); + format!("({}){root_label}", inner.join(",")) + }; + format!("({inner_body});") +} + #[cfg(test)] mod tests { use super::*; @@ -88,4 +127,30 @@ mod tests { assert_eq!(lines[2], "1\t0\t1\t1\t2\ttrue\tAAAC"); assert_eq!(lines[4], "3\t1\t2\t1\t1\ttrue\tATAC"); } + + #[test] + fn newick_encodes_topology_and_branch_lengths() { + let nwk = to_newick(&sample_tree()); + assert!(nwk.ends_with(';'), "newick must end with ';': {nwk}"); + let opens = nwk.matches('(').count(); + let closes = nwk.matches(')').count(); + assert_eq!(opens, closes, "unbalanced parens: {nwk}"); + assert!(nwk.contains("node1:1")); + assert!(nwk.contains("node2:1")); + assert!(nwk.contains("node3:1")); + assert!(nwk.contains("node0")); + assert!(!nwk.contains("node0:"), "root must not have a branch length: {nwk}"); + assert_eq!(nwk, "(((node3:1)node1:1,node2:1)node0);"); + } + + #[test] + fn newick_single_node_tree() { + let tree = LineageTree { + nodes: vec![LineageNode { + id: 0, parent_id: None, generation: 0, genotype: b"AAAA".to_vec(), + mutations_from_parent: 0, abundance: 1, observed: true, + }], + }; + assert_eq!(to_newick(&tree), "(node0);"); + } } diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index 8858cff..eeab2f4 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -28,4 +28,4 @@ pub use tree::{LineageNode, LineageTree}; pub use branching::{grow_lineage, grow_topology, BranchingParams}; pub use sampling::sample_and_collapse; pub use family::simulate_family; -pub use export::{to_fasta, to_node_table_tsv}; +pub use export::{to_fasta, to_newick, to_node_table_tsv}; From e339f2a0987281296aaa04442cc86bda77b031bc Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 14:50:19 +0300 Subject: [PATCH 19/59] fix(lineage): emit standard rooted Newick (founder is the root, no phantom node) --- engine_rs/src/lineage/export.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/engine_rs/src/lineage/export.rs b/engine_rs/src/lineage/export.rs index f4d8d4e..38a5b7d 100644 --- a/engine_rs/src/lineage/export.rs +++ b/engine_rs/src/lineage/export.rs @@ -66,14 +66,16 @@ fn newick_subtree(tree: &LineageTree, id: u32) -> String { } /// Newick string for the whole tree. Branch lengths are per-edge mutation -/// counts. The root carries a label but no branch length (it is the origin). -/// The entire tree body is wrapped in parentheses per the Newick convention -/// that the outermost node is a virtual root. Always terminated with `;`. +/// counts. The founder is the named Newick root (its children are wrapped in +/// parentheses, its label follows) and carries no branch length, since it is +/// the origin. Always terminated with `;`. This is standard rooted Newick +/// (e.g. `((node3:1)node1:1,node2:1)node0;`) — no phantom outer node — so +/// ete3 / dendropy / Bio.Phylo parse `node0` as the actual root. pub fn to_newick(tree: &LineageTree) -> String { let root = tree.root(); let children = tree.children_of(root.id); let root_label = format!("node{}", root.id); - let inner_body = if children.is_empty() { + let body = if children.is_empty() { root_label } else { let inner: Vec = children @@ -82,7 +84,7 @@ pub fn to_newick(tree: &LineageTree) -> String { .collect(); format!("({}){root_label}", inner.join(",")) }; - format!("({inner_body});") + format!("{body};") } #[cfg(test)] @@ -140,7 +142,7 @@ mod tests { assert!(nwk.contains("node3:1")); assert!(nwk.contains("node0")); assert!(!nwk.contains("node0:"), "root must not have a branch length: {nwk}"); - assert_eq!(nwk, "(((node3:1)node1:1,node2:1)node0);"); + assert_eq!(nwk, "((node3:1)node1:1,node2:1)node0;"); } #[test] @@ -151,6 +153,6 @@ mod tests { mutations_from_parent: 0, abundance: 1, observed: true, }], }; - assert_eq!(to_newick(&tree), "(node0);"); + assert_eq!(to_newick(&tree), "node0;"); } } From 1d892900e73198311c34da3193a7fefad85226ea Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 14:52:45 +0300 Subject: [PATCH 20/59] test(lineage): export a grown family end-to-end; module doc --- engine_rs/src/lineage/export.rs | 41 +++++++++++++++++++++++++++++++++ engine_rs/src/lineage/mod.rs | 2 ++ 2 files changed, 43 insertions(+) diff --git a/engine_rs/src/lineage/export.rs b/engine_rs/src/lineage/export.rs index 38a5b7d..563c062 100644 --- a/engine_rs/src/lineage/export.rs +++ b/engine_rs/src/lineage/export.rs @@ -90,7 +90,48 @@ pub fn to_newick(tree: &LineageTree) -> String { #[cfg(test)] mod tests { use super::*; + use crate::dist::{EmpiricalLengthDist, UniformBase}; + use crate::ir::{Nucleotide, NucHandle, Region, Segment, Simulation}; + use crate::lineage::{simulate_family, BranchingParams}; use crate::lineage::tree::{LineageNode, LineageTree}; + use crate::passes::UniformMutationPass; + + fn grown_founder() -> Simulation { + let mut sim = Simulation::new(); + for (i, b) in b"AAAAAAAA".iter().enumerate() { + let (next, _) = sim.with_nucleotide_pushed( + Nucleotide::germline(*b, i as u16, Segment::V)); + sim = next; + } + sim.with_region_added(Region::new(Segment::V, NucHandle::new(0), NucHandle::new(8))) + } + + #[test] + fn exports_a_grown_family_consistently() { + let params = BranchingParams { + lambda_base: 1.5, lambda_mut: 0.0, max_generations: 6, + n_max: 300, n_sample: 20, seed: 2024, + }; + let mutator = UniformMutationPass::new( + Box::new(EmpiricalLengthDist::from_pairs(vec![(1, 1.0)])), + Box::new(UniformBase), + ); + let tree = simulate_family(&grown_founder(), ¶ms, &mutator); + assert!(tree.validate().is_ok()); + + let tsv = to_node_table_tsv(&tree); + let fasta = to_fasta(&tree); + let nwk = to_newick(&tree); + + assert_eq!(tsv.lines().count(), tree.len() + 1); + assert_eq!(fasta.lines().count(), tree.len() * 2); + assert!(nwk.ends_with(';')); + assert_eq!(nwk.matches('(').count(), nwk.matches(')').count()); + for n in &tree.nodes { + assert!(nwk.contains(&format!("node{}", n.id)), + "newick missing node{}", n.id); + } + } // root(0,"AAAA") -> 1("AAAC", abundance 2, observed), 2("AAAG"); 1 -> 3("ATAC", abundance 1, observed) fn sample_tree() -> LineageTree { diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index eeab2f4..b68c162 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -16,6 +16,8 @@ //! Neutral mode only (no affinity selection — a later plan). The mutator is any //! `Pass`; production wires the S5F pass, tests use `UniformMutationPass` (no //! reference cartridge required). +//! +//! Ground-truth export (Newick / FASTA / node-table TSV) lives in [`export`]. pub mod tree; pub mod poisson; From 1bcca6458083622593926b3019094e4a3fe0d329 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 15:00:06 +0300 Subject: [PATCH 21/59] docs(lineage): to_newick precondition note; assert node-2 TSV row --- engine_rs/src/lineage/export.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/engine_rs/src/lineage/export.rs b/engine_rs/src/lineage/export.rs index 563c062..647c1c0 100644 --- a/engine_rs/src/lineage/export.rs +++ b/engine_rs/src/lineage/export.rs @@ -71,6 +71,9 @@ fn newick_subtree(tree: &LineageTree, id: u32) -> String { /// the origin. Always terminated with `;`. This is standard rooted Newick /// (e.g. `((node3:1)node1:1,node2:1)node0;`) — no phantom outer node — so /// ete3 / dendropy / Bio.Phylo parse `node0` as the actual root. +/// +/// Precondition: the tree has exactly one root (one node with `parent_id == +/// None`); call [`LineageTree::validate`] first if the tree was hand-built. pub fn to_newick(tree: &LineageTree) -> String { let root = tree.root(); let children = tree.children_of(root.id); @@ -168,6 +171,7 @@ mod tests { ); assert_eq!(lines[1], "0\tNA\t0\t0\t0\tfalse\tAAAA"); assert_eq!(lines[2], "1\t0\t1\t1\t2\ttrue\tAAAC"); + assert_eq!(lines[3], "2\t0\t1\t1\t0\tfalse\tAAAG"); assert_eq!(lines[4], "3\t1\t2\t1\t1\ttrue\tATAC"); } From 0852140524a131c92d3f56ef3210d18665f35f9c Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 15:10:46 +0300 Subject: [PATCH 22/59] feat(lineage): PyO3 LineageNode/LineageTree bindings --- engine_rs/src/python/lineage.rs | 108 ++++++++++++++++++++++++++++++++ engine_rs/src/python/mod.rs | 3 + 2 files changed, 111 insertions(+) create mode 100644 engine_rs/src/python/lineage.rs diff --git a/engine_rs/src/python/lineage.rs b/engine_rs/src/python/lineage.rs new file mode 100644 index 0000000..256ab3d --- /dev/null +++ b/engine_rs/src/python/lineage.rs @@ -0,0 +1,108 @@ +//! PyO3 bindings for the clonal lineage engine: `LineageNode`, `LineageTree`, +//! and the `simulate_lineage` entry point. + +use pyo3::prelude::*; + +use crate::lineage::export::{to_fasta, to_newick, to_node_table_tsv}; +use crate::lineage::tree::{LineageNode, LineageTree}; + +/// One node of a clonal lineage tree (read-only view). +#[pyclass(name = "LineageNode", module = "GenAIRR._engine", frozen)] +pub struct PyLineageNode { + pub(crate) inner: LineageNode, +} + +#[pymethods] +impl PyLineageNode { + #[getter] + fn id(&self) -> u32 { + self.inner.id + } + #[getter] + fn parent_id(&self) -> Option { + self.inner.parent_id + } + #[getter] + fn generation(&self) -> u32 { + self.inner.generation + } + #[getter] + fn mutations_from_parent(&self) -> u32 { + self.inner.mutations_from_parent + } + #[getter] + fn abundance(&self) -> u32 { + self.inner.abundance + } + #[getter] + fn observed(&self) -> bool { + self.inner.observed + } + /// Nucleotide sequence (pool bases) as a string. + #[getter] + fn sequence(&self) -> String { + String::from_utf8_lossy(&self.inner.genotype).into_owned() + } + + fn __repr__(&self) -> String { + format!( + "LineageNode(id={}, parent_id={:?}, generation={}, abundance={}, observed={})", + self.inner.id, + self.inner.parent_id, + self.inner.generation, + self.inner.abundance, + self.inner.observed + ) + } +} + +/// A clonal lineage tree (read-only view) with ground-truth export. +#[pyclass(name = "LineageTree", module = "GenAIRR._engine", frozen)] +pub struct PyLineageTree { + pub(crate) inner: LineageTree, +} + +impl PyLineageTree { + pub(crate) fn new(inner: LineageTree) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyLineageTree { + fn __len__(&self) -> usize { + self.inner.len() + } + + /// All nodes (founder + descendants), in arena order (ascending id). + fn nodes(&self) -> Vec { + self.inner + .nodes + .iter() + .cloned() + .map(|inner| PyLineageNode { inner }) + .collect() + } + + /// Validate structural invariants; raises ValueError if malformed. + fn validate(&self) -> PyResult<()> { + self.inner + .validate() + .map_err(pyo3::exceptions::PyValueError::new_err) + } + + /// Standard rooted Newick string (branch length = per-edge mutation count). + fn to_newick(&self) -> String { + to_newick(&self.inner) + } + + /// FASTA of every node (ancestral + observed) sequence. + fn to_fasta(&self) -> String { + to_fasta(&self.inner) + } + + /// Tab-separated node table. + fn to_node_table_tsv(&self) -> String { + to_node_table_tsv(&self.inner) + } +} diff --git a/engine_rs/src/python/mod.rs b/engine_rs/src/python/mod.rs index fbb9e43..c018473 100644 --- a/engine_rs/src/python/mod.rs +++ b/engine_rs/src/python/mod.rs @@ -28,6 +28,7 @@ use pyo3::prelude::*; pub mod compiled; pub mod contract; pub mod event; +pub mod lineage; pub mod outcome; pub mod plan; pub mod refdata; @@ -47,6 +48,8 @@ pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; From efdea3a06899a84bd382381f3cf198adb762f168 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 15:15:20 +0300 Subject: [PATCH 23/59] feat(lineage): simulate_lineage PyO3 entry point --- engine_rs/src/python/lineage.rs | 66 +++++++++++++++++++++++++++++++++ engine_rs/src/python/mod.rs | 1 + 2 files changed, 67 insertions(+) diff --git a/engine_rs/src/python/lineage.rs b/engine_rs/src/python/lineage.rs index 256ab3d..79d0d04 100644 --- a/engine_rs/src/python/lineage.rs +++ b/engine_rs/src/python/lineage.rs @@ -5,6 +5,11 @@ use pyo3::prelude::*; use crate::lineage::export::{to_fasta, to_newick, to_node_table_tsv}; use crate::lineage::tree::{LineageNode, LineageTree}; +use crate::lineage::{simulate_family, BranchingParams}; +use crate::passes::S5FMutationPass; +use crate::s5f::S5FKernel; + +use super::simulation::PySimulation; /// One node of a clonal lineage tree (read-only view). #[pyclass(name = "LineageNode", module = "GenAIRR._engine", frozen)] @@ -106,3 +111,64 @@ impl PyLineageTree { to_node_table_tsv(&self.inner) } } + +/// Grow + sample a clonal lineage family from `founder` using an S5F mutator +/// built from the supplied kernel tables. Returns the ground-truth tree. +/// +/// `mutability` must have 1024 entries and `substitution` 4096 (the S5F 5-mer +/// kernel). `rate` is the per-base SHM rate in [0, 1]. Determinism is keyed on +/// `seed`. +#[pyfunction] +#[pyo3(signature = ( + founder, mutability, substitution, rate, + lambda_base, lambda_mut, max_generations, n_max, n_sample, seed +))] +#[allow(clippy::too_many_arguments)] +pub(crate) fn simulate_lineage( + founder: &PySimulation, + mutability: Vec, + substitution: Vec, + rate: f64, + lambda_base: f64, + lambda_mut: f64, + max_generations: u32, + n_max: u32, + n_sample: u32, + seed: u64, +) -> PyResult { + use pyo3::exceptions::PyValueError; + + if mutability.len() != 1024 { + return Err(PyValueError::new_err(format!( + "simulate_lineage: mutability must have 1024 entries, got {}", + mutability.len() + ))); + } + if substitution.len() != 4096 { + return Err(PyValueError::new_err(format!( + "simulate_lineage: substitution must have 4096 entries, got {}", + substitution.len() + ))); + } + if !(rate.is_finite() && (0.0..=1.0).contains(&rate)) { + return Err(PyValueError::new_err(format!( + "simulate_lineage: rate must be in [0.0, 1.0], got {rate}" + ))); + } + if n_max == 0 { + return Err(PyValueError::new_err("simulate_lineage: n_max must be > 0")); + } + + let kernel = S5FKernel::new(mutability, substitution); + let mutator = S5FMutationPass::new_rate(kernel, rate); + let params = BranchingParams { + lambda_base, + lambda_mut, + max_generations, + n_max, + n_sample, + seed, + }; + let tree = simulate_family(&founder.inner, ¶ms, &mutator); + Ok(PyLineageTree::new(tree)) +} diff --git a/engine_rs/src/python/mod.rs b/engine_rs/src/python/mod.rs index c018473..29e4e2a 100644 --- a/engine_rs/src/python/mod.rs +++ b/engine_rs/src/python/mod.rs @@ -50,6 +50,7 @@ pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_function(pyo3::wrap_pyfunction!(lineage::simulate_lineage, m)?)?; m.add_class::()?; m.add_class::()?; m.add_class::()?; From 463b7adbfb8f914c4aecf6c84458137fbf27d2ab Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 15:20:50 +0300 Subject: [PATCH 24/59] test(lineage): end-to-end Python simulate_lineage --- tests/test_lineage_engine.py | 56 ++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/test_lineage_engine.py diff --git a/tests/test_lineage_engine.py b/tests/test_lineage_engine.py new file mode 100644 index 0000000..3b072b8 --- /dev/null +++ b/tests/test_lineage_engine.py @@ -0,0 +1,56 @@ +import pytest +import GenAIRR as ga +from GenAIRR import _engine +from GenAIRR._s5f_loader import load_builtin_s5f_kernel + +S5F_MODEL = "hh_s5f" + + +def _founder(): + compiled = ga.Experiment.on("human_igh").recombine().compile() + outcome = compiled.run(n=1, seed=0)[0] + return outcome.final_simulation() + + +def _kernel(): + return load_builtin_s5f_kernel(S5F_MODEL) + + +def test_simulate_lineage_produces_valid_tree(): + founder = _founder() + mut, sub = _kernel() + tree = _engine.simulate_lineage(founder, mut, sub, 0.05, 1.5, 0.0, 8, 500, 30, 2024) + tree.validate() + assert len(tree) >= 1 + nodes = tree.nodes() + assert nodes[0].parent_id is None + assert nodes[0].generation == 0 + assert any(n.mutations_from_parent > 0 for n in nodes) or len(tree) == 1 + + +def test_simulate_lineage_exports_are_wellformed(): + founder = _founder() + mut, sub = _kernel() + tree = _engine.simulate_lineage(founder, mut, sub, 0.05, 1.5, 0.0, 8, 500, 30, 7) + nwk = tree.to_newick() + assert nwk.endswith(";") + assert nwk.count("(") == nwk.count(")") + assert tree.to_fasta().startswith(">node") + tsv = tree.to_node_table_tsv() + assert tsv.splitlines()[0].startswith("node_id\t") + assert len(tsv.splitlines()) == len(tree) + 1 + + +def test_simulate_lineage_is_deterministic(): + founder = _founder() + mut, sub = _kernel() + a = _engine.simulate_lineage(founder, mut, sub, 0.05, 1.5, 0.0, 8, 500, 30, 99) + b = _engine.simulate_lineage(founder, mut, sub, 0.05, 1.5, 0.0, 8, 500, 30, 99) + assert a.to_newick() == b.to_newick() + assert a.to_fasta() == b.to_fasta() + + +def test_simulate_lineage_rejects_bad_kernel(): + founder = _founder() + with pytest.raises(ValueError): + _engine.simulate_lineage(founder, [0.1] * 10, [0.1] * 10, 0.05, 1.5, 0.0, 8, 500, 30, 0) From 668e1c4dcd243b3ed3438dc6784021408a6a8a10 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 15:27:00 +0300 Subject: [PATCH 25/59] fix(lineage): guard simulate_lineage against non-finite/negative kernel + zero n_sample --- engine_rs/src/python/lineage.rs | 20 ++++++++++++++++++-- tests/test_lineage_engine.py | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/engine_rs/src/python/lineage.rs b/engine_rs/src/python/lineage.rs index 79d0d04..dde852a 100644 --- a/engine_rs/src/python/lineage.rs +++ b/engine_rs/src/python/lineage.rs @@ -116,8 +116,10 @@ impl PyLineageTree { /// built from the supplied kernel tables. Returns the ground-truth tree. /// /// `mutability` must have 1024 entries and `substitution` 4096 (the S5F 5-mer -/// kernel). `rate` is the per-base SHM rate in [0, 1]. Determinism is keyed on -/// `seed`. +/// kernel); all values must be finite and non-negative. `rate` is the per-base +/// SHM rate in [0, 1]. Determinism is keyed on `seed`. `lambda_mut` is reserved +/// for a future plan and currently has no effect (mutations are driven by the +/// S5F `rate`). #[pyfunction] #[pyo3(signature = ( founder, mutability, substitution, rate, @@ -155,10 +157,24 @@ pub(crate) fn simulate_lineage( "simulate_lineage: rate must be in [0.0, 1.0], got {rate}" ))); } + if mutability.iter().any(|&m| !m.is_finite() || m < 0.0) { + return Err(PyValueError::new_err( + "simulate_lineage: mutability values must be finite and non-negative", + )); + } + if substitution.iter().any(|&s| !s.is_finite() || s < 0.0) { + return Err(PyValueError::new_err( + "simulate_lineage: substitution values must be finite and non-negative", + )); + } if n_max == 0 { return Err(PyValueError::new_err("simulate_lineage: n_max must be > 0")); } + if n_sample == 0 { + return Err(PyValueError::new_err("simulate_lineage: n_sample must be > 0")); + } + // Lengths and value ranges are now guaranteed, so S5FKernel::new cannot panic. let kernel = S5FKernel::new(mutability, substitution); let mutator = S5FMutationPass::new_rate(kernel, rate); let params = BranchingParams { diff --git a/tests/test_lineage_engine.py b/tests/test_lineage_engine.py index 3b072b8..fccfb01 100644 --- a/tests/test_lineage_engine.py +++ b/tests/test_lineage_engine.py @@ -54,3 +54,25 @@ def test_simulate_lineage_rejects_bad_kernel(): founder = _founder() with pytest.raises(ValueError): _engine.simulate_lineage(founder, [0.1] * 10, [0.1] * 10, 0.05, 1.5, 0.0, 8, 500, 30, 0) + + +def test_simulate_lineage_rejects_nonfinite_or_negative_kernel(): + founder = _founder() + _mut, sub = _kernel() + # NaN in the mutability table must raise (not panic across the FFI boundary). + with pytest.raises(ValueError): + _engine.simulate_lineage( + founder, [float("nan")] * 1024, sub, 0.05, 1.5, 0.0, 8, 500, 30, 0 + ) + # Negative value in the mutability table must raise too. + with pytest.raises(ValueError): + _engine.simulate_lineage( + founder, [-1.0] + [0.0] * 1023, sub, 0.05, 1.5, 0.0, 8, 500, 30, 0 + ) + + +def test_simulate_lineage_rejects_zero_n_sample(): + founder = _founder() + mut, sub = _kernel() + with pytest.raises(ValueError): + _engine.simulate_lineage(founder, mut, sub, 0.05, 1.5, 0.0, 8, 500, 0, 0) From 90a3f54b67f3dc08ae449307f3a0ded68990f07f Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 15:36:19 +0300 Subject: [PATCH 26/59] feat(lineage): BLOSUM62 weighted aa-distance + mature-target generation --- engine_rs/src/lineage/affinity.rs | 138 ++++++++++++++++++++++++++++++ engine_rs/src/lineage/mod.rs | 1 + 2 files changed, 139 insertions(+) create mode 100644 engine_rs/src/lineage/affinity.rs diff --git a/engine_rs/src/lineage/affinity.rs b/engine_rs/src/lineage/affinity.rs new file mode 100644 index 0000000..c739d9c --- /dev/null +++ b/engine_rs/src/lineage/affinity.rs @@ -0,0 +1,138 @@ +//! Affinity model for clonal selection: BLOSUM62-weighted, region-weighted +//! amino-acid distance to a target antigen, and target generation. + +use crate::rng::Rng; + +/// 20 standard amino acids in the BLOSUM62 matrix's row/column order. +const AA_ORDER: &[u8; 20] = b"ARNDCQEGHILKMFPSTWYV"; + +/// BLOSUM62 substitution score for two amino-acid bytes (uppercase one-letter +/// codes). Unknown / non-standard residues (including `*` and `X`) score as a +/// strong mismatch so they contribute maximally to distance. +pub fn blosum62(a: u8, b: u8) -> i8 { + fn idx(aa: u8) -> Option { + AA_ORDER.iter().position(|&x| x == aa) + } + // Canonical BLOSUM62, rows/cols in AA_ORDER. + const M: [[i8; 20]; 20] = [ + // A R N D C Q E G H I L K M F P S T W Y V + [ 4,-1,-2,-2, 0,-1,-1, 0,-2,-1,-1,-1,-1,-2,-1, 1, 0,-3,-2, 0], + [-1, 5, 0,-2,-3, 1, 0,-2, 0,-3,-2, 2,-1,-3,-2,-1,-1,-3,-2,-3], + [-2, 0, 6, 1,-3, 0, 0, 0, 1,-3,-3, 0,-2,-3,-2, 1, 0,-4,-2,-3], + [-2,-2, 1, 6,-3, 0, 2,-1,-1,-3,-4,-1,-3,-3,-1, 0,-1,-4,-3,-3], + [ 0,-3,-3,-3, 9,-3,-4,-3,-3,-1,-1,-3,-1,-2,-3,-1,-1,-2,-2,-1], + [-1, 1, 0, 0,-3, 5, 2,-2, 0,-3,-2, 1, 0,-3,-1, 0,-1,-2,-1,-2], + [-1, 0, 0, 2,-4, 2, 5,-2, 0,-3,-3, 1,-2,-3,-1, 0,-1,-3,-2,-2], + [ 0,-2, 0,-1,-3,-2,-2, 6,-2,-4,-4,-2,-3,-3,-2, 0,-2,-2,-3,-3], + [-2, 0, 1,-1,-3, 0, 0,-2, 8,-3,-3,-1,-2,-1,-2,-1,-2,-2, 2,-3], + [-1,-3,-3,-3,-1,-3,-3,-4,-3, 4, 2,-3, 1, 0,-3,-2,-1,-3,-1, 3], + [-1,-2,-3,-4,-1,-2,-3,-4,-3, 2, 4,-2, 2, 0,-3,-2,-1,-2,-1, 1], + [-1, 2, 0,-1,-3, 1, 1,-2,-1,-3,-2, 5,-1,-3,-1, 0,-1,-3,-2,-2], + [-1,-1,-2,-3,-1, 0,-2,-3,-2, 1, 2,-1, 5, 0,-2,-1,-1,-1,-1, 1], + [-2,-3,-3,-3,-2,-3,-3,-3,-1, 0, 0,-3, 0, 6,-4,-2,-2, 1, 3,-1], + [-1,-2,-2,-1,-3,-1,-1,-2,-2,-3,-3,-1,-2,-4, 7,-1,-1,-4,-3,-2], + [ 1,-1, 1, 0,-1, 0, 0, 0,-1,-2,-2, 0,-1,-2,-1, 4, 1,-3,-2,-2], + [ 0,-1, 0,-1,-1,-1,-1,-2,-2,-1,-1,-1,-1,-2,-1, 1, 5,-2,-2, 0], + [-3,-3,-4,-4,-2,-2,-3,-2,-2,-3,-2,-3,-1, 1,-4,-3,-2,11, 2,-3], + [-2,-2,-2,-3,-2,-1,-2,-3, 2,-1,-1,-2,-1, 3,-3,-2,-2, 2, 7,-1], + [ 0,-3,-3,-3,-1,-2,-2,-3,-3, 3, 1,-2, 1,-1,-2,-2, 0,-3,-1, 4], + ]; + match (idx(a), idx(b)) { + (Some(i), Some(j)) => M[i][j], + _ => -4, + } +} + +/// Per-position substitution cost from BLOSUM62: `S(a,a) - S(a,b)`, which is 0 +/// when `b == a` and grows as the substitution becomes less favorable. +fn substitution_cost(a: u8, b: u8) -> f64 { + (blosum62(a, a) as f64 - blosum62(a, b) as f64).max(0.0) +} + +/// Region-weighted BLOSUM62 amino-acid distance between two aa sequences. +/// `weights` is per amino-acid position; positions beyond the shorter sequence +/// are ignored, and weights shorter than the sequences default to 1.0. +pub fn weighted_aa_distance(a: &[u8], b: &[u8], weights: &[f64]) -> f64 { + let n = a.len().min(b.len()); + let mut d = 0.0; + for i in 0..n { + let w = weights.get(i).copied().unwrap_or(1.0); + d += w * substitution_cost(a[i], b[i]); + } + d +} + +/// Generate a "mature" target: the naive aa sequence with up to `m` random +/// single-residue substitutions to a different standard amino acid. +/// Deterministic for the given `rng` state. +pub fn make_mature_target(naive_aa: &[u8], m: u32, rng: &mut Rng) -> Vec { + let mut target = naive_aa.to_vec(); + if target.is_empty() { + return target; + } + for _ in 0..m { + let pos = (rng.next_u64() % target.len() as u64) as usize; + let cur = target[pos]; + // pick a standard amino acid different from the current residue + let start = (rng.next_u64() % 20) as usize; + let mut pick = AA_ORDER[start]; + if pick == cur { + pick = AA_ORDER[(start + 1) % 20]; + } + target[pos] = pick; + } + target +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rng::Rng; + + #[test] + fn blosum62_known_entries() { + assert_eq!(blosum62(b'A', b'A'), 4); + assert_eq!(blosum62(b'W', b'W'), 11); + assert_eq!(blosum62(b'C', b'C'), 9); + assert_eq!(blosum62(b'A', b'R'), -1); + assert_eq!(blosum62(b'W', b'C'), -2); + assert_eq!(blosum62(b'D', b'E'), 2); + assert_eq!(blosum62(b'R', b'A'), blosum62(b'A', b'R')); + let _ = blosum62(b'*', b'A'); + let _ = blosum62(b'X', b'X'); + } + + #[test] + fn weighted_distance_zero_for_identical() { + let a = b"ACDEW"; + let w = vec![1.0; 5]; + assert!(weighted_aa_distance(a, a, &w).abs() < 1e-9); + } + + #[test] + fn weighted_distance_increases_with_substitutions_and_respects_weights() { + let a = b"AAAAA"; + let b = b"AAAAW"; + let flat = vec![1.0; 5]; + let d_flat = weighted_aa_distance(a, b, &flat); + assert!(d_flat > 0.0); + let mut heavy = vec![1.0; 5]; + heavy[4] = 10.0; + assert!(weighted_aa_distance(a, b, &heavy) > d_flat); + let mut zero = vec![1.0; 5]; + zero[4] = 0.0; + assert!(weighted_aa_distance(a, b, &zero).abs() < 1e-9); + } + + #[test] + fn mature_target_applies_m_substitutions_deterministically() { + let naive = b"ACDEFGHIKLMNPQRSTVWY".to_vec(); + let mut rng = Rng::new(42); + let t = make_mature_target(&naive, 3, &mut rng); + assert_eq!(t.len(), naive.len()); + let diffs = naive.iter().zip(&t).filter(|(a, b)| a != b).count(); + assert!(diffs >= 1 && diffs <= 3, "expected up to 3 aa substitutions, got {diffs}"); + let mut rng2 = Rng::new(42); + assert_eq!(make_mature_target(&naive, 3, &mut rng2), t); + } +} diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index b68c162..fc6b7bb 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -25,6 +25,7 @@ pub mod branching; pub mod sampling; pub mod family; pub mod export; +pub mod affinity; pub use tree::{LineageNode, LineageTree}; pub use branching::{grow_lineage, grow_topology, BranchingParams}; From 97241323fab658690e68d08ac2e74bab42e1b4f3 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 16:13:33 +0300 Subject: [PATCH 27/59] review: validate lambda/max_generations, harden poisson finiteness, O(n) leaves, unbiased leaf sampling --- engine_rs/src/lineage/poisson.rs | 15 +++++++++++++-- engine_rs/src/lineage/sampling.rs | 4 +++- engine_rs/src/lineage/tree.rs | 21 ++++++++++++++++----- engine_rs/src/python/lineage.rs | 18 ++++++++++++++++++ tests/test_lineage_engine.py | 19 +++++++++++++++++++ 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/engine_rs/src/lineage/poisson.rs b/engine_rs/src/lineage/poisson.rs index 3a8bbcc..e428767 100644 --- a/engine_rs/src/lineage/poisson.rs +++ b/engine_rs/src/lineage/poisson.rs @@ -4,11 +4,14 @@ use crate::rng::Rng; /// Draw a Poisson(`lambda`) variate using Knuth's multiplicative method. /// -/// Deterministic for a given `Rng` state. `lambda <= 0.0` always returns 0. +/// Deterministic for a given `Rng` state. Non-finite (NaN / ±inf) or +/// non-positive `lambda` always returns 0 — the `is_finite` guard matches the +/// engine's other Poisson sampler (`passes::count_source`) so an infinite +/// lambda cannot drive the loop to a pathological underflow-termination. /// Suitable for the small lambdas used in clonal branching (offspring ~1.5, /// mutations ~<1). Not optimized for very large lambda. pub fn poisson_sample(rng: &mut Rng, lambda: f64) -> u32 { - if !(lambda > 0.0) { + if !(lambda.is_finite() && lambda > 0.0) { return 0; } let l = (-lambda).exp(); @@ -45,6 +48,14 @@ mod tests { assert_eq!(xs, ys); } + #[test] + fn poisson_nonfinite_lambda_is_zero() { + let mut rng = Rng::new(3); + assert_eq!(poisson_sample(&mut rng, f64::NAN), 0); + assert_eq!(poisson_sample(&mut rng, f64::INFINITY), 0); + assert_eq!(poisson_sample(&mut rng, -1.0), 0); + } + #[test] fn poisson_mean_is_near_lambda() { let mut rng = Rng::new(99); diff --git a/engine_rs/src/lineage/sampling.rs b/engine_rs/src/lineage/sampling.rs index 8f19408..5af60b6 100644 --- a/engine_rs/src/lineage/sampling.rs +++ b/engine_rs/src/lineage/sampling.rs @@ -26,7 +26,9 @@ pub fn sample_and_collapse(tree: &mut LineageTree, n_sample: u32, rng: &mut Rng) let mut abundance: HashMap = HashMap::new(); for _ in 0..n_sample { - let idx = (rng.next_u64() % (leaf_ids.len() as u64)) as usize; + // Unbiased uniform index (Lemire), matching the engine's RNG convention; + // avoids the modulo bias of `next_u64() % len`. + let idx = rng.range_u32(leaf_ids.len() as u32) as usize; let leaf_id = leaf_ids[idx]; let genotype = tree.get(leaf_id).unwrap().genotype.clone(); let rep = *rep_by_genotype.entry(genotype).or_insert(leaf_id); diff --git a/engine_rs/src/lineage/tree.rs b/engine_rs/src/lineage/tree.rs index 00065f8..a303727 100644 --- a/engine_rs/src/lineage/tree.rs +++ b/engine_rs/src/lineage/tree.rs @@ -108,13 +108,24 @@ impl LineageTree { } /// Nodes with no children (tips), in ascending id order. + /// + /// O(n): a single pass marks which ids are parents (relying on the + /// `id == index` invariant, same as the rest of the module), then filters. + /// Avoids the previous O(n²) of calling `children_of` per node. pub fn leaves(&self) -> Vec<&LineageNode> { - let mut v: Vec<&LineageNode> = self.nodes + let mut has_child = vec![false; self.nodes.len()]; + for n in &self.nodes { + if let Some(p) = n.parent_id { + if (p as usize) < has_child.len() { + has_child[p as usize] = true; + } + } + } + // Arena order == ascending id under the id==index invariant. + self.nodes .iter() - .filter(|n| self.children_of(n.id).is_empty()) - .collect(); - v.sort_unstable_by_key(|n| n.id); - v + .filter(|n| !has_child.get(n.id as usize).copied().unwrap_or(false)) + .collect() } } diff --git a/engine_rs/src/python/lineage.rs b/engine_rs/src/python/lineage.rs index dde852a..483b498 100644 --- a/engine_rs/src/python/lineage.rs +++ b/engine_rs/src/python/lineage.rs @@ -173,6 +173,24 @@ pub(crate) fn simulate_lineage( if n_sample == 0 { return Err(PyValueError::new_err("simulate_lineage: n_sample must be > 0")); } + if !(lambda_base.is_finite() && lambda_base >= 0.0) { + return Err(PyValueError::new_err(format!( + "simulate_lineage: lambda_base must be finite and >= 0, got {lambda_base}" + ))); + } + if !(lambda_mut.is_finite() && lambda_mut >= 0.0) { + return Err(PyValueError::new_err(format!( + "simulate_lineage: lambda_mut must be finite and >= 0, got {lambda_mut}" + ))); + } + // Cap generations: `to_newick` recurses to a depth equal to the generation + // count, so an unbounded value could overflow the stack across the FFI + // boundary. 1000 is far beyond any biological germinal-center reaction. + if max_generations > 1000 { + return Err(PyValueError::new_err(format!( + "simulate_lineage: max_generations must be <= 1000, got {max_generations}" + ))); + } // Lengths and value ranges are now guaranteed, so S5FKernel::new cannot panic. let kernel = S5FKernel::new(mutability, substitution); diff --git a/tests/test_lineage_engine.py b/tests/test_lineage_engine.py index fccfb01..4a012ee 100644 --- a/tests/test_lineage_engine.py +++ b/tests/test_lineage_engine.py @@ -76,3 +76,22 @@ def test_simulate_lineage_rejects_zero_n_sample(): mut, sub = _kernel() with pytest.raises(ValueError): _engine.simulate_lineage(founder, mut, sub, 0.05, 1.5, 0.0, 8, 500, 0, 0) + + +def test_simulate_lineage_rejects_nonfinite_lambda(): + founder = _founder() + mut, sub = _kernel() + # NaN/inf/negative lambda_base must raise, not silently return a founder-only tree. + for bad in (float("nan"), float("inf"), -1.0): + with pytest.raises(ValueError): + _engine.simulate_lineage(founder, mut, sub, 0.05, bad, 0.0, 8, 500, 30, 0) + # lambda_mut is validated too. + with pytest.raises(ValueError): + _engine.simulate_lineage(founder, mut, sub, 0.05, 1.5, float("nan"), 8, 500, 30, 0) + + +def test_simulate_lineage_rejects_excessive_max_generations(): + founder = _founder() + mut, sub = _kernel() + with pytest.raises(ValueError): + _engine.simulate_lineage(founder, mut, sub, 0.05, 1.5, 0.0, 5000, 500, 30, 0) From f6481f018dce3763fdbc6186ab5a3b573aaeca7b Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 16:16:28 +0300 Subject: [PATCH 28/59] feat(lineage): add per-node affinity field + node-table column --- engine_rs/src/lineage/branching.rs | 2 ++ engine_rs/src/lineage/export.rs | 28 ++++++++++++++-------------- engine_rs/src/lineage/sampling.rs | 8 ++++---- engine_rs/src/lineage/tree.rs | 12 +++++++----- 4 files changed, 27 insertions(+), 23 deletions(-) diff --git a/engine_rs/src/lineage/branching.rs b/engine_rs/src/lineage/branching.rs index 8a93822..c7ca14e 100644 --- a/engine_rs/src/lineage/branching.rs +++ b/engine_rs/src/lineage/branching.rs @@ -71,6 +71,7 @@ fn grow_core( generation: 0, genotype: genotype_of(founder), mutations_from_parent: 0, + affinity: 0.0, abundance: 0, observed: false, }); @@ -114,6 +115,7 @@ fn grow_core( generation: gen, genotype: genotype_of(&child_sim), mutations_from_parent: muts, + affinity: 0.0, abundance: 0, observed: false, }); diff --git a/engine_rs/src/lineage/export.rs b/engine_rs/src/lineage/export.rs index 647c1c0..f94bce2 100644 --- a/engine_rs/src/lineage/export.rs +++ b/engine_rs/src/lineage/export.rs @@ -8,11 +8,11 @@ use super::tree::LineageTree; /// Tab-separated node table, one row per node plus a header. `parent_id` is /// `NA` for the root. Columns: -/// `node_id, parent_id, generation, mutations_from_parent, abundance, observed, sequence`. +/// `node_id, parent_id, generation, mutations_from_parent, abundance, observed, affinity, sequence`. pub fn to_node_table_tsv(tree: &LineageTree) -> String { let mut out = String::new(); out.push_str( - "node_id\tparent_id\tgeneration\tmutations_from_parent\tabundance\tobserved\tsequence\n", + "node_id\tparent_id\tgeneration\tmutations_from_parent\tabundance\tobserved\taffinity\tsequence\n", ); for n in &tree.nodes { let parent = match n.parent_id { @@ -22,8 +22,8 @@ pub fn to_node_table_tsv(tree: &LineageTree) -> String { let seq = String::from_utf8_lossy(&n.genotype); let _ = writeln!( out, - "{}\t{}\t{}\t{}\t{}\t{}\t{}", - n.id, parent, n.generation, n.mutations_from_parent, n.abundance, n.observed, seq + "{}\t{}\t{}\t{}\t{}\t{}\t{:.4}\t{}", + n.id, parent, n.generation, n.mutations_from_parent, n.abundance, n.observed, n.affinity, seq ); } out @@ -140,10 +140,10 @@ mod tests { fn sample_tree() -> LineageTree { LineageTree { nodes: vec![ - LineageNode { id: 0, parent_id: None, generation: 0, genotype: b"AAAA".to_vec(), mutations_from_parent: 0, abundance: 0, observed: false }, - LineageNode { id: 1, parent_id: Some(0), generation: 1, genotype: b"AAAC".to_vec(), mutations_from_parent: 1, abundance: 2, observed: true }, - LineageNode { id: 2, parent_id: Some(0), generation: 1, genotype: b"AAAG".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, - LineageNode { id: 3, parent_id: Some(1), generation: 2, genotype: b"ATAC".to_vec(), mutations_from_parent: 1, abundance: 1, observed: true }, + LineageNode { id: 0, parent_id: None, generation: 0, genotype: b"AAAA".to_vec(), mutations_from_parent: 0, affinity: 0.0, abundance: 0, observed: false }, + LineageNode { id: 1, parent_id: Some(0), generation: 1, genotype: b"AAAC".to_vec(), mutations_from_parent: 1, affinity: 0.0, abundance: 2, observed: true }, + LineageNode { id: 2, parent_id: Some(0), generation: 1, genotype: b"AAAG".to_vec(), mutations_from_parent: 1, affinity: 0.0, abundance: 0, observed: false }, + LineageNode { id: 3, parent_id: Some(1), generation: 2, genotype: b"ATAC".to_vec(), mutations_from_parent: 1, affinity: 0.0, abundance: 1, observed: true }, ], } } @@ -167,12 +167,12 @@ mod tests { assert_eq!(lines.len(), 5); // 1 header + 4 nodes assert_eq!( lines[0], - "node_id\tparent_id\tgeneration\tmutations_from_parent\tabundance\tobserved\tsequence" + "node_id\tparent_id\tgeneration\tmutations_from_parent\tabundance\tobserved\taffinity\tsequence" ); - assert_eq!(lines[1], "0\tNA\t0\t0\t0\tfalse\tAAAA"); - assert_eq!(lines[2], "1\t0\t1\t1\t2\ttrue\tAAAC"); - assert_eq!(lines[3], "2\t0\t1\t1\t0\tfalse\tAAAG"); - assert_eq!(lines[4], "3\t1\t2\t1\t1\ttrue\tATAC"); + assert_eq!(lines[1], "0\tNA\t0\t0\t0\tfalse\t0.0000\tAAAA"); + assert_eq!(lines[2], "1\t0\t1\t1\t2\ttrue\t0.0000\tAAAC"); + assert_eq!(lines[3], "2\t0\t1\t1\t0\tfalse\t0.0000\tAAAG"); + assert_eq!(lines[4], "3\t1\t2\t1\t1\ttrue\t0.0000\tATAC"); } #[test] @@ -195,7 +195,7 @@ mod tests { let tree = LineageTree { nodes: vec![LineageNode { id: 0, parent_id: None, generation: 0, genotype: b"AAAA".to_vec(), - mutations_from_parent: 0, abundance: 1, observed: true, + mutations_from_parent: 0, affinity: 0.0, abundance: 1, observed: true, }], }; assert_eq!(to_newick(&tree), "node0;"); diff --git a/engine_rs/src/lineage/sampling.rs b/engine_rs/src/lineage/sampling.rs index 5af60b6..adbd6f7 100644 --- a/engine_rs/src/lineage/sampling.rs +++ b/engine_rs/src/lineage/sampling.rs @@ -58,10 +58,10 @@ mod tests { fn tree_with_dupes() -> LineageTree { LineageTree { nodes: vec![ - LineageNode { id: 0, parent_id: None, generation: 0, genotype: b"AAAA".to_vec(), mutations_from_parent: 0, abundance: 0, observed: false }, - LineageNode { id: 1, parent_id: Some(0), generation: 1, genotype: b"AAAC".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, - LineageNode { id: 2, parent_id: Some(0), generation: 1, genotype: b"AAAC".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, - LineageNode { id: 3, parent_id: Some(1), generation: 2, genotype: b"AAGC".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, + LineageNode { id: 0, parent_id: None, generation: 0, genotype: b"AAAA".to_vec(), mutations_from_parent: 0, affinity: 0.0, abundance: 0, observed: false }, + LineageNode { id: 1, parent_id: Some(0), generation: 1, genotype: b"AAAC".to_vec(), mutations_from_parent: 1, affinity: 0.0, abundance: 0, observed: false }, + LineageNode { id: 2, parent_id: Some(0), generation: 1, genotype: b"AAAC".to_vec(), mutations_from_parent: 1, affinity: 0.0, abundance: 0, observed: false }, + LineageNode { id: 3, parent_id: Some(1), generation: 2, genotype: b"AAGC".to_vec(), mutations_from_parent: 1, affinity: 0.0, abundance: 0, observed: false }, ], } } diff --git a/engine_rs/src/lineage/tree.rs b/engine_rs/src/lineage/tree.rs index a303727..188a3f5 100644 --- a/engine_rs/src/lineage/tree.rs +++ b/engine_rs/src/lineage/tree.rs @@ -7,7 +7,7 @@ //! genotype-collapse and downstream export. /// One cell in a clonal lineage. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct LineageNode { /// Stable index of this node within `LineageTree::nodes` (also its arena position). pub id: u32, @@ -20,6 +20,8 @@ pub struct LineageNode { /// Mutations introduced on the edge from the parent to this node /// (the number of substitutions on the parent→child edge; 0 for the root). pub mutations_from_parent: u32, + /// Affinity of this cell to the target antigen (0.0 = neutral / not computed). + pub affinity: f64, /// Observation count after sampling + genotype-collapse. 0 until sampled. pub abundance: u32, /// Whether this node was observed: the representative node a sampled @@ -137,10 +139,10 @@ mod tests { fn hand_tree() -> LineageTree { LineageTree { nodes: vec![ - LineageNode { id: 0, parent_id: None, generation: 0, genotype: b"AAAA".to_vec(), mutations_from_parent: 0, abundance: 0, observed: false }, - LineageNode { id: 1, parent_id: Some(0), generation: 1, genotype: b"AAAC".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, - LineageNode { id: 2, parent_id: Some(0), generation: 1, genotype: b"AAAG".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, - LineageNode { id: 3, parent_id: Some(1), generation: 2, genotype: b"AATC".to_vec(), mutations_from_parent: 1, abundance: 0, observed: false }, + LineageNode { id: 0, parent_id: None, generation: 0, genotype: b"AAAA".to_vec(), mutations_from_parent: 0, affinity: 0.0, abundance: 0, observed: false }, + LineageNode { id: 1, parent_id: Some(0), generation: 1, genotype: b"AAAC".to_vec(), mutations_from_parent: 1, affinity: 0.0, abundance: 0, observed: false }, + LineageNode { id: 2, parent_id: Some(0), generation: 1, genotype: b"AAAG".to_vec(), mutations_from_parent: 1, affinity: 0.0, abundance: 0, observed: false }, + LineageNode { id: 3, parent_id: Some(1), generation: 2, genotype: b"AATC".to_vec(), mutations_from_parent: 1, affinity: 0.0, abundance: 0, observed: false }, ], } } From d2013344ce8a3dd57b7f78f57a546fe8c218dfb6 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 16:28:29 +0300 Subject: [PATCH 29/59] feat(lineage): AffinityModel (affinity_value/fitness) + sim_to_aa translation --- engine_rs/src/lineage/affinity.rs | 114 ++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/engine_rs/src/lineage/affinity.rs b/engine_rs/src/lineage/affinity.rs index c739d9c..22498b9 100644 --- a/engine_rs/src/lineage/affinity.rs +++ b/engine_rs/src/lineage/affinity.rs @@ -1,6 +1,7 @@ //! Affinity model for clonal selection: BLOSUM62-weighted, region-weighted //! amino-acid distance to a target antigen, and target generation. +use crate::ir::{compute_codon_rail, Simulation}; use crate::rng::Rng; /// 20 standard amino acids in the BLOSUM62 matrix's row/column order. @@ -84,11 +85,124 @@ pub fn make_mature_target(naive_aa: &[u8], m: u32, rng: &mut Rng) -> Vec { target } +/// Translate a `Simulation` to its amino-acid sequence by concatenating the +/// in-frame translation of each region (respecting each region's frame phase), +/// in biological order. Used to score a cell's affinity to a target antigen. +pub fn sim_to_aa(sim: &Simulation) -> Vec { + let mut aa = Vec::new(); + for region in sim.sequence.regions.iter() { + let rail = compute_codon_rail(region, &sim.pool); + aa.extend_from_slice(&rail.amino_acids); + } + aa +} + +/// Affinity-selection model: maps a cell's amino-acid sequence to an affinity in +/// (0, 1] (1 = identical to target) and to a fitness multiplier for its offspring +/// rate. `selection_strength == 0` makes fitness identically 1 (neutral). +#[derive(Clone, Debug)] +pub struct AffinityModel { + target_aa: Vec, + aa_weights: Vec, + beta: f64, + selection_strength: f64, + /// Affinity of the founder; fitness is measured relative to this baseline so + /// the founder has fitness ~1 and cells that improve on it exceed 1. + founder_affinity: f64, +} + +impl AffinityModel { + /// Build a model. `founder_aa` is the naive founder's aa sequence; its + /// affinity becomes the fitness baseline. + pub fn new( + target_aa: Vec, + aa_weights: Vec, + beta: f64, + selection_strength: f64, + founder_aa: &[u8], + ) -> Self { + let founder_affinity = + (-beta * weighted_aa_distance(founder_aa, &target_aa, &aa_weights)).exp(); + Self { + target_aa, + aa_weights, + beta, + selection_strength, + founder_affinity, + } + } + + /// Affinity in (0, 1]: `exp(-beta * region-weighted BLOSUM distance to target)`. + pub fn affinity_value(&self, aa: &[u8]) -> f64 { + (-self.beta * weighted_aa_distance(aa, &self.target_aa, &self.aa_weights)).exp() + } + + /// Fitness multiplier for offspring rate: `1 + strength * (affinity - founder_affinity)`, + /// clamped at 0. `selection_strength == 0` ⇒ always 1 (neutral). + pub fn fitness(&self, aa: &[u8]) -> f64 { + (1.0 + self.selection_strength * (self.affinity_value(aa) - self.founder_affinity)).max(0.0) + } +} + #[cfg(test)] mod tests { use super::*; + use crate::ir::{Nucleotide, NucHandle, Region, Segment, Simulation}; use crate::rng::Rng; + fn aa_founder(seq: &[u8]) -> Simulation { + let mut sim = Simulation::new(); + for (i, b) in seq.iter().enumerate() { + let (next, _) = sim.with_nucleotide_pushed( + Nucleotide::germline(*b, i as u16, Segment::V)); + sim = next; + } + sim.with_region_added(Region::new(Segment::V, NucHandle::new(0), NucHandle::new(seq.len() as u32))) + } + + #[test] + fn sim_to_aa_translates_in_frame() { + // 8 'A' bases -> codons AAA, AAA (last 2 ignored) -> "KK" (Lys, Lys) + let sim = aa_founder(b"AAAAAAAA"); + assert_eq!(sim_to_aa(&sim), b"KK".to_vec()); + } + + #[test] + fn affinity_value_is_one_at_target() { + let m = AffinityModel::new(b"KK".to_vec(), vec![1.0; 2], 1.0, 1.0, b"KK"); + assert!((m.affinity_value(b"KK") - 1.0).abs() < 1e-9); + } + + #[test] + fn affinity_value_decreases_with_distance() { + let m = AffinityModel::new(b"KK".to_vec(), vec![1.0; 2], 1.0, 1.0, b"KK"); + let near = m.affinity_value(b"KK"); + let far = m.affinity_value(b"WW"); + assert!(far < near, "far {far} should be < near {near}"); + assert!(far > 0.0); + } + + #[test] + fn fitness_is_neutral_when_strength_zero() { + // selection_strength = 0 => fitness == 1.0 for ANY sequence + let m = AffinityModel::new(b"KK".to_vec(), vec![1.0; 2], 1.0, 0.0, b"NN"); + assert!((m.fitness(b"KK") - 1.0).abs() < 1e-9); + assert!((m.fitness(b"WW") - 1.0).abs() < 1e-9); + assert!((m.fitness(b"NN") - 1.0).abs() < 1e-9); + } + + #[test] + fn fitness_rewards_closer_than_founder_penalizes_farther() { + // founder = "NN" (far from target "KK"); strength 1. + let m = AffinityModel::new(b"KK".to_vec(), vec![1.0; 2], 1.0, 1.0, b"NN"); + // a cell AT the target is fitter than the founder baseline (fitness > 1) + assert!(m.fitness(b"KK") > 1.0); + // a cell equal to the founder has fitness exactly 1 (baseline) + assert!((m.fitness(b"NN") - 1.0).abs() < 1e-9); + // fitness never goes negative + assert!(m.fitness(b"WWWWWWWW") >= 0.0); + } + #[test] fn blosum62_known_entries() { assert_eq!(blosum62(b'A', b'A'), 4); From 723343b300e2c3aebd2144d2e14b8e36b4a04a85 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 16:33:20 +0300 Subject: [PATCH 30/59] feat(lineage): affinity-modulated offspring in grow_core + family/affinity entries --- engine_rs/src/lineage/affinity.rs | 7 ++- engine_rs/src/lineage/branching.rs | 92 +++++++++++++++++++++++++++--- engine_rs/src/lineage/family.rs | 16 +++++- engine_rs/src/lineage/mod.rs | 5 +- 4 files changed, 109 insertions(+), 11 deletions(-) diff --git a/engine_rs/src/lineage/affinity.rs b/engine_rs/src/lineage/affinity.rs index 22498b9..cefa088 100644 --- a/engine_rs/src/lineage/affinity.rs +++ b/engine_rs/src/lineage/affinity.rs @@ -137,10 +137,15 @@ impl AffinityModel { (-self.beta * weighted_aa_distance(aa, &self.target_aa, &self.aa_weights)).exp() } + /// Fitness from an already-computed affinity value (avoids re-translation). + pub fn fitness_from_affinity(&self, affinity_value: f64) -> f64 { + (1.0 + self.selection_strength * (affinity_value - self.founder_affinity)).max(0.0) + } + /// Fitness multiplier for offspring rate: `1 + strength * (affinity - founder_affinity)`, /// clamped at 0. `selection_strength == 0` ⇒ always 1 (neutral). pub fn fitness(&self, aa: &[u8]) -> f64 { - (1.0 + self.selection_strength * (self.affinity_value(aa) - self.founder_affinity)).max(0.0) + self.fitness_from_affinity(self.affinity_value(aa)) } } diff --git a/engine_rs/src/lineage/branching.rs b/engine_rs/src/lineage/branching.rs index c7ca14e..25b3df7 100644 --- a/engine_rs/src/lineage/branching.rs +++ b/engine_rs/src/lineage/branching.rs @@ -5,6 +5,7 @@ use crate::pass::{Pass, PassContext}; use crate::rng::Rng; use crate::trace::Trace; +use super::affinity::{sim_to_aa, AffinityModel}; use super::poisson::poisson_sample; use super::tree::{LineageNode, LineageTree}; @@ -55,23 +56,31 @@ fn mutate_child(parent: &Simulation, mutator: &dyn Pass, child_seed: u64) -> Sim /// Core generation-synchronous growth. With `mutator = None`, children are exact /// clones (and NO extra RNG is consumed). With `Some(m)`, each division draws a -/// deterministic sub-seed and applies `m`. Returns (tree, peak_live_population). +/// deterministic sub-seed and applies `m`. With `affinity = Some(model)`, each +/// cell's offspring rate is modulated by the model's fitness; `None` leaves the +/// rate unchanged (byte-identical to the pre-affinity path). Returns +/// (tree, peak_live_population). fn grow_core( founder: &Simulation, params: &BranchingParams, mutator: Option<&dyn Pass>, + affinity: Option<&AffinityModel>, ) -> (LineageTree, usize) { let mut nodes: Vec = Vec::new(); let mut sims: Vec = Vec::new(); let mut rng = Rng::new(params.seed); + let root_affinity = affinity + .map(|m| m.affinity_value(&sim_to_aa(founder))) + .unwrap_or(0.0); + nodes.push(LineageNode { id: 0, parent_id: None, generation: 0, genotype: genotype_of(founder), mutations_from_parent: 0, - affinity: 0.0, + affinity: root_affinity, abundance: 0, observed: false, }); @@ -92,7 +101,11 @@ fn grow_core( let mut next_live: Vec = Vec::new(); 'generation: for &parent_id in &live { - let k = poisson_sample(&mut rng, eff_lambda); + let cell_lambda = match affinity { + Some(m) => eff_lambda * m.fitness_from_affinity(nodes[parent_id as usize].affinity), + None => eff_lambda, + }; + let k = poisson_sample(&mut rng, cell_lambda); for _ in 0..k { // Hard cap: a Poisson draw can still overshoot near saturation. if next_live.len() >= params.n_max as usize { @@ -109,13 +122,16 @@ fn grow_core( let muts = child_sim .mutation_count .saturating_sub(parent_mut_count); + let child_affinity = affinity + .map(|m| m.affinity_value(&sim_to_aa(&child_sim))) + .unwrap_or(0.0); nodes.push(LineageNode { id: next_id, parent_id: Some(parent_id), generation: gen, genotype: genotype_of(&child_sim), mutations_from_parent: muts, - affinity: 0.0, + affinity: child_affinity, abundance: 0, observed: false, }); @@ -140,13 +156,24 @@ pub fn grow_lineage( params: &BranchingParams, mutator: &dyn Pass, ) -> LineageTree { - grow_core(founder, params, Some(mutator)).0 + grow_core(founder, params, Some(mutator), None).0 } /// Grow the lineage TOPOLOGY only (children are exact clones of their parent /// `Simulation`; no mutation — a later task layers mutation on top). pub fn grow_topology(founder: &Simulation, params: &BranchingParams) -> LineageTree { - grow_core(founder, params, None).0 + grow_core(founder, params, None, None).0 +} + +/// Grow a clonal lineage with per-division mutation AND affinity selection. +/// Deterministic for `params.seed`. +pub fn grow_lineage_with_affinity( + founder: &Simulation, + params: &BranchingParams, + mutator: &dyn Pass, + model: &AffinityModel, +) -> LineageTree { + grow_core(founder, params, Some(mutator), Some(model)).0 } #[cfg(test)] @@ -217,7 +244,7 @@ mod tests { n_sample: 10, seed: 7, }; - let (_tree, peak_live) = grow_core(&founder(), ¶ms, None); + let (_tree, peak_live) = grow_core(&founder(), ¶ms, None, None); // hard cap: live population never exceeds n_max assert!(peak_live <= params.n_max as usize, "peak live {peak_live} exceeded n_max {}", params.n_max); @@ -276,4 +303,55 @@ mod tests { assert_eq!(x.mutations_from_parent, y.mutations_from_parent); } } + + #[test] + fn affinity_run_populates_node_affinities_and_root_matches() { + use crate::lineage::affinity::{sim_to_aa, AffinityModel}; + let f = founder(); + let founder_aa = sim_to_aa(&f); + let w = vec![1.0; founder_aa.len().max(1)]; + let model = AffinityModel::new(b"W".to_vec(), w, 1.0, 1.0, &founder_aa); + let params = BranchingParams { lambda_base:1.2, lambda_mut:0.0, max_generations:4, n_max:500, n_sample:10, seed:11 }; + let tree = grow_lineage_with_affinity(&f, ¶ms, &two_mut_mutator(), &model); + assert!((tree.root().affinity - model.affinity_value(&founder_aa)).abs() < 1e-9); + for n in &tree.nodes { + assert!(n.affinity > 0.0 && n.affinity <= 1.0 + 1e-9, "affinity out of range: {}", n.affinity); + } + } + + #[test] + fn affinity_strength_zero_matches_neutral_topology() { + use crate::lineage::affinity::{sim_to_aa, AffinityModel}; + let f = founder(); + let founder_aa = sim_to_aa(&f); + let w = vec![1.0; founder_aa.len().max(1)]; + // selection_strength = 0 => fitness identically 1 => identical topology/genotypes to neutral + let model = AffinityModel::new(b"W".to_vec(), w, 1.0, 0.0, &founder_aa); + let params = BranchingParams { lambda_base:1.2, lambda_mut:0.0, max_generations:4, n_max:500, n_sample:10, seed:11 }; + let with_aff = grow_lineage_with_affinity(&f, ¶ms, &two_mut_mutator(), &model); + let neutral = grow_lineage(&f, ¶ms, &two_mut_mutator()); + assert_eq!(with_aff.len(), neutral.len()); + for (a, b) in with_aff.nodes.iter().zip(neutral.nodes.iter()) { + assert_eq!(a.genotype, b.genotype); + assert_eq!(a.parent_id, b.parent_id); + assert_eq!(a.generation, b.generation); + assert_eq!(a.mutations_from_parent, b.mutations_from_parent); + } + } + + #[test] + fn affinity_growth_is_deterministic() { + use crate::lineage::affinity::{sim_to_aa, AffinityModel}; + let f = founder(); + let founder_aa = sim_to_aa(&f); + let mk = || AffinityModel::new(b"W".to_vec(), vec![1.0; founder_aa.len().max(1)], 1.0, 2.0, &founder_aa); + let params = BranchingParams { lambda_base:1.5, lambda_mut:0.0, max_generations:5, n_max:500, n_sample:10, seed:21 }; + let a = grow_lineage_with_affinity(&f, ¶ms, &two_mut_mutator(), &mk()); + let b = grow_lineage_with_affinity(&f, ¶ms, &two_mut_mutator(), &mk()); + assert_eq!(a.len(), b.len()); + for (x, y) in a.nodes.iter().zip(b.nodes.iter()) { + assert_eq!(x.genotype, y.genotype); + assert!((x.affinity - y.affinity).abs() < 1e-12); + } + } } diff --git a/engine_rs/src/lineage/family.rs b/engine_rs/src/lineage/family.rs index c31f5cd..dfb29eb 100644 --- a/engine_rs/src/lineage/family.rs +++ b/engine_rs/src/lineage/family.rs @@ -4,7 +4,8 @@ use crate::ir::Simulation; use crate::pass::Pass; use crate::rng::Rng; -use super::branching::{grow_lineage, BranchingParams}; +use super::affinity::AffinityModel; +use super::branching::{grow_lineage, grow_lineage_with_affinity, BranchingParams}; use super::sampling::sample_and_collapse; use super::tree::LineageTree; @@ -28,6 +29,19 @@ pub fn simulate_family( tree } +/// Grow + sample a clonal family with affinity selection. +pub fn simulate_family_with_affinity( + founder: &Simulation, + params: &BranchingParams, + mutator: &dyn Pass, + model: &AffinityModel, +) -> LineageTree { + let mut tree = grow_lineage_with_affinity(founder, params, mutator, model); + let mut sample_rng = Rng::new(params.seed ^ SAMPLE_SEED_SALT); + sample_and_collapse(&mut tree, params.n_sample, &mut sample_rng); + tree +} + #[cfg(test)] mod tests { use super::*; diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index fc6b7bb..5e072e4 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -27,8 +27,9 @@ pub mod family; pub mod export; pub mod affinity; +pub use affinity::{sim_to_aa, AffinityModel}; pub use tree::{LineageNode, LineageTree}; -pub use branching::{grow_lineage, grow_topology, BranchingParams}; +pub use branching::{grow_lineage, grow_lineage_with_affinity, grow_topology, BranchingParams}; pub use sampling::sample_and_collapse; -pub use family::simulate_family; +pub use family::{simulate_family, simulate_family_with_affinity}; pub use export::{to_fasta, to_newick, to_node_table_tsv}; From fa4512877c97351cd32eab45f7f4b0d30d40575e Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 16:44:57 +0300 Subject: [PATCH 31/59] feat(lineage): expose affinity selection in simulate_lineage (Python) Co-Authored-By: Claude Opus 4.8 (1M context) --- engine_rs/src/python/lineage.rs | 50 +++++++++++++++++++++++-- tests/test_lineage_engine.py | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/engine_rs/src/python/lineage.rs b/engine_rs/src/python/lineage.rs index 483b498..a5720ff 100644 --- a/engine_rs/src/python/lineage.rs +++ b/engine_rs/src/python/lineage.rs @@ -5,7 +5,9 @@ use pyo3::prelude::*; use crate::lineage::export::{to_fasta, to_newick, to_node_table_tsv}; use crate::lineage::tree::{LineageNode, LineageTree}; -use crate::lineage::{simulate_family, BranchingParams}; +use crate::lineage::{simulate_family, simulate_family_with_affinity, sim_to_aa, AffinityModel, BranchingParams}; +use crate::lineage::affinity::make_mature_target; +use crate::rng::Rng; use crate::passes::S5FMutationPass; use crate::s5f::S5FKernel; @@ -43,6 +45,10 @@ impl PyLineageNode { fn observed(&self) -> bool { self.inner.observed } + #[getter] + fn affinity(&self) -> f64 { + self.inner.affinity + } /// Nucleotide sequence (pool bases) as a string. #[getter] fn sequence(&self) -> String { @@ -123,7 +129,8 @@ impl PyLineageTree { #[pyfunction] #[pyo3(signature = ( founder, mutability, substitution, rate, - lambda_base, lambda_mut, max_generations, n_max, n_sample, seed + lambda_base, lambda_mut, max_generations, n_max, n_sample, seed, + selection_strength=0.0, beta=1.0, target_aa=None, mature_substitutions=5 ))] #[allow(clippy::too_many_arguments)] pub(crate) fn simulate_lineage( @@ -137,6 +144,10 @@ pub(crate) fn simulate_lineage( n_max: u32, n_sample: u32, seed: u64, + selection_strength: f64, + beta: f64, + target_aa: Option, + mature_substitutions: u32, ) -> PyResult { use pyo3::exceptions::PyValueError; @@ -203,6 +214,39 @@ pub(crate) fn simulate_lineage( n_sample, seed, }; - let tree = simulate_family(&founder.inner, ¶ms, &mutator); + + // Validate affinity params. + if !(beta.is_finite() && beta >= 0.0) { + return Err(PyValueError::new_err(format!( + "simulate_lineage: beta must be finite and >= 0, got {beta}" + ))); + } + if !(selection_strength.is_finite() && selection_strength >= 0.0) { + return Err(PyValueError::new_err(format!( + "simulate_lineage: selection_strength must be finite and >= 0, got {selection_strength}" + ))); + } + + // Neutral fast path: no selection and no explicit target → byte-identical to + // the pre-affinity behavior. + if selection_strength == 0.0 && target_aa.is_none() { + let tree = simulate_family(&founder.inner, ¶ms, &mutator); + return Ok(PyLineageTree::new(tree)); + } + + // Build the affinity model. Uniform per-position weights for v1 (the model + // accepts arbitrary weights; CDR3 region-weighting is a follow-up). + let founder_aa = sim_to_aa(&founder.inner); + let target = match target_aa { + Some(s) => s.into_bytes(), + None => { + // Deterministic auto "mature" target derived from the seed. + let mut trng = Rng::new(seed ^ 0x7461_7267_6574_0001); // "target\0\1" + make_mature_target(&founder_aa, mature_substitutions, &mut trng) + } + }; + let weights = vec![1.0; founder_aa.len()]; + let model = AffinityModel::new(target, weights, beta, selection_strength, &founder_aa); + let tree = simulate_family_with_affinity(&founder.inner, ¶ms, &mutator, &model); Ok(PyLineageTree::new(tree)) } diff --git a/tests/test_lineage_engine.py b/tests/test_lineage_engine.py index 4a012ee..f7bcb88 100644 --- a/tests/test_lineage_engine.py +++ b/tests/test_lineage_engine.py @@ -95,3 +95,68 @@ def test_simulate_lineage_rejects_excessive_max_generations(): mut, sub = _kernel() with pytest.raises(ValueError): _engine.simulate_lineage(founder, mut, sub, 0.05, 1.5, 0.0, 5000, 500, 30, 0) + + +def test_affinity_node_getter_and_neutral_default(): + founder = _founder() + mut, sub = _kernel() + # Default call (no affinity args) is the neutral path; affinity getter exists. + tree = _engine.simulate_lineage(founder, mut, sub, 0.05, 1.5, 0.0, 8, 500, 30, 2024) + nodes = tree.nodes() + assert all(hasattr(n, "affinity") for n in nodes) + # neutral path leaves affinity at 0.0 + assert all(n.affinity == 0.0 for n in nodes) + + +def test_affinity_selection_raises_mean_affinity(): + founder = _founder() + mut, sub = _kernel() + founder_aa_len = 100 # target length need not match; distance uses min length + # Use "A" (alanine) target with small beta so exp(-beta*d) stays in (0,1) + # and doesn't underflow. "W"*100 + beta=1.0 collapses to denorm for IgH + # sequences, making neutral and selected indistinguishable. + target = "A" * founder_aa_len # a fixed explicit target both runs share + common = dict(target_aa=target, beta=0.001, mature_substitutions=5) + # neutral: selection_strength = 0 (affinities populated but not selected on) + neutral = _engine.simulate_lineage( + founder, mut, sub, 0.1, 1.6, 0.0, 12, 800, 60, 4242, + selection_strength=0.0, **common, + ) + # strong selection toward the same target, same seed + selected = _engine.simulate_lineage( + founder, mut, sub, 0.1, 1.6, 0.0, 12, 800, 60, 4242, + selection_strength=50.0, **common, + ) + + def mean_aff(tree): + ns = tree.nodes() + return sum(n.affinity for n in ns) / max(1, len(ns)) + + # selection should enrich high-affinity cells → higher mean affinity + assert mean_aff(selected) > mean_aff(neutral), ( + f"selected {mean_aff(selected)} !> neutral {mean_aff(neutral)}" + ) + # affinities are populated (in (0,1]) when a model is active + assert all(0.0 < n.affinity <= 1.0 + 1e-9 for n in selected.nodes()) + + +def test_affinity_auto_target_is_deterministic(): + founder = _founder() + mut, sub = _kernel() + a = _engine.simulate_lineage(founder, mut, sub, 0.1, 1.5, 0.0, 10, 500, 40, 7, + selection_strength=5.0) + b = _engine.simulate_lineage(founder, mut, sub, 0.1, 1.5, 0.0, 10, 500, 40, 7, + selection_strength=5.0) + assert a.to_newick() == b.to_newick() + assert [n.affinity for n in a.nodes()] == [n.affinity for n in b.nodes()] + + +def test_affinity_rejects_bad_params(): + founder = _founder() + mut, sub = _kernel() + with pytest.raises(ValueError): + _engine.simulate_lineage(founder, mut, sub, 0.05, 1.5, 0.0, 8, 500, 30, 0, + selection_strength=float("nan")) + with pytest.raises(ValueError): + _engine.simulate_lineage(founder, mut, sub, 0.05, 1.5, 0.0, 8, 500, 30, 0, + beta=-1.0) From 264c8d8b95f3c1fd3ec429eb8dd9eca7337d870e Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 16:52:01 +0300 Subject: [PATCH 32/59] feat(lineage): heavy-tailed clone-size distributions (power-law, lognormal) --- engine_rs/src/lineage/clone_size.rs | 109 ++++++++++++++++++++++++++++ engine_rs/src/lineage/mod.rs | 3 + 2 files changed, 112 insertions(+) create mode 100644 engine_rs/src/lineage/clone_size.rs diff --git a/engine_rs/src/lineage/clone_size.rs b/engine_rs/src/lineage/clone_size.rs new file mode 100644 index 0000000..72607d5 --- /dev/null +++ b/engine_rs/src/lineage/clone_size.rs @@ -0,0 +1,109 @@ +//! Clone-size distributions for repertoire composition. T-cell (and the +//! singleton tail of B-cell) repertoires have heavy-tailed clone-size +//! distributions; this module samples integer clone sizes >= 1. + +use crate::rng::Rng; + +/// A heavy-tailed clone-size distribution. Sizes are integers in `[1, x_max]`. +#[derive(Clone, Debug)] +pub enum CloneSizeDist { + /// Truncated discrete power law P(size=k) ∝ k^(-exponent), k in [1, x_max]. + /// `exponent` ~2–3 is typical for TCR repertoires. + PowerLaw { exponent: f64, x_max: u32 }, + /// Log-normal sizes: round(exp(mu + sigma*Z)), clamped to [1, x_max]. + LogNormal { mu: f64, sigma: f64, x_max: u32 }, +} + +impl Default for CloneSizeDist { + fn default() -> Self { + CloneSizeDist::PowerLaw { + exponent: 2.0, + x_max: 100_000, + } + } +} + +/// Standard-normal variate via Box–Muller using two uniforms from `rng`. +fn next_standard_normal(rng: &mut Rng) -> f64 { + let mut u1 = rng.next_f64(); + if u1 < 1e-300 { + u1 = 1e-300; + } + let u2 = rng.next_f64(); + (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos() +} + +/// Sample one clone size (>= 1) from `dist`, deterministic for the `rng` state. +pub fn sample_clone_size(rng: &mut Rng, dist: &CloneSizeDist) -> u32 { + match *dist { + CloneSizeDist::PowerLaw { exponent, x_max } => { + let x_max = x_max.max(1) as f64; + let u = rng.next_f64(); + let x = if (exponent - 1.0).abs() < 1e-9 { + x_max.powf(u) + } else { + let a = 1.0 - exponent; + (1.0 + u * (x_max.powf(a) - 1.0)).powf(1.0 / a) + }; + (x.round() as i64).clamp(1, x_max as i64) as u32 + } + CloneSizeDist::LogNormal { mu, sigma, x_max } => { + let z = next_standard_normal(rng); + let x = (mu + sigma * z).exp(); + (x.round() as i64).clamp(1, x_max.max(1) as i64) as u32 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rng::Rng; + + #[test] + fn sizes_are_at_least_one_and_within_max() { + let mut rng = Rng::new(1); + let d = CloneSizeDist::PowerLaw { exponent: 2.0, x_max: 1000 }; + for _ in 0..1000 { + let s = sample_clone_size(&mut rng, &d); + assert!(s >= 1 && s <= 1000, "size {s} out of [1,1000]"); + } + } + + #[test] + fn power_law_is_deterministic() { + let d = CloneSizeDist::PowerLaw { exponent: 2.0, x_max: 1000 }; + let mut a = Rng::new(42); + let mut b = Rng::new(42); + let xs: Vec = (0..100).map(|_| sample_clone_size(&mut a, &d)).collect(); + let ys: Vec = (0..100).map(|_| sample_clone_size(&mut b, &d)).collect(); + assert_eq!(xs, ys); + } + + #[test] + fn power_law_is_heavy_tailed_mostly_small_some_large() { + let mut rng = Rng::new(7); + let d = CloneSizeDist::PowerLaw { exponent: 2.0, x_max: 100_000 }; + let n = 20_000; + let sizes: Vec = (0..n).map(|_| sample_clone_size(&mut rng, &d)).collect(); + let ones = sizes.iter().filter(|&&s| s == 1).count(); + let big = sizes.iter().filter(|&&s| s >= 100).count(); + assert!(ones > n / 3, "expected many singletons, got {ones}/{n}"); + assert!(big > 0, "expected some large clones in the tail"); + let max = *sizes.iter().max().unwrap(); + assert!(max > 50, "tail did not reach large sizes (max {max})"); + } + + #[test] + fn lognormal_is_deterministic_and_in_range() { + let d = CloneSizeDist::LogNormal { mu: 1.0, sigma: 1.0, x_max: 10_000 }; + let mut a = Rng::new(9); + let mut b = Rng::new(9); + let xs: Vec = (0..100).map(|_| sample_clone_size(&mut a, &d)).collect(); + let ys: Vec = (0..100).map(|_| sample_clone_size(&mut b, &d)).collect(); + assert_eq!(xs, ys); + for &s in &xs { + assert!(s >= 1 && s <= 10_000); + } + } +} diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index 5e072e4..e922bb6 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -26,6 +26,9 @@ pub mod sampling; pub mod family; pub mod export; pub mod affinity; +pub mod clone_size; + +pub use clone_size::{sample_clone_size, CloneSizeDist}; pub use affinity::{sim_to_aa, AffinityModel}; pub use tree::{LineageNode, LineageTree}; From 01b1f4b2dfa624defbc48ac31978a93e84662ff3 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 16:54:24 +0300 Subject: [PATCH 33/59] feat(lineage): repertoire composition with unexpanded-singleton fraction --- engine_rs/src/lineage/clone_size.rs | 61 +++++++++++++++++++++++++++++ engine_rs/src/lineage/mod.rs | 2 +- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/engine_rs/src/lineage/clone_size.rs b/engine_rs/src/lineage/clone_size.rs index 72607d5..0361b8c 100644 --- a/engine_rs/src/lineage/clone_size.rs +++ b/engine_rs/src/lineage/clone_size.rs @@ -55,6 +55,32 @@ pub fn sample_clone_size(rng: &mut Rng, dist: &CloneSizeDist) -> u32 { } } +/// Draw `n_clones` clone sizes from `dist`. A fraction `unexpanded_fraction` +/// (in [0,1]) of the clones are forced to be unexpanded singletons (size 1); +/// the deterministic forced count is `round(n_clones * unexpanded_fraction)`, +/// placed at the front of the returned vector. The remainder are drawn from +/// `dist` (and may themselves be 1, so the realized singleton count can exceed +/// the forced minimum — matching real repertoires). Deterministic for the `rng` +/// state. `unexpanded_fraction` is clamped to [0,1]. +pub fn sample_repertoire_sizes( + rng: &mut Rng, + n_clones: u32, + dist: &CloneSizeDist, + unexpanded_fraction: f64, +) -> Vec { + let frac = unexpanded_fraction.clamp(0.0, 1.0); + let n = n_clones as usize; + let forced_singletons = (((n as f64) * frac).round() as usize).min(n); + let mut sizes = Vec::with_capacity(n); + for _ in 0..forced_singletons { + sizes.push(1u32); + } + for _ in forced_singletons..n { + sizes.push(sample_clone_size(rng, dist)); + } + sizes +} + #[cfg(test)] mod tests { use super::*; @@ -106,4 +132,39 @@ mod tests { assert!(s >= 1 && s <= 10_000); } } + + #[test] + fn repertoire_sizes_respects_count_and_min_one() { + let mut rng = Rng::new(3); + let d = CloneSizeDist::PowerLaw { exponent: 2.0, x_max: 1000 }; + let sizes = sample_repertoire_sizes(&mut rng, 500, &d, 0.0); + assert_eq!(sizes.len(), 500); + assert!(sizes.iter().all(|&s| s >= 1)); + } + + #[test] + fn unexpanded_fraction_forces_singletons() { + let mut rng = Rng::new(5); + let d = CloneSizeDist::PowerLaw { exponent: 2.0, x_max: 1000 }; + let sizes = sample_repertoire_sizes(&mut rng, 1000, &d, 0.4); + assert_eq!(sizes.len(), 1000); + let ones = sizes.iter().filter(|&&s| s == 1).count(); + assert!(ones >= 400, "expected >=400 singletons, got {ones}"); + } + + #[test] + fn unexpanded_fraction_one_is_all_singletons() { + let mut rng = Rng::new(8); + let d = CloneSizeDist::default(); + let sizes = sample_repertoire_sizes(&mut rng, 100, &d, 1.0); + assert!(sizes.iter().all(|&s| s == 1)); + } + + #[test] + fn repertoire_is_deterministic() { + let d = CloneSizeDist::PowerLaw { exponent: 2.0, x_max: 1000 }; + let a = sample_repertoire_sizes(&mut Rng::new(11), 200, &d, 0.3); + let b = sample_repertoire_sizes(&mut Rng::new(11), 200, &d, 0.3); + assert_eq!(a, b); + } } diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index e922bb6..e458adf 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -28,7 +28,7 @@ pub mod export; pub mod affinity; pub mod clone_size; -pub use clone_size::{sample_clone_size, CloneSizeDist}; +pub use clone_size::{sample_clone_size, sample_repertoire_sizes, CloneSizeDist}; pub use affinity::{sim_to_aa, AffinityModel}; pub use tree::{LineageNode, LineageTree}; From 260a2540adda6f2d52e492aef67541d8671e77f2 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 16:55:48 +0300 Subject: [PATCH 34/59] docs(lineage): refresh module doc for affinity + clone_size --- engine_rs/src/lineage/mod.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index e458adf..d3dcef9 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -13,11 +13,14 @@ //! assert!(tree.validate().is_ok()); //! ``` //! -//! Neutral mode only (no affinity selection — a later plan). The mutator is any -//! `Pass`; production wires the S5F pass, tests use `UniformMutationPass` (no -//! reference cartridge required). +//! The mutator is any `Pass`; production wires the S5F pass, tests use +//! `UniformMutationPass` (no reference cartridge required). Affinity-driven +//! selection (BLOSUM-weighted distance to a target antigen → fitness-modulated +//! offspring) lives in [`affinity`] and is used via [`simulate_family_with_affinity`]. //! //! Ground-truth export (Newick / FASTA / node-table TSV) lives in [`export`]. +//! Heavy-tailed clone-size distributions + repertoire composition (the TCR core +//! and the singleton tail of BCR repertoires) live in [`clone_size`]. pub mod tree; pub mod poisson; From 8965c4322ebf43ee6c0252fce6e52e2831d1d783 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 17:28:24 +0300 Subject: [PATCH 35/59] feat(lineage): per-node Outcomes via simulate_family_outcomes (full AIRR projection) --- engine_rs/src/lineage/branching.rs | 21 +++- engine_rs/src/lineage/family.rs | 42 ++++++- engine_rs/src/lineage/mod.rs | 4 +- engine_rs/src/python/lineage.rs | 178 ++++++++++++++++++++++++++++- engine_rs/src/python/mod.rs | 2 + 5 files changed, 239 insertions(+), 8 deletions(-) diff --git a/engine_rs/src/lineage/branching.rs b/engine_rs/src/lineage/branching.rs index 25b3df7..75729a6 100644 --- a/engine_rs/src/lineage/branching.rs +++ b/engine_rs/src/lineage/branching.rs @@ -59,13 +59,14 @@ fn mutate_child(parent: &Simulation, mutator: &dyn Pass, child_seed: u64) -> Sim /// deterministic sub-seed and applies `m`. With `affinity = Some(model)`, each /// cell's offspring rate is modulated by the model's fitness; `None` leaves the /// rate unchanged (byte-identical to the pre-affinity path). Returns -/// (tree, peak_live_population). +/// (tree, peak_live_population, sims_arena) where sims_arena[node.id] is the +/// Simulation for that node. fn grow_core( founder: &Simulation, params: &BranchingParams, mutator: Option<&dyn Pass>, affinity: Option<&AffinityModel>, -) -> (LineageTree, usize) { +) -> (LineageTree, usize, Vec) { let mut nodes: Vec = Vec::new(); let mut sims: Vec = Vec::new(); let mut rng = Rng::new(params.seed); @@ -146,7 +147,7 @@ fn grow_core( } } - (LineageTree { nodes }, peak_live) + (LineageTree { nodes }, peak_live, sims) } /// Grow a full clonal lineage with per-division mutation via `mutator`. @@ -176,6 +177,18 @@ pub fn grow_lineage_with_affinity( grow_core(founder, params, Some(mutator), Some(model)).0 } +/// Grow + mutate a lineage and ALSO return the per-node `Simulation` arena +/// (index == node id), for building per-node AIRR `Outcome`s. +pub fn grow_lineage_retaining_sims( + founder: &Simulation, + params: &BranchingParams, + mutator: &dyn Pass, + affinity: Option<&AffinityModel>, +) -> (LineageTree, Vec) { + let (tree, _peak, sims) = grow_core(founder, params, Some(mutator), affinity); + (tree, sims) +} + #[cfg(test)] mod tests { use super::*; @@ -244,7 +257,7 @@ mod tests { n_sample: 10, seed: 7, }; - let (_tree, peak_live) = grow_core(&founder(), ¶ms, None, None); + let (_tree, peak_live, _sims) = grow_core(&founder(), ¶ms, None, None); // hard cap: live population never exceeds n_max assert!(peak_live <= params.n_max as usize, "peak live {peak_live} exceeded n_max {}", params.n_max); diff --git a/engine_rs/src/lineage/family.rs b/engine_rs/src/lineage/family.rs index dfb29eb..870254b 100644 --- a/engine_rs/src/lineage/family.rs +++ b/engine_rs/src/lineage/family.rs @@ -5,7 +5,7 @@ use crate::pass::Pass; use crate::rng::Rng; use super::affinity::AffinityModel; -use super::branching::{grow_lineage, grow_lineage_with_affinity, BranchingParams}; +use super::branching::{grow_lineage, grow_lineage_retaining_sims, grow_lineage_with_affinity, BranchingParams}; use super::sampling::sample_and_collapse; use super::tree::LineageTree; @@ -42,6 +42,22 @@ pub fn simulate_family_with_affinity( tree } +/// Grow + sample a family, returning the tree AND the per-node Simulation arena. +/// Index in the arena equals the node id (arena[node.id] is the Simulation for +/// that node). Only observed (sampled) nodes are useful for AIRR projection; the +/// arena is full-tree and never trimmed. +pub fn simulate_family_sims( + founder: &Simulation, + params: &BranchingParams, + mutator: &dyn Pass, + affinity: Option<&AffinityModel>, +) -> (LineageTree, Vec) { + let (mut tree, sims) = grow_lineage_retaining_sims(founder, params, mutator, affinity); + let mut sample_rng = Rng::new(params.seed ^ SAMPLE_SEED_SALT); + sample_and_collapse(&mut tree, params.n_sample, &mut sample_rng); + (tree, sims) +} + #[cfg(test)] mod tests { use super::*; @@ -83,4 +99,28 @@ mod tests { Box::new(UniformBase))); assert_eq!(tree.len(), tree2.len()); } + + #[test] + fn simulate_family_sims_arena_length_matches_tree_and_observed_nodes_have_nonempty_pool() { + let params = BranchingParams { + lambda_base: 1.5, lambda_mut: 0.0, max_generations: 6, + n_max: 300, n_sample: 20, seed: 9999, + }; + let mutator = UniformMutationPass::new( + Box::new(EmpiricalLengthDist::from_pairs(vec![(1, 1.0)])), + Box::new(UniformBase), + ); + let (tree, sims) = simulate_family_sims(&founder(), ¶ms, &mutator, None); + + // Arena length must equal tree length (one entry per node). + assert_eq!(sims.len(), tree.len(), + "arena len {} != tree len {}", sims.len(), tree.len()); + + // Every observed node's sim must have a non-empty pool. + for node in tree.nodes.iter().filter(|n| n.observed) { + let sim = &sims[node.id as usize]; + assert!(!sim.pool.is_empty(), + "observed node {} has empty pool", node.id); + } + } } diff --git a/engine_rs/src/lineage/mod.rs b/engine_rs/src/lineage/mod.rs index d3dcef9..5f4a753 100644 --- a/engine_rs/src/lineage/mod.rs +++ b/engine_rs/src/lineage/mod.rs @@ -35,7 +35,7 @@ pub use clone_size::{sample_clone_size, sample_repertoire_sizes, CloneSizeDist}; pub use affinity::{sim_to_aa, AffinityModel}; pub use tree::{LineageNode, LineageTree}; -pub use branching::{grow_lineage, grow_lineage_with_affinity, grow_topology, BranchingParams}; +pub use branching::{grow_lineage, grow_lineage_retaining_sims, grow_lineage_with_affinity, grow_topology, BranchingParams}; pub use sampling::sample_and_collapse; -pub use family::{simulate_family, simulate_family_with_affinity}; +pub use family::{simulate_family, simulate_family_sims, simulate_family_with_affinity}; pub use export::{to_fasta, to_newick, to_node_table_tsv}; diff --git a/engine_rs/src/python/lineage.rs b/engine_rs/src/python/lineage.rs index a5720ff..68f0fb1 100644 --- a/engine_rs/src/python/lineage.rs +++ b/engine_rs/src/python/lineage.rs @@ -5,12 +5,14 @@ use pyo3::prelude::*; use crate::lineage::export::{to_fasta, to_newick, to_node_table_tsv}; use crate::lineage::tree::{LineageNode, LineageTree}; -use crate::lineage::{simulate_family, simulate_family_with_affinity, sim_to_aa, AffinityModel, BranchingParams}; +use crate::lineage::{simulate_family, simulate_family_sims, simulate_family_with_affinity, sim_to_aa, AffinityModel, BranchingParams}; use crate::lineage::affinity::make_mature_target; +use crate::pass::Outcome; use crate::rng::Rng; use crate::passes::S5FMutationPass; use crate::s5f::S5FKernel; +use super::outcome::PyOutcome; use super::simulation::PySimulation; /// One node of a clonal lineage tree (read-only view). @@ -250,3 +252,177 @@ pub(crate) fn simulate_lineage( let tree = simulate_family_with_affinity(&founder.inner, ¶ms, &mutator, &model); Ok(PyLineageTree::new(tree)) } + +/// A grown clonal family: the lineage tree plus per-node AIRR-projectable +/// `Outcome`s (only observed nodes carry one). +#[pyclass(name = "FamilyOutcome", module = "GenAIRR._engine")] +pub struct PyFamilyOutcome { + tree: LineageTree, + node_outcomes: Vec>, // index == node id +} + +#[pymethods] +impl PyFamilyOutcome { + /// The ground-truth lineage tree. + fn tree(&self) -> PyLineageTree { + PyLineageTree::new(self.tree.clone()) + } + + /// Per-node Outcomes aligned with `tree().nodes()`; None for unsampled nodes. + fn node_outcomes(&self) -> Vec> { + self.node_outcomes + .iter() + .cloned() + .map(|o: Option| o.map(PyOutcome::new)) + .collect() + } + + /// Observed nodes' Outcomes only (abundance > 0), in node-id order. + fn observed_outcomes(&self) -> Vec { + self.node_outcomes + .iter() + .cloned() + .flatten() + .map(PyOutcome::new) + .collect() + } +} + +/// Grow + sample a clonal lineage family from `founder` (a full `Outcome`) using +/// an S5F mutator, returning a `FamilyOutcome` with per-node AIRR-projectable +/// `Outcome`s for every observed (sampled) node. +#[pyfunction] +#[pyo3(signature = ( + founder, mutability, substitution, rate, + lambda_base, lambda_mut, max_generations, n_max, n_sample, seed, + selection_strength=0.0, beta=1.0, target_aa=None, mature_substitutions=5 +))] +#[allow(clippy::too_many_arguments)] +pub(crate) fn simulate_family_outcomes( + founder: &PyOutcome, + mutability: Vec, + substitution: Vec, + rate: f64, + lambda_base: f64, + lambda_mut: f64, + max_generations: u32, + n_max: u32, + n_sample: u32, + seed: u64, + selection_strength: f64, + beta: f64, + target_aa: Option, + mature_substitutions: u32, +) -> PyResult { + use pyo3::exceptions::PyValueError; + + if mutability.len() != 1024 { + return Err(PyValueError::new_err(format!( + "simulate_family_outcomes: mutability must have 1024 entries, got {}", + mutability.len() + ))); + } + if substitution.len() != 4096 { + return Err(PyValueError::new_err(format!( + "simulate_family_outcomes: substitution must have 4096 entries, got {}", + substitution.len() + ))); + } + if !(rate.is_finite() && (0.0..=1.0).contains(&rate)) { + return Err(PyValueError::new_err(format!( + "simulate_family_outcomes: rate must be in [0.0, 1.0], got {rate}" + ))); + } + if mutability.iter().any(|&m| !m.is_finite() || m < 0.0) { + return Err(PyValueError::new_err( + "simulate_family_outcomes: mutability values must be finite and non-negative", + )); + } + if substitution.iter().any(|&s| !s.is_finite() || s < 0.0) { + return Err(PyValueError::new_err( + "simulate_family_outcomes: substitution values must be finite and non-negative", + )); + } + if n_max == 0 { + return Err(PyValueError::new_err("simulate_family_outcomes: n_max must be > 0")); + } + if n_sample == 0 { + return Err(PyValueError::new_err("simulate_family_outcomes: n_sample must be > 0")); + } + if !(lambda_base.is_finite() && lambda_base >= 0.0) { + return Err(PyValueError::new_err(format!( + "simulate_family_outcomes: lambda_base must be finite and >= 0, got {lambda_base}" + ))); + } + if !(lambda_mut.is_finite() && lambda_mut >= 0.0) { + return Err(PyValueError::new_err(format!( + "simulate_family_outcomes: lambda_mut must be finite and >= 0, got {lambda_mut}" + ))); + } + if max_generations > 1000 { + return Err(PyValueError::new_err(format!( + "simulate_family_outcomes: max_generations must be <= 1000, got {max_generations}" + ))); + } + if !(beta.is_finite() && beta >= 0.0) { + return Err(PyValueError::new_err(format!( + "simulate_family_outcomes: beta must be finite and >= 0, got {beta}" + ))); + } + if !(selection_strength.is_finite() && selection_strength >= 0.0) { + return Err(PyValueError::new_err(format!( + "simulate_family_outcomes: selection_strength must be finite and >= 0, got {selection_strength}" + ))); + } + + let founder_sim = founder.inner.final_simulation().clone(); + let founder_trace = founder.inner.trace.clone(); + + let kernel = S5FKernel::new(mutability, substitution); + let mutator = S5FMutationPass::new_rate(kernel, rate); + let params = BranchingParams { + lambda_base, + lambda_mut, + max_generations, + n_max, + n_sample, + seed, + }; + + // Optional affinity model (same logic as simulate_lineage's non-neutral path). + let model: Option = if selection_strength == 0.0 && target_aa.is_none() { + None + } else { + let founder_aa = sim_to_aa(&founder_sim); + let target = match target_aa { + Some(s) => s.into_bytes(), + None => { + let mut trng = Rng::new(seed ^ 0x7461_7267_6574_0001); + make_mature_target(&founder_aa, mature_substitutions, &mut trng) + } + }; + let weights = vec![1.0; founder_aa.len()]; + Some(AffinityModel::new(target, weights, beta, selection_strength, &founder_aa)) + }; + + let (tree, sims) = simulate_family_sims(&founder_sim, ¶ms, &mutator, model.as_ref()); + + let node_outcomes: Vec> = tree + .nodes + .iter() + .map(|n| { + if n.observed { + Some(Outcome { + revisions: vec![sims[n.id as usize].clone()], + pass_names: Vec::new(), + trace: founder_trace.clone(), + events: Vec::new(), + }) + } else { + None + } + }) + .collect(); + + Ok(PyFamilyOutcome { tree, node_outcomes }) +} diff --git a/engine_rs/src/python/mod.rs b/engine_rs/src/python/mod.rs index 29e4e2a..f579181 100644 --- a/engine_rs/src/python/mod.rs +++ b/engine_rs/src/python/mod.rs @@ -50,7 +50,9 @@ pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_function(pyo3::wrap_pyfunction!(lineage::simulate_lineage, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(lineage::simulate_family_outcomes, m)?)?; m.add_class::()?; m.add_class::()?; m.add_class::()?; From 3fdc8f34937f9fd97e38728a0e89d221af33c13a Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 17:40:39 +0300 Subject: [PATCH 36/59] fix(lineage): pool-derived per-segment mutation counts on node AIRR records --- engine_rs/src/python/lineage.rs | 159 +++++++++++++++++++++++++- engine_rs/src/python/outcome.rs | 2 +- tests/test_lineage_mutation_counts.py | 30 +++++ 3 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 tests/test_lineage_mutation_counts.py diff --git a/engine_rs/src/python/lineage.rs b/engine_rs/src/python/lineage.rs index 68f0fb1..2bf49ce 100644 --- a/engine_rs/src/python/lineage.rs +++ b/engine_rs/src/python/lineage.rs @@ -2,17 +2,23 @@ //! and the `simulate_lineage` entry point. use pyo3::prelude::*; +use pyo3::types::PyDict; +use crate::airr_record::build_airr_record; +use crate::ir::Segment; +use crate::ir::Simulation; use crate::lineage::export::{to_fasta, to_newick, to_node_table_tsv}; use crate::lineage::tree::{LineageNode, LineageTree}; use crate::lineage::{simulate_family, simulate_family_sims, simulate_family_with_affinity, sim_to_aa, AffinityModel, BranchingParams}; use crate::lineage::affinity::make_mature_target; use crate::pass::Outcome; +use crate::refdata::RefDataConfig; use crate::rng::Rng; use crate::passes::S5FMutationPass; use crate::s5f::S5FKernel; -use super::outcome::PyOutcome; +use super::outcome::{PyOutcome, airr_record_to_pydict}; +use super::refdata::PyRefDataConfig; use super::simulation::PySimulation; /// One node of a clonal lineage tree (read-only view). @@ -286,6 +292,157 @@ impl PyFamilyOutcome { .map(PyOutcome::new) .collect() } + + /// Build AIRR Rearrangement record dicts for every observed node, + /// in node-id order (aligned with `observed_outcomes()`). + /// + /// Per-segment and V-subregion mutation counts are recomputed from + /// the node's final simulation pool (net mutations from germline: + /// positions where `base != germline`), overwriting the zero + /// counts that `build_airr_record` would produce from the empty + /// event ledger carried by lineage node `Outcome`s. + fn airr_records<'py>( + &self, + py: Python<'py>, + refdata: &PyRefDataConfig, + ) -> PyResult>> { + let mut results = Vec::new(); + for (node_id, outcome) in self.node_outcomes.iter().enumerate() { + let Some(outcome) = outcome else { continue }; + let sequence_id = format!("node{}", node_id); + let rec = build_airr_record(outcome, refdata.inner(), &sequence_id); + let dict = airr_record_to_pydict(py, &rec)?; + + // Recompute mutation counts from the pool (net mutations + // from germline) to fix the empty-event-ledger bug. + let sim = outcome.final_simulation(); + let counts = pool_mutation_counts(sim, refdata.inner()); + + dict.set_item("n_mutations", counts.n_mutations)?; + dict.set_item("n_v_mutations", counts.n_v_mutations)?; + dict.set_item("n_d_mutations", counts.n_d_mutations)?; + dict.set_item("n_j_mutations", counts.n_j_mutations)?; + dict.set_item("n_np_mutations", counts.n_np_mutations)?; + dict.set_item("n_fwr1_mutations", counts.n_fwr1_mutations)?; + dict.set_item("n_cdr1_mutations", counts.n_cdr1_mutations)?; + dict.set_item("n_fwr2_mutations", counts.n_fwr2_mutations)?; + dict.set_item("n_cdr2_mutations", counts.n_cdr2_mutations)?; + dict.set_item("n_fwr3_mutations", counts.n_fwr3_mutations)?; + dict.set_item("n_v_unannotated_mutations", counts.n_v_unannotated_mutations)?; + + // Recompute mutation_rate = n_mutations / sequence_length. + let seq_len = rec.sequence_length; + let mutation_rate = if seq_len > 0 { + counts.n_mutations as f64 / seq_len as f64 + } else { + 0.0 + }; + dict.set_item("mutation_rate", mutation_rate)?; + + results.push(dict); + } + Ok(results) + } +} + +// ────────────────────────────────────────────────────────────────── +// Pool-derived mutation counter helper +// ────────────────────────────────────────────────────────────────── + +/// Per-segment and V-subregion mutation counts derived by scanning +/// the simulation pool for positions where `base != germline`. +/// This is the "net mutations from germline" quantity that lineage +/// tools use, computed without relying on the event ledger. +struct PoolMutCounts { + n_mutations: i64, + n_v_mutations: i64, + n_d_mutations: i64, + n_j_mutations: i64, + n_np_mutations: i64, + n_fwr1_mutations: i64, + n_cdr1_mutations: i64, + n_fwr2_mutations: i64, + n_cdr2_mutations: i64, + n_fwr3_mutations: i64, + n_v_unannotated_mutations: i64, +} + +/// Scan `sim.pool` and count positions where `base != germline`, +/// bucketing by segment and (for V) by V-subregion. Mirrors the +/// event-loop logic in `airr_record/builder.rs` exactly: same +/// `v_subregions` table lookup, same `germline_pos.get()` → Option +/// coordinate, same fallthrough to `n_v_unannotated_mutations`. +fn pool_mutation_counts(sim: &Simulation, refdata: &RefDataConfig) -> PoolMutCounts { + // Hoist the V-allele subregion table out of the per-nucleotide + // loop (invariant within a record) — same pattern as builder.rs. + let v_subregions: Option<&[crate::refdata::VSubregion]> = sim + .assignments + .get(Segment::V) + .and_then(|inst| refdata.v_pool.get(inst.allele_id)) + .map(|allele| allele.subregions.as_slice()); + + let mut n_v_mutations = 0i64; + let mut n_d_mutations = 0i64; + let mut n_j_mutations = 0i64; + let mut n_np_mutations = 0i64; + let mut n_fwr1_mutations = 0i64; + let mut n_cdr1_mutations = 0i64; + let mut n_fwr2_mutations = 0i64; + let mut n_cdr2_mutations = 0i64; + let mut n_fwr3_mutations = 0i64; + let mut n_v_unannotated_mutations = 0i64; + + for nuc in sim.pool.as_slice() { + // A position is mutated iff its current base differs from germline. + // No case folding — the engine stores mutations as lowercase bytes + // (SHM traces lowercase), so a raw byte comparison is correct and + // mirrors `base != germline` in the event-loop path. + if nuc.base == nuc.germline { + continue; + } + match nuc.segment { + Segment::V => { + n_v_mutations += 1; + // Dispatch into the V-subregion partition using the same + // logic as builder.rs:321-347. `germline_pos.get()` yields + // the allele-relative Option coordinate that the + // VSubregion [start, end) intervals are keyed on. + let label = v_subregions.and_then(|subs| { + nuc.germline_pos.get().and_then(|pos| { + subs.iter() + .find(|s| s.start <= pos && pos < s.end) + .map(|s| s.label) + }) + }); + match label { + Some(crate::refdata::VSubregionLabel::Fwr1) => n_fwr1_mutations += 1, + Some(crate::refdata::VSubregionLabel::Cdr1) => n_cdr1_mutations += 1, + Some(crate::refdata::VSubregionLabel::Fwr2) => n_fwr2_mutations += 1, + Some(crate::refdata::VSubregionLabel::Cdr2) => n_cdr2_mutations += 1, + Some(crate::refdata::VSubregionLabel::Fwr3) => n_fwr3_mutations += 1, + None => n_v_unannotated_mutations += 1, + } + } + Segment::D => n_d_mutations += 1, + Segment::J => n_j_mutations += 1, + Segment::Np1 | Segment::Np2 => n_np_mutations += 1, + } + } + + let n_mutations = n_v_mutations + n_d_mutations + n_j_mutations + n_np_mutations; + PoolMutCounts { + n_mutations, + n_v_mutations, + n_d_mutations, + n_j_mutations, + n_np_mutations, + n_fwr1_mutations, + n_cdr1_mutations, + n_fwr2_mutations, + n_cdr2_mutations, + n_fwr3_mutations, + n_v_unannotated_mutations, + } } /// Grow + sample a clonal lineage family from `founder` (a full `Outcome`) using diff --git a/engine_rs/src/python/outcome.rs b/engine_rs/src/python/outcome.rs index 943b62f..34c0e8e 100644 --- a/engine_rs/src/python/outcome.rs +++ b/engine_rs/src/python/outcome.rs @@ -225,7 +225,7 @@ impl PyOutcome { /// `Option` becomes `int | None`, `Option` becomes `float /// | None`, `Option` becomes `bool | None`. Strings and the /// few `bool`/`i64`/`f64` non-optional fields go through directly. -fn airr_record_to_pydict<'py>(py: Python<'py>, rec: &AirrRecord) -> PyResult> { +pub(crate) fn airr_record_to_pydict<'py>(py: Python<'py>, rec: &AirrRecord) -> PyResult> { let dict = PyDict::new_bound(py); // AIRR metadata diff --git a/tests/test_lineage_mutation_counts.py b/tests/test_lineage_mutation_counts.py new file mode 100644 index 0000000..c57c5f7 --- /dev/null +++ b/tests/test_lineage_mutation_counts.py @@ -0,0 +1,30 @@ +import GenAIRR as ga +from GenAIRR import _engine +from GenAIRR._s5f_loader import load_builtin_s5f_kernel + + +def _founder_and_refdata(): + compiled = ga.Experiment.on("human_igh").recombine().compile() + return compiled.run(n=1, seed=0)[0], compiled.refdata + + +def test_lineage_record_mutation_counts_are_consistent(): + founder, refdata = _founder_and_refdata() + mut, sub = load_builtin_s5f_kernel("hh_s5f") + fam = _engine.simulate_family_outcomes(founder, mut, sub, 0.1, 1.6, 0.0, 8, 300, 30, 7) + recs = fam.airr_records(refdata) # NEW method (Task 1b) + assert len(recs) >= 1 + saw_mutated = False + for r in recs: + per_seg = r["n_v_mutations"] + r["n_d_mutations"] + r["n_j_mutations"] + r["n_np_mutations"] + assert r["n_mutations"] == per_seg, ( + f"n_mutations {r['n_mutations']} != sum per-segment {per_seg}" + ) + v_sub = (r["n_fwr1_mutations"] + r["n_cdr1_mutations"] + r["n_fwr2_mutations"] + + r["n_cdr2_mutations"] + r["n_fwr3_mutations"] + r["n_v_unannotated_mutations"]) + assert r["n_v_mutations"] == v_sub, ( + f"n_v_mutations {r['n_v_mutations']} != sum subregion {v_sub}" + ) + if r["n_mutations"] > 0: + saw_mutated = True + assert saw_mutated, "expected at least one mutated node record" From a2377a2142e42ef7a2d0c18dd84e952a4138029f Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 17:47:12 +0300 Subject: [PATCH 37/59] feat(lineage): Experiment.clonal_lineage DSL with per-node AIRR records + lineage_trees --- src/GenAIRR/_compiled.py | 103 ++++++++++++++++- src/GenAIRR/_pipeline_ir.py | 24 ++++ src/GenAIRR/experiment.py | 183 ++++++++++++++++++++++++++++++- src/GenAIRR/result.py | 35 ++++++ tests/test_clonal_lineage_dsl.py | 53 +++++++++ 5 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 tests/test_clonal_lineage_dsl.py diff --git a/src/GenAIRR/_compiled.py b/src/GenAIRR/_compiled.py index e6f8bf8..e05c019 100644 --- a/src/GenAIRR/_compiled.py +++ b/src/GenAIRR/_compiled.py @@ -24,7 +24,7 @@ _describe_step_sequence, _format_active_contracts, ) -from ._pipeline_ir import _ClonalForkStep +from ._pipeline_ir import _ClonalForkStep, _LineageForkStep if TYPE_CHECKING: from .dataconfig import DataConfig @@ -500,3 +500,104 @@ def __repr__(self) -> str: f"" ) + + +class CompiledLineageExperiment: + """A compiled experiment that grows BCR affinity-maturation lineage trees. + + Wraps a pre-fork :class:`GenAIRR._engine.CompiledSimulator` (founder + recombination) and a :class:`~GenAIRR._pipeline_ir._LineageForkStep` + (lineage parameters). :meth:`run_records` grows one lineage tree per + clone via the Rust ``simulate_family_outcomes`` kernel and returns a + :class:`~GenAIRR.result.SimulationResultWithLineages` whose records are + per-observed-node AIRR dicts with lineage metadata. + """ + + __slots__ = ("_pre", "_step", "_refdata", "_dataconfig", "_metadata") + + def __init__( + self, + pre_simulator: "_engine.CompiledSimulator", + step: "_LineageForkStep", + refdata: "_engine.RefDataConfig", + dataconfig: Optional["DataConfig"] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: + self._pre = pre_simulator + self._step = step + self._refdata = refdata + self._dataconfig = dataconfig + self._metadata = dict(metadata) if metadata else {} + + @property + def refdata(self) -> "_engine.RefDataConfig": + return self._refdata + + def run_records( + self, + *, + seed: int = 0, + strict: bool = False, + ) -> "SimulationResultWithLineages": + """Grow lineage trees and return per-observed-node AIRR records. + + Each clone is seeded at ``seed + clone_idx * 1_000_000`` so + independent clones are reproducible and non-overlapping. + """ + from GenAIRR import _engine as _eng + from ._s5f_loader import load_builtin_s5f_kernel + from .result import SimulationResultWithLineages + + step = self._step + mutability, substitution = load_builtin_s5f_kernel(step.s5f_model) + + records: List[Dict[str, Any]] = [] + lineage_trees = [] + + for clone_idx in range(step.n_clones): + clone_seed = int(seed) + clone_idx * 1_000_000 + # _pre.run() returns a single Outcome (not a list) — mirror + # CompiledClonalExperiment which calls self._pre.run(seed=...). + founder = self._pre.run(seed=clone_seed, strict=strict) + fam = _eng.simulate_family_outcomes( + founder, + mutability, + substitution, + step.rate, + step.lambda_base, + step.lambda_mut, + step.max_generations, + step.n_max, + step.n_sample, + clone_seed, + selection_strength=step.selection_strength, + beta=step.beta, + target_aa=step.target_aa, + mature_substitutions=step.mature_substitutions, + ) + tree = fam.tree() + lineage_trees.append(tree) + + # Collect observed nodes in id order (same order as airr_records). + observed_nodes = [n for n in tree.nodes() if n.observed] + recs = fam.airr_records(self._refdata) + + for node, rec in zip(observed_nodes, recs): + rec["clone_id"] = clone_idx + rec["lineage_node_id"] = node.id + rec["lineage_parent_id"] = ( + node.parent_id if node.parent_id is not None else -1 + ) + rec["lineage_generation"] = node.generation + rec["lineage_abundance"] = node.abundance + rec["lineage_affinity"] = node.affinity + rec["sequence_id"] = f"clone{clone_idx}_node{node.id}" + records.append(rec) + + return SimulationResultWithLineages(records, lineage_trees=lineage_trees) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/src/GenAIRR/_pipeline_ir.py b/src/GenAIRR/_pipeline_ir.py index 0a69856..7147b6a 100644 --- a/src/GenAIRR/_pipeline_ir.py +++ b/src/GenAIRR/_pipeline_ir.py @@ -188,6 +188,30 @@ class _ClonalForkStep: size: int +@dataclass(frozen=True) +class _LineageForkStep: + """Marks an affinity-maturation lineage fork. + + Causes :meth:`Experiment.compile` to return a + :class:`~GenAIRR._compiled.CompiledLineageExperiment` that grows BCR + clonal lineage trees via the Rust ``simulate_family_outcomes`` kernel + and returns per-observed-node AIRR records with lineage metadata. + """ + + n_clones: int + max_generations: int + n_max: int + n_sample: int + rate: float + lambda_base: float + lambda_mut: float + selection_strength: float + beta: float + target_aa: Optional[str] + mature_substitutions: int + s5f_model: str + + @dataclass(frozen=True) class _PairedEndStep: """One ``paired_end(r1_length=…, r2_length=…, insert_size=…)`` diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index eb34e74..adef8eb 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -49,7 +49,7 @@ _lower_recombine, lower_step, ) -from ._compiled import CompiledClonalExperiment, CompiledExperiment +from ._compiled import CompiledClonalExperiment, CompiledExperiment, CompiledLineageExperiment from ._refdata_resolver import ( _CONFIG_ALIASES, ExperimentInput, @@ -69,6 +69,7 @@ _ClonalForkStep, _CorruptStep, _InvertDStep, + _LineageForkStep, _MutateStep, _PairedEndStep, _ReceptorRevisionStep, @@ -1023,6 +1024,148 @@ def expand_clones( self._steps.append(_ClonalForkStep(n_clones=n_clones, size=per_clone)) return self + def clonal_lineage( + self, + *, + n_clones: int, + max_generations: int = 10, + n_max: int = 1000, + n_sample: int = 50, + rate: float = 0.05, + lambda_base: float = 1.5, + lambda_mut: float = 0.0, + selection_strength: float = 0.0, + beta: float = 1.0, + target_aa: Optional[str] = None, + mature_substitutions: int = 5, + s5f_model: str = "hh_s5f", + ) -> "Experiment": + """Grow BCR affinity-maturation lineage trees and return per-node AIRR records. + + Each clone gets its own lineage tree produced by the Rust + ``simulate_family_outcomes`` kernel. The returned + :class:`~GenAIRR.result.SimulationResultWithLineages` carries: + + - ``.records`` — one AIRR dict per *observed* cell, tagged with + ``clone_id``, ``lineage_node_id``, ``lineage_parent_id``, + ``lineage_generation``, ``lineage_abundance``, and + ``lineage_affinity``. Mutation counts (``n_mutations``, + ``n_v_mutations``, …) are pool-derived and self-consistent. + - ``.lineage_trees`` — one :class:`~GenAIRR._engine.LineageTree` + per clone for ground-truth export (Newick, FASTA, node table TSV). + + Parameters + ---------- + n_clones: + Number of independent clonal lineages to grow. + max_generations: + Maximum depth of the lineage tree (≤ 1000). + n_max: + Hard cap on total cells per clone (carrying capacity). + n_sample: + Number of cells to sample as observed leaves. + rate: + Per-base SHM rate for within-lineage mutations. + lambda_base: + Poisson mean for offspring count at affinity 0. + lambda_mut: + Additional Poisson mean increase per affinity unit. + selection_strength: + Sigmoid selection pressure; 0.0 = neutral drift. + beta: + Scaling factor for the affinity term in selection. + target_aa: + Target amino-acid string used to compute affinity (Hamming). + When ``None`` all cells get affinity 0.0. + mature_substitutions: + Minimum cumulative mutations a cell must accumulate before + it is considered a mature/observed cell. + s5f_model: + Bundled S5F kernel name for within-lineage mutation context + (``"hh_s5f"``, ``"hkl_s5f"``, …). + """ + import math + + # --- n_clones --- + if isinstance(n_clones, bool) or not isinstance(n_clones, int) or n_clones < 1: + raise ValueError(f"n_clones must be a positive int, got {n_clones!r}") + # --- max_generations --- + if ( + isinstance(max_generations, bool) + or not isinstance(max_generations, int) + or max_generations < 1 + or max_generations > 1000 + ): + raise ValueError( + f"max_generations must be a positive int <= 1000, got {max_generations!r}" + ) + # --- n_max --- + if isinstance(n_max, bool) or not isinstance(n_max, int) or n_max < 1: + raise ValueError(f"n_max must be a positive int, got {n_max!r}") + # --- n_sample --- + if isinstance(n_sample, bool) or not isinstance(n_sample, int) or n_sample < 1: + raise ValueError(f"n_sample must be a positive int, got {n_sample!r}") + # --- rate --- + if not isinstance(rate, (int, float)) or not math.isfinite(rate) or rate < 0 or rate > 1: + raise ValueError(f"rate must be a float in [0, 1], got {rate!r}") + # --- lambda_base --- + if not isinstance(lambda_base, (int, float)) or not math.isfinite(lambda_base) or lambda_base < 0: + raise ValueError(f"lambda_base must be a finite non-negative float, got {lambda_base!r}") + # --- lambda_mut --- + if not isinstance(lambda_mut, (int, float)) or not math.isfinite(lambda_mut) or lambda_mut < 0: + raise ValueError(f"lambda_mut must be a finite non-negative float, got {lambda_mut!r}") + # --- beta --- + if not isinstance(beta, (int, float)) or not math.isfinite(beta) or beta < 0: + raise ValueError(f"beta must be a finite non-negative float, got {beta!r}") + # --- selection_strength --- + if not isinstance(selection_strength, (int, float)) or not math.isfinite(selection_strength) or selection_strength < 0: + raise ValueError( + f"selection_strength must be a finite non-negative float, got {selection_strength!r}" + ) + # --- mature_substitutions --- + if ( + isinstance(mature_substitutions, bool) + or not isinstance(mature_substitutions, int) + or mature_substitutions < 0 + ): + raise ValueError( + f"mature_substitutions must be a non-negative int, got {mature_substitutions!r}" + ) + # --- reject duplicate fork steps --- + for s in self._steps: + if isinstance(s, (_ClonalForkStep, _LineageForkStep)): + raise ValueError( + "clonal_lineage() / expand_clones() can only be called once per pipeline" + ) + # --- descendant-phase guard (same as expand_clones) --- + for step in self._steps: + offending_method = _descendant_phase_step_classifier(step) + if offending_method is None: + continue + raise ValueError( + f"{offending_method} must be called after " + f"clonal_lineage(); it is descendant-specific and must be sampled " + f"independently for each clone member. Move " + f"{offending_method}(...) after clonal_lineage(...)." + ) + self._steps.append( + _LineageForkStep( + n_clones=n_clones, + max_generations=max_generations, + n_max=n_max, + n_sample=n_sample, + rate=rate, + lambda_base=lambda_base, + lambda_mut=lambda_mut, + selection_strength=selection_strength, + beta=beta, + target_aa=target_aa, + mature_substitutions=mature_substitutions, + s5f_model=s5f_model, + ) + ) + return self + def mutate( self, *, @@ -2227,6 +2370,39 @@ def compile(self, *, allow_curatable_refdata: Optional[bool] = None): metadata=self._metadata, ) + # if a `_LineageForkStep` is present, compile a + # CompiledLineageExperiment: pre-fork steps (recombine) become + # the founder simulator; post-fork steps must be empty (the + # lineage engine handles mutation internally). + lineage_idx = next( + (i for i, s in enumerate(self._steps) if isinstance(s, _LineageForkStep)), + None, + ) + if lineage_idx is not None: + lineage_step: _LineageForkStep = self._steps[lineage_idx] + pre_steps = self._steps[:lineage_idx] + post_steps = self._steps[lineage_idx + 1:] + if post_steps: + raise ValueError( + "No steps may follow clonal_lineage() — the lineage engine " + "handles mutation internally. Remove the following step(s): " + + ", ".join(type(s).__name__ for s in post_steps) + ) + pre_simulator = self._build_simulator( + pre_steps, + contracts, + any_lock, + replace_fn=_replace, + allow_curatable_refdata=allow_curatable_refdata, + ) + return CompiledLineageExperiment( + pre_simulator, + lineage_step, + self._refdata, + dataconfig=self._dataconfig, + metadata=self._metadata, + ) + simulator = self._build_simulator( self._steps, contracts, @@ -2374,6 +2550,11 @@ def run_records( expose_provenance=expose_provenance, validate_records=validate_records, ) + elif isinstance(compiled, CompiledLineageExperiment): + result = compiled.run_records( + seed=seed, + strict=strict, + ) else: result = compiled.run_records( n=1 if n is None else n, diff --git a/src/GenAIRR/result.py b/src/GenAIRR/result.py index f5d8054..b2baabc 100644 --- a/src/GenAIRR/result.py +++ b/src/GenAIRR/result.py @@ -1318,3 +1318,38 @@ def _write_delimited( # don't carry literal ``"None"`` strings. row = {k: ("" if v is None else v) for k, v in source.items()} writer.writerow(row) + + +class SimulationResultWithLineages(SimulationResult): + """A :class:`SimulationResult` that also carries per-clone lineage trees. + + Produced by :meth:`CompiledLineageExperiment.run_records`. Adds a + ``.lineage_trees`` property that exposes the raw + :class:`~GenAIRR._engine.LineageTree` objects (one per clone) for + ground-truth export via ``.to_newick()``, ``.to_fasta()``, and + ``.to_node_table_tsv()``. + """ + + __slots__ = ("_lineage_trees",) + + def __init__( + self, + records: "Sequence[Dict[str, Any]]", + outcomes: "Optional[Sequence]" = None, + parents: "Optional[Sequence]" = None, + lineage_trees: "Optional[Sequence]" = None, + ) -> None: + super().__init__(records, outcomes, parents) + self._lineage_trees: "Optional[List]" = ( + list(lineage_trees) if lineage_trees is not None else None + ) + + @property + def lineage_trees(self) -> "Optional[List]": + """Per-clone :class:`~GenAIRR._engine.LineageTree` objects, or ``None``. + + Each tree supports ``.validate()``, ``.to_newick()``, + ``.to_fasta()``, and ``.to_node_table_tsv()`` for ground-truth + export and downstream phylogenetic analysis. + """ + return self._lineage_trees diff --git a/tests/test_clonal_lineage_dsl.py b/tests/test_clonal_lineage_dsl.py new file mode 100644 index 0000000..d293171 --- /dev/null +++ b/tests/test_clonal_lineage_dsl.py @@ -0,0 +1,53 @@ +import pytest +import GenAIRR as ga + + +def _exp(**kw): + base = dict(n_clones=3, max_generations=6, n_max=200, n_sample=20, + rate=0.05, lambda_base=1.6) + base.update(kw) + return ga.Experiment.on("human_igh").recombine().clonal_lineage(**base) + + +def test_clonal_lineage_runs_and_tags_records(): + result = _exp().run_records(seed=0) + assert len(result.records) > 0 + cids = {r["clone_id"] for r in result.records} + assert cids == {0, 1, 2} + for r in result.records: + assert r["v_call"] # real recombination provenance + assert r["sequence"] + assert "lineage_node_id" in r + assert "lineage_generation" in r + assert "lineage_abundance" in r + assert "lineage_affinity" in r + # mutation counts are self-consistent (pool-derived) + per_seg = (r["n_v_mutations"] + r["n_d_mutations"] + + r["n_j_mutations"] + r["n_np_mutations"]) + assert r["n_mutations"] == per_seg + + +def test_clonal_lineage_exposes_per_clone_trees(): + result = _exp(n_clones=2).run_records(seed=1) + trees = result.lineage_trees + assert trees is not None and len(trees) == 2 + for t in trees: + t.validate() # raises if malformed + assert t.to_newick().endswith(";") + assert t.to_fasta().startswith(">node") + + +def test_clonal_lineage_selection_raises_mean_affinity(): + neutral = _exp(n_clones=2, selection_strength=0.0, target_aa="A" * 100, + beta=0.001, max_generations=10, n_sample=40).run_records(seed=5) + selected = _exp(n_clones=2, selection_strength=50.0, target_aa="A" * 100, + beta=0.001, max_generations=10, n_sample=40).run_records(seed=5) + def mean_aff(res): + a = [r["lineage_affinity"] for r in res.records] + return sum(a) / max(1, len(a)) + assert mean_aff(selected) > mean_aff(neutral) + + +def test_clonal_lineage_validation_rejects_bad_args(): + with pytest.raises((ValueError, TypeError)): + ga.Experiment.on("human_igh").recombine().clonal_lineage(n_clones=0) From 51ac130f27eb8ee2840583074eabfc68be6f10df Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 18:10:51 +0300 Subject: [PATCH 38/59] feat(lineage): deprecate expand_clones in favor of clonal_lineage (kept working) --- src/GenAIRR/experiment.py | 14 +++++++++++ tests/test_expand_clones_deprecation.py | 32 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 tests/test_expand_clones_deprecation.py diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index adef8eb..7abcc7b 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -969,6 +969,12 @@ def expand_clones( records automatically. Passing ``n`` is allowed only when ``n == n_clones * per_clone``. + .. deprecated:: + Use :meth:`clonal_lineage` instead, which grows real + affinity-maturation lineage trees rather than a flat star + topology. ``expand_clones`` remains supported for flat + clonal expansion. + Constraints: - Both ``n_clones`` and ``per_clone`` must be positive ints. - At most one expansion per pipeline; calling this method @@ -981,6 +987,14 @@ def expand_clones( shares the same recombination provenance (V allele, trim, NP bases) and only diverges through the post-fork passes. """ + warnings.warn( + "Experiment.expand_clones() is deprecated in favor of " + "Experiment.clonal_lineage(), which grows real clonal lineage " + "trees (affinity maturation) instead of a star topology. " + "expand_clones() remains supported for flat clonal expansion.", + DeprecationWarning, + stacklevel=2, + ) if not isinstance(n_clones, int) or isinstance(n_clones, bool) or n_clones < 1: raise ValueError( f"n_clones must be a positive int, got {n_clones!r}" diff --git a/tests/test_expand_clones_deprecation.py b/tests/test_expand_clones_deprecation.py new file mode 100644 index 0000000..5ef1532 --- /dev/null +++ b/tests/test_expand_clones_deprecation.py @@ -0,0 +1,32 @@ +"""Deprecation contract for ``Experiment.expand_clones``. + +Verifies that: + +1. Calling ``expand_clones()`` emits a ``DeprecationWarning`` that + mentions ``clonal_lineage``. +2. Despite the warning, ``expand_clones()`` continues to produce the + correct star-topology clonal output (n_clones * per_clone records + with the expected ``clone_id`` values). +""" + +import warnings + +import pytest + +import GenAIRR as ga + + +def test_expand_clones_emits_deprecation_warning_but_still_works(): + exp = ga.Experiment.on("human_igh").recombine() + with pytest.warns(DeprecationWarning, match="clonal_lineage"): + exp = exp.expand_clones(n_clones=2, per_clone=3) + # Pipeline continues to work after the deprecation warning. + exp = exp.mutate(count=5) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + result = exp.run_records(seed=0) + # 2 clones × 3 descendants = 6 records. + assert len(result) == 6 + # Exactly the expected clone IDs are present. + clone_ids = {r["clone_id"] for r in result} + assert clone_ids == {0, 1} From 8135854581258658d88226be24c78d9dcde5f7a6 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 19:19:23 +0300 Subject: [PATCH 39/59] docs(site): detailed clonal lineage guide (how the engine grows real trees) --- mkdocs.yml | 1 + site_docs/guides/clonal-lineage.md | 268 +++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 site_docs/guides/clonal-lineage.md diff --git a/mkdocs.yml b/mkdocs.yml index f4470f8..ad9ccb0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -205,6 +205,7 @@ nav: - Recombination + junction biology: guides/recombination-junction.md - D inversion + receptor revision: guides/recombination-editing.md - Clonal families: guides/clonal-families.md + - Clonal lineage trees: guides/clonal-lineage.md - Junction N/P additions: guides/junction-additions.md - Targeted SHM rates: guides/shm-targeting.md - Corruption + sequencing artefacts: guides/corruption-sequencing.md diff --git a/site_docs/guides/clonal-lineage.md b/site_docs/guides/clonal-lineage.md new file mode 100644 index 0000000..9ffcff5 --- /dev/null +++ b/site_docs/guides/clonal-lineage.md @@ -0,0 +1,268 @@ +# Clonal lineage trees (affinity maturation) + +

Where expand_clones +produces a star — one founder and many independent descendants — +clonal_lineage grows a real tree: a generation-by-generation +birth–death process in which cells divide, somatically hypermutate, are selected +for antigen affinity, and are finally sampled. The output is a set of +per-cell AIRR records plus the ground-truth lineage tree (topology, +ancestral sequences, abundances) — exactly what B-cell lineage-inference tools +(GCtree, IgPhyML, dowser, Change-O) are built to reconstruct. This page explains +precisely how it works under the hood; nothing here is a black box.

+ +## Why a tree, not a star + +A clonal family in vivo is the progeny of one naive B cell that has entered a +germinal center. Inside that germinal center the cell **divides**, its B-cell +receptor **somatically hypermutates** a few bases per division, and cells whose +mutated receptor **binds the antigen better** are preferentially expanded +(affinity maturation). The result is a genealogy with internal ancestors, +unequal branch lengths, and selective sweeps — a tree. + +The older `expand_clones` collapses all of that into a star: it takes the founder +recombination and draws `per_clone` independent descendants directly off it. That +is fine for "many reads that share a V(D)J truth", but it has no genealogy, no +generations, no selection, and no ancestral nodes — so it cannot serve as ground +truth for lineage reconstruction. `clonal_lineage` adds the missing biology. + +> T cells do **not** somatically hypermutate. A TCR "clone" is one rearrangement +> proliferated to many identical copies; the meaningful quantity is the +> **clone-size distribution**, not a mutation tree. GenAIRR models that with a +> separate heavy-tailed clone-size sampler (see +> [Clone-size distributions](#clone-size-distributions-tcr-and-repertoire-mix)). + +## Quick start + +```python +import GenAIRR as ga + +result = ( + ga.Experiment.on("human_igh") + .recombine() # the founder: one V(D)J recombination per clone + .clonal_lineage( + n_clones=20, # grow 20 independent families + max_generations=6, # germinal-center rounds + n_max=300, # carrying capacity (cells per family) + n_sample=30, # cells sampled per family at the end + rate=0.01, # per-base S5F SHM rate, per division + lambda_base=1.6, # mean offspring per cell per generation + selection_strength=10.0, # 0 = neutral; >0 = affinity selection + ) + .run_records(seed=0) +) + +# Per-cell AIRR records, tagged with clone + lineage metadata: +for rec in result.records[:3]: + print(rec["clone_id"], rec["lineage_node_id"], rec["lineage_generation"], + rec["lineage_abundance"], rec["lineage_affinity"], rec["v_call"]) + +# Ground-truth trees, one per clone: +tree = result.lineage_trees[0] +tree.validate() # structural invariants (raises if malformed) +newick = tree.to_newick() # true topology, branch length = per-edge mutations +fasta = tree.to_fasta() # every node's sequence (ancestral + observed) +table = tree.to_node_table_tsv() +``` + +## How it works under the hood + +Each clone is grown independently by a generation-synchronous birth–death process +in the Rust engine. The whole loop is deterministic for a given `seed`. + +```mermaid +flowchart TB + A["Founder: one V(D)J recombination
(the pre-fork plan, run once per clone)"] --> B["Generation g = 1..max_generations"] + B --> C["For each live cell:
offspring k ~ Poisson(λ_eff)"] + C --> D["λ_eff = lambda_base
× carrying-capacity damping
× affinity fitness(cell)"] + C --> E["Each child = parent + per-division S5F mutations"] + E --> F["Child affinity = exp(−β · weighted aa distance to target)"] + F --> B + B --> G["Stop at max_generations / extinction / capacity"] + G --> H["Sample n_sample cells from the final population"] + H --> I["Collapse identical genotypes → abundances
(observed nodes)"] + I --> J["Project each observed cell → AIRR record
+ emit ground-truth tree"] +``` + +### 1. The founder + +`recombine()` (everything before `clonal_lineage` in the chain) is the **per-clone** +phase: it runs once to produce the naive rearrangement — the V/D/J allele picks, +trims, NP bases, junction. That founder `Simulation` (and its recombination trace) +is the root of the tree. Clone *c* uses seed `seed + c × 1_000_000`, so families +are independent and reproducible. + +### 2. Generation-synchronous birth–death + +Growth proceeds in discrete generations. In each generation every currently-live +cell produces a number of offspring drawn from a Poisson distribution: + +``` +k ~ Poisson(λ_eff) +``` + +`k = 0` means the cell leaves no progeny (it becomes a tip); `k ≥ 1` creates `k` +children for the next generation. `λ_eff` is the base offspring rate `lambda_base` +modulated by two factors below. + +### 3. Carrying capacity (logistic damping) + +A germinal center is population-bounded, so the effective rate is damped as the +live population `P` approaches `n_max`: + +``` +λ_eff = lambda_base × max(0, 1 − P / n_max) +``` + +Near saturation `λ_eff → 0` and growth plateaus instead of exploding. A hard cap +also prevents the live set from exceeding `n_max` even on a lucky Poisson draw. + +### 4. Per-division somatic hypermutation (S5F) + +Every child is a clone of its parent's `Simulation` with a fresh round of somatic +hypermutation applied. GenAIRR reuses its **context-sensitive S5F** engine (the +same one behind `mutate(model="s5f")`): mutations are drawn from the 5-mer +mutability/substitution kernel (`s5f_model`, default `"hh_s5f"`) at per-base rate +`rate`, applied one at a time with the sequence context re-evaluated between +mutations. Because SHM only substitutes bases in place, each cell keeps the +founder's V/D/J assignments and region map — its germline ancestry stays intact, +which is what lets every node be projected to a correct AIRR record (below). + +The branch length stored on each edge is the **realized** number of substitutions +introduced on that division. + +### 5. Affinity selection + +This is what turns a neutral tree into affinity maturation. Each cell has an +**affinity** to a target antigen: + +``` +affinity = exp(−beta · weighted_aa_distance(cell, target)) +``` + +`weighted_aa_distance` is a **BLOSUM62 substitution-aware** amino-acid distance +between the cell's translated receptor and the target (region weights let CDRs be +emphasized; v1 uses uniform weights, with CDR3-weighting as a planned refinement). +`affinity` is 1.0 at the target and decays toward 0 as the cell diverges. + +The target is either supplied by you (`target_aa=...`, an antigen amino-acid +sequence) or auto-generated as a "mature" target — the founder's amino-acid +sequence with `mature_substitutions` random residue changes (the standard +benchmark convention). + +Affinity feeds back into the offspring rate through a **fitness multiplier**: + +``` +fitness = max(0, 1 + selection_strength · (affinity − founder_affinity)) +λ_eff = lambda_base × carrying_capacity_damping × fitness +``` + +`founder_affinity` is the affinity of the naive founder, so the founder has +fitness ≈ 1, cells that improve on it divide faster, and worse cells divide +slower — producing selective sweeps. **`selection_strength = 0` makes `fitness ≡ 1`, +i.e. a neutral tree** (byte-identical to growing with no selection at all). + +> **Calibration note.** `exp(−beta · distance)` can underflow to ~0 for long +> receptor sequences at large `beta`, flattening selection. If you supply a full +> antigen `target_aa`, keep `beta` small (e.g. `1e-3`) or use the auto target. + +### 6. Sampling and genotype collapse + +When growth stops (at `max_generations`, extinction, or capacity), `n_sample` +cells are sampled uniformly from the final population. Cells with **identical +genotypes** are then collapsed: the first cell seen for a genotype becomes the +**observed** representative and accumulates an **abundance** count. This is the +standard germinal-center convention — it produces observed tips with multiplicities +and "sampled ancestor" nodes, exactly the structure abundance-aware tree methods +(e.g. GCtree) expect. Observed nodes are the ones that become AIRR records. + +## What you get back + +### Per-cell AIRR records + +`result.records` is a list of full AIRR Rearrangement dicts, one per observed +cell. Each carries the founder's recombination provenance (`v_call`, `d_call`, +`j_call`, junction, …) — correct because the node's `Outcome` reuses the founder's +recombination trace — plus its own mutated `sequence`. Mutation counts +(`n_mutations`, `n_v_mutations`, …, and the IMGT-subregion counters) are recomputed +**from the cell's sequence vs. germline** (net mutations from germline — the +branch-length quantity lineage tools use), so they are internally consistent +(`n_v + n_d + n_j + n_np == n_mutations`). + +Lineage metadata stamped on every record: + +| Field | Meaning | +|---|---| +| `clone_id` | Which family (0 … n_clones−1) — the ground-truth clone label | +| `lineage_node_id` | The cell's node id within its tree | +| `lineage_parent_id` | Parent node id (−1 for the founder) | +| `lineage_generation` | Generation depth (founder = 0) | +| `lineage_abundance` | Observation count after genotype collapse | +| `lineage_affinity` | Affinity to the target (0 in neutral mode) | + +### Ground-truth lineage trees + +`result.lineage_trees` is one `LineageTree` per clone. Each tree exposes the full +genealogy (every node, not just observed ones) and three exporters consumed by +inference tools: + +- `to_newick()` — standard rooted Newick; the founder is the root, branch lengths + are per-edge mutation counts. (`((node3:1)node1:1,node2:1)node0;`) +- `to_fasta()` — every node's sequence, ancestral and observed, so + ancestral-sequence-reconstruction can be scored against truth. +- `to_node_table_tsv()` — `node_id, parent_id, generation, mutations_from_parent, + abundance, observed, affinity, sequence`. +- `nodes()` / `validate()` — node access and a structural-invariant check. + +## Clone-size distributions (TCR and repertoire mix) + +Real repertoires are not uniform: a few clones are huge, most are singletons. The +engine includes heavy-tailed **clone-size distributions** (`CloneSizeDist`: +power-law/Zipf by default, log-normal optional) and a repertoire-composition +sampler that draws a set of clone sizes with a controllable **unexpanded fraction** +(size-1, never-expanded clones). For TCR — which has no SHM — a clone is simply one +rearrangement at copy-number `size`, with within-clone variation coming only from +the existing sequencing/PCR-error passes. These primitives are the basis for +mixing large expanded families with a realistic singleton tail. + +## Determinism + +Everything is keyed on `seed`. Clone *c* grows from `seed + c × 1_000_000`; +within a clone, generations, divisions, mutations, and sampling all draw from +seeded RNG streams (growth and sampling use separate streams so they don't +interfere). Re-running with the same seed reproduces the trees and records +byte-for-byte. + +## Parameters + +| Parameter | Default | Meaning | +|---|---|---| +| `n_clones` | — | Number of independent families to grow | +| `max_generations` | 10 | Germinal-center rounds (≤ 1000) | +| `n_max` | 1000 | Carrying capacity (live cells per family) | +| `n_sample` | 50 | Cells sampled per family at the end | +| `rate` | 0.05 | Per-base S5F SHM rate, per division | +| `lambda_base` | 1.5 | Mean offspring per cell per generation | +| `lambda_mut` | 0.0 | Reserved; currently inert (SHM is driven by `rate`) | +| `selection_strength` | 0.0 | 0 = neutral; >0 = affinity selection | +| `beta` | 1.0 | Affinity steepness in `exp(−beta·distance)` | +| `target_aa` | `None` | Antigen target; `None` ⇒ auto "mature" target | +| `mature_substitutions` | 5 | aa substitutions for the auto target | +| `s5f_model` | `"hh_s5f"` | Bundled S5F kernel | + +## Validated against community tools + +The simulated repertoires are detectable by established AIRR clone-callers out of +the box. On a realistic-SHM run, Immcantation's **Change-O** `DefineClones` at its +**default** junction-distance threshold recovers the planted clones exactly +(adjusted Rand index = 1.0; precision = recall = 1.0). Across mutation regimes the +clonal signal is clean — standard callers never merge two distinct planted clones +(precision = 1.0), and recovery reaches 1.0 once the caller's distance threshold +matches the simulated SHM level. In other words, the trees GenAIRR plants are the +trees these tools find. + +## Relationship to `expand_clones` + +`expand_clones` (the star model) is **deprecated** but still works — it remains +useful for "many reads sharing one V(D)J truth" without a genealogy. For real +clonal trees, ground-truth lineages, and affinity maturation, use +`clonal_lineage`. From e98d50052aad6d1cdda1243802ffbe060b2af3a5 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 19:36:40 +0300 Subject: [PATCH 40/59] docs(site): add detection figure + Change-O worked example to clonal lineage guide --- site_docs/assets/clonal-lineage-detection.png | Bin 0 -> 97838 bytes site_docs/guides/clonal-lineage.md | 68 +++++++++++++++--- 2 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 site_docs/assets/clonal-lineage-detection.png diff --git a/site_docs/assets/clonal-lineage-detection.png b/site_docs/assets/clonal-lineage-detection.png new file mode 100644 index 0000000000000000000000000000000000000000..711f0fceff6316b74ed182f1a2da95515d1a45e6 GIT binary patch literal 97838 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 z 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 literal 0 HcmV?d00001 diff --git a/site_docs/guides/clonal-lineage.md b/site_docs/guides/clonal-lineage.md index 9ffcff5..eb9d5fa 100644 --- a/site_docs/guides/clonal-lineage.md +++ b/site_docs/guides/clonal-lineage.md @@ -251,14 +251,66 @@ byte-for-byte. ## Validated against community tools -The simulated repertoires are detectable by established AIRR clone-callers out of -the box. On a realistic-SHM run, Immcantation's **Change-O** `DefineClones` at its -**default** junction-distance threshold recovers the planted clones exactly -(adjusted Rand index = 1.0; precision = recall = 1.0). Across mutation regimes the -clonal signal is clean — standard callers never merge two distinct planted clones -(precision = 1.0), and recovery reaches 1.0 once the caller's distance threshold -matches the simulated SHM level. In other words, the trees GenAIRR plants are the -trees these tools find. +The point of planting ground-truth clones is that **other people's tools can find +them**. They can. On a realistic-SHM run, Immcantation's **Change-O** +`DefineClones` at its **default** junction-distance threshold (0.16) recovers the +planted clones exactly. + +![GenAIRR clonal_lineage clones are recovered by Change-O at default settings](../assets/clonal-lineage-detection.png) + +*30 clones simulated with `clonal_lineage` (human IGH, realistic SHM). **(A)** the +repertoire has a realistic spread of clone sizes. **(B)** within-clone junction +distances sit far below Change-O's default 0.16 threshold while between-clone +distances are ~1.0 — the planted clones are cleanly separable. **(C)** Change-O at +its default threshold recovers all 30 planted clones (adjusted Rand index = 1.0, +precision = recall = 1.0).* + +### Reproduce it: run Change-O on a simulated repertoire + +```python +import GenAIRR as ga +import pandas as pd + +result = (ga.Experiment.on("human_igh").recombine() + .clonal_lineage(n_clones=30, max_generations=3, n_max=300, + n_sample=20, rate=0.008, lambda_base=1.6, + selection_strength=0.3) + .run_records(seed=42)) + +# Write an AIRR-format TSV. Keep the ground-truth clone label under a NON-AIRR +# name so it doesn't collide with the tool's inferred `clone_id` column. +df = pd.DataFrame(result.records) +df = df.rename(columns={"clone_id": "true_clone_id"}) +df.to_csv("repertoire.tsv", sep="\t", index=False) +``` + +```bash +# Immcantation Change-O (pip install changeo) infers clones from junctions: +DefineClones.py -d repertoire.tsv --format airr \ + --act set --model ham --norm len --dist 0.16 -o clones.tsv +``` + +Comparing Change-O's inferred `clone_id` against the planted `true_clone_id` +(e.g. with `sklearn.metrics.adjusted_rand_score`) gives **ARI = 1.0** — a perfect +match. + +### What the validation shows across SHM regimes + +- **Perfect precision, always.** Across every tool and threshold tested, two + *different* planted clones are never merged. The per-clone founding + rearrangements are distinct, so the planted signal is unambiguous. +- **Full recovery at a matched threshold.** Detection is a function of how mutated + the lineage is versus the caller's distance cutoff. At realistic SHM, the default + 0.16 cutoff recovers everything; for deeply matured lineages (e.g. `rate=0.05`, + 6 generations → ~21 % SHM) you raise the cutoff (a threshold sweep climbs from + ARI 0.26 at 0.16 → 0.91 at 0.30 → 1.0 at 0.45). This mirrors how these tools + behave on real data and is **not** a property of the simulator. +- **Independent of any one tool.** The same recovery holds for an + implementation-independent V/J + junction-length + single-linkage clusterer, and + the exported Newick/FASTA feed tree-based methods (GCtree, IgPhyML, dowser) + directly. + +In short: the clones GenAIRR plants are the clones the ecosystem detects. ## Relationship to `expand_clones` From 186e4952fdf7cc17e444b47e8dd0e6eae3141032 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 19:59:24 +0300 Subject: [PATCH 41/59] fix(lineage): remove dead lambda_mut, validate target_aa/s5f_model, clear run_records errors, doc fixes --- site_docs/guides/clonal-lineage.md | 36 ++++--- src/GenAIRR/_compiled.py | 2 +- src/GenAIRR/_pipeline_ir.py | 1 - src/GenAIRR/experiment.py | 90 ++++++++++++---- tests/test_clonal_lineage_validation.py | 133 ++++++++++++++++++++++++ 5 files changed, 230 insertions(+), 32 deletions(-) create mode 100644 tests/test_clonal_lineage_validation.py diff --git a/site_docs/guides/clonal-lineage.md b/site_docs/guides/clonal-lineage.md index eb9d5fa..26ec663 100644 --- a/site_docs/guides/clonal-lineage.md +++ b/site_docs/guides/clonal-lineage.md @@ -179,14 +179,27 @@ and "sampled ancestor" nodes, exactly the structure abundance-aware tree methods ### Per-cell AIRR records -`result.records` is a list of full AIRR Rearrangement dicts, one per observed -cell. Each carries the founder's recombination provenance (`v_call`, `d_call`, -`j_call`, junction, …) — correct because the node's `Outcome` reuses the founder's -recombination trace — plus its own mutated `sequence`. Mutation counts -(`n_mutations`, `n_v_mutations`, …, and the IMGT-subregion counters) are recomputed -**from the cell's sequence vs. germline** (net mutations from germline — the -branch-length quantity lineage tools use), so they are internally consistent -(`n_v + n_d + n_j + n_np == n_mutations`). +`result.records` is a list of full AIRR Rearrangement dicts, one per *observed* +(genotype-collapsed) cell. Each carries the founder's recombination provenance +(`v_call`, `d_call`, `j_call`, junction, …) — correct because the node's `Outcome` +reuses the founder's recombination trace — plus its own mutated `sequence`. Mutation +counts (`n_mutations`, `n_v_mutations`, …, and the IMGT-subregion counters) are +recomputed **from the cell's sequence vs. germline** — these are **net differences +from germline** (accumulated across all divisions from founder to leaf). Because +identical genotypes are collapsed before sampling, the number of records per clone +is ≤ `n_sample`; the `lineage_abundance` field accounts for the collapsed copies. + +> **Branch lengths vs. record `n_mutations`.** Newick branch lengths +> (as returned by `to_newick()`) count the **per-division substitution events** +> along each edge — re-mutations of a site that was already mutated count again. +> The record field `n_mutations` is the **net difference from germline** at the +> leaf (a site mutated and then back-mutated is not counted). As a result, +> summing branch lengths root→leaf will generally exceed a leaf's `n_mutations`. +> Both quantities are standard and correct: branch lengths track evolutionary +> distance along an edge (as tools like GCtree and IgPhyML expect), while +> `n_mutations` tracks the observable deviation from germline (as AIRR requires). + +Consistency check: `n_v + n_d + n_j + n_np == n_mutations` holds for every record. Lineage metadata stamped on every record: @@ -239,13 +252,12 @@ byte-for-byte. | `n_clones` | — | Number of independent families to grow | | `max_generations` | 10 | Germinal-center rounds (≤ 1000) | | `n_max` | 1000 | Carrying capacity (live cells per family) | -| `n_sample` | 50 | Cells sampled per family at the end | +| `n_sample` | 50 | Cells sampled per family at the end; records per clone ≤ this (genotype-collapsed) | | `rate` | 0.05 | Per-base S5F SHM rate, per division | | `lambda_base` | 1.5 | Mean offspring per cell per generation | -| `lambda_mut` | 0.0 | Reserved; currently inert (SHM is driven by `rate`) | -| `selection_strength` | 0.0 | 0 = neutral; >0 = affinity selection | +| `selection_strength` | 0.0 | Neutral drift by default (`lineage_affinity ≡ 0`); set `> 0` for affinity selection | | `beta` | 1.0 | Affinity steepness in `exp(−beta·distance)` | -| `target_aa` | `None` | Antigen target; `None` ⇒ auto "mature" target | +| `target_aa` | `None` | Amino-acid sequence of the full receptor used as the antigen target (BLOSUM62-weighted distance, position-wise; only the overlapping prefix is scored when lengths differ). `None` ⇒ auto "mature" target | | `mature_substitutions` | 5 | aa substitutions for the auto target | | `s5f_model` | `"hh_s5f"` | Bundled S5F kernel | diff --git a/src/GenAIRR/_compiled.py b/src/GenAIRR/_compiled.py index e05c019..448258a 100644 --- a/src/GenAIRR/_compiled.py +++ b/src/GenAIRR/_compiled.py @@ -565,7 +565,7 @@ def run_records( substitution, step.rate, step.lambda_base, - step.lambda_mut, + 0.0, # lambda_mut: positional slot 6 (inert; hardcoded) step.max_generations, step.n_max, step.n_sample, diff --git a/src/GenAIRR/_pipeline_ir.py b/src/GenAIRR/_pipeline_ir.py index 7147b6a..c381c18 100644 --- a/src/GenAIRR/_pipeline_ir.py +++ b/src/GenAIRR/_pipeline_ir.py @@ -204,7 +204,6 @@ class _LineageForkStep: n_sample: int rate: float lambda_base: float - lambda_mut: float selection_strength: float beta: float target_aa: Optional[str] diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index 7abcc7b..d3755f1 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -1047,23 +1047,26 @@ def clonal_lineage( n_sample: int = 50, rate: float = 0.05, lambda_base: float = 1.5, - lambda_mut: float = 0.0, selection_strength: float = 0.0, beta: float = 1.0, target_aa: Optional[str] = None, mature_substitutions: int = 5, s5f_model: str = "hh_s5f", ) -> "Experiment": - """Grow BCR affinity-maturation lineage trees and return per-node AIRR records. + """Grow BCR lineage trees (neutral by default; set ``selection_strength > 0`` + and optionally ``target_aa`` to enable affinity maturation). Each clone gets its own lineage tree produced by the Rust ``simulate_family_outcomes`` kernel. The returned :class:`~GenAIRR.result.SimulationResultWithLineages` carries: - - ``.records`` — one AIRR dict per *observed* cell, tagged with - ``clone_id``, ``lineage_node_id``, ``lineage_parent_id``, + - ``.records`` — one AIRR dict per *observed* (genotype-collapsed) cell, + tagged with ``clone_id``, ``lineage_node_id``, ``lineage_parent_id``, ``lineage_generation``, ``lineage_abundance``, and - ``lineage_affinity``. Mutation counts (``n_mutations``, + ``lineage_affinity``. Because identical genotypes are collapsed before + sampling, the number of records per clone is ≤ ``n_sample``; the + ``lineage_abundance`` field accounts for how many sampled cells were + represented by each observed record. Mutation counts (``n_mutations``, ``n_v_mutations``, …) are pool-derived and self-consistent. - ``.lineage_trees`` — one :class:`~GenAIRR._engine.LineageTree` per clone for ground-truth export (Newick, FASTA, node table TSV). @@ -1077,28 +1080,38 @@ def clonal_lineage( n_max: Hard cap on total cells per clone (carrying capacity). n_sample: - Number of cells to sample as observed leaves. + Number of cells to sample as observed leaves. Records returned + per clone are ≤ ``n_sample`` because identical genotypes are + collapsed (duplicates are counted in ``lineage_abundance``). rate: Per-base SHM rate for within-lineage mutations. lambda_base: Poisson mean for offspring count at affinity 0. - lambda_mut: - Additional Poisson mean increase per affinity unit. selection_strength: - Sigmoid selection pressure; 0.0 = neutral drift. + Selection pressure; ``0.0`` = neutral drift (``lineage_affinity`` + will be 0.0 for every cell). Set ``> 0`` to enable affinity + maturation; combine with ``target_aa`` for a fixed antigen target. beta: - Scaling factor for the affinity term in selection. + Scaling factor for the affinity term in ``exp(−beta·distance)``. target_aa: - Target amino-acid string used to compute affinity (Hamming). - When ``None`` all cells get affinity 0.0. + Amino-acid sequence of the full receptor used to compute + per-cell affinity via a BLOSUM62-weighted distance (compared + position-wise against the cell's translated receptor; only + the overlapping prefix is scored when lengths differ). Must be + a non-empty string of standard amino-acid letters + (``ACDEFGHIKLMNPQRSTVWY``). When ``None``, an auto target is + generated from the founder by applying ``mature_substitutions`` + random residue changes. ``selection_strength=0`` makes + ``lineage_affinity ≡ 0`` regardless. mature_substitutions: - Minimum cumulative mutations a cell must accumulate before - it is considered a mature/observed cell. + Number of amino-acid substitutions used to build the auto + target (when ``target_aa`` is ``None``). s5f_model: Bundled S5F kernel name for within-lineage mutation context (``"hh_s5f"``, ``"hkl_s5f"``, …). """ import math + import warnings # --- n_clones --- if isinstance(n_clones, bool) or not isinstance(n_clones, int) or n_clones < 1: @@ -1125,9 +1138,6 @@ def clonal_lineage( # --- lambda_base --- if not isinstance(lambda_base, (int, float)) or not math.isfinite(lambda_base) or lambda_base < 0: raise ValueError(f"lambda_base must be a finite non-negative float, got {lambda_base!r}") - # --- lambda_mut --- - if not isinstance(lambda_mut, (int, float)) or not math.isfinite(lambda_mut) or lambda_mut < 0: - raise ValueError(f"lambda_mut must be a finite non-negative float, got {lambda_mut!r}") # --- beta --- if not isinstance(beta, (int, float)) or not math.isfinite(beta) or beta < 0: raise ValueError(f"beta must be a finite non-negative float, got {beta!r}") @@ -1145,6 +1155,38 @@ def clonal_lineage( raise ValueError( f"mature_substitutions must be a non-negative int, got {mature_substitutions!r}" ) + # --- target_aa --- + _VALID_AA = set("ACDEFGHIKLMNPQRSTVWY") + if target_aa is not None: + if not isinstance(target_aa, str) or len(target_aa) == 0: + raise ValueError( + "target_aa must be a non-empty amino-acid string " + "(letters from ACDEFGHIKLMNPQRSTVWY)" + ) + target_aa = target_aa.upper() + invalid = set(target_aa) - _VALID_AA + if invalid: + raise ValueError( + f"target_aa contains invalid characters {sorted(invalid)!r}; " + "only standard amino-acid letters (ACDEFGHIKLMNPQRSTVWY) are allowed" + ) + if len(target_aa) < 30: + warnings.warn( + f"target_aa has length {len(target_aa)}, which is shorter than a typical " + "receptor sequence (~300+ aa). If this is an epitope sequence rather than " + "the full receptor, affinity scoring will be based only on the overlapping " + "prefix — consider supplying the full translated receptor instead.", + UserWarning, + stacklevel=2, + ) + # --- s5f_model (validate at call time, not at run time) --- + from GenAIRR._s5f_loader import _BUILTIN_S5F_MODELS + _s5f_key = s5f_model.lower().strip() + if _s5f_key not in _BUILTIN_S5F_MODELS: + avail = ", ".join(f'"{k}"' for k in sorted(_BUILTIN_S5F_MODELS)) + raise ValueError( + f"Unknown s5f_model {s5f_model!r}. Available: {avail}" + ) # --- reject duplicate fork steps --- for s in self._steps: if isinstance(s, (_ClonalForkStep, _LineageForkStep)): @@ -1170,7 +1212,6 @@ def clonal_lineage( n_sample=n_sample, rate=rate, lambda_base=lambda_base, - lambda_mut=lambda_mut, selection_strength=selection_strength, beta=beta, target_aa=target_aa, @@ -2565,6 +2606,19 @@ def run_records( validate_records=validate_records, ) elif isinstance(compiled, CompiledLineageExperiment): + if n is not None: + raise ValueError( + "The 'n' parameter is not supported for clonal_lineage experiments. " + "The number of records is determined by n_clones and n_sample." + ) + if validate_records: + raise NotImplementedError( + "validate_records=True is not yet supported for clonal_lineage experiments." + ) + if expose_provenance: + raise NotImplementedError( + "expose_provenance=True is not yet supported for clonal_lineage experiments." + ) result = compiled.run_records( seed=seed, strict=strict, diff --git a/tests/test_clonal_lineage_validation.py b/tests/test_clonal_lineage_validation.py new file mode 100644 index 0000000..fa74b01 --- /dev/null +++ b/tests/test_clonal_lineage_validation.py @@ -0,0 +1,133 @@ +"""Tests for clonal_lineage DSL validation (Fix 1–4).""" +import pytest +import GenAIRR as ga + + +def _base_exp(**kw): + defaults = dict(n_clones=2, max_generations=4, n_max=100, n_sample=10, + rate=0.03, lambda_base=1.5) + defaults.update(kw) + return ga.Experiment.on("human_igh").recombine().clonal_lineage(**defaults) + + +# --------------------------------------------------------------------------- +# Fix 1: lambda_mut is removed — passing it must be a TypeError +# --------------------------------------------------------------------------- + +def test_lambda_mut_removed_raises_typeerror(): + """lambda_mut is no longer a parameter; passing it must raise TypeError.""" + with pytest.raises(TypeError): + _base_exp(lambda_mut=0.1) + + +# --------------------------------------------------------------------------- +# Fix 3: target_aa validation +# --------------------------------------------------------------------------- + +def test_target_aa_empty_string_raises(): + with pytest.raises(ValueError, match="non-empty amino-acid"): + _base_exp(target_aa="") + + +def test_target_aa_invalid_chars_raises(): + with pytest.raises(ValueError, match="invalid characters"): + _base_exp(target_aa="not-an-aa-$$") + + +def test_target_aa_invalid_chars_mixed_raises(): + with pytest.raises(ValueError, match="invalid characters"): + _base_exp(target_aa="ACDEFG1HIKLM") + + +def test_target_aa_non_aa_chars_raises(): + """Characters outside the 20 standard AAs (X, B, Z, U, O, numbers) must be rejected.""" + with pytest.raises(ValueError): + _base_exp(target_aa="XBZUOACDEFGHIKL") + with pytest.raises(ValueError): + _base_exp(target_aa="ACDEFG0HIKLMN") + + +def test_target_aa_none_is_valid(): + """None (auto-target) must be accepted without error.""" + exp = _base_exp(target_aa=None) + assert exp is not None + + +def test_target_aa_valid_long_string(): + """A realistic receptor-length AA string must be accepted.""" + aa = "ACDEFGHIKLMNPQRSTVWY" * 20 # 400 aa — long enough for no warning + exp = _base_exp(target_aa=aa) + assert exp is not None + + +def test_target_aa_short_triggers_warning(recwarn): + """A very short target_aa (< 30 aa) must emit a UserWarning.""" + aa = "ACDEFGHIKLMN" # 12 aa — below the 30-aa soft threshold + import warnings + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _base_exp(target_aa=aa) + user_warnings = [w for w in caught if issubclass(w.category, UserWarning)] + assert len(user_warnings) == 1 + assert "receptor" in str(user_warnings[0].message).lower() + + +# --------------------------------------------------------------------------- +# Fix 3: s5f_model validation at call time +# --------------------------------------------------------------------------- + +def test_s5f_model_bogus_raises_at_call_time(): + """A typo in s5f_model must error at clonal_lineage() call time.""" + with pytest.raises(ValueError, match="Unknown s5f_model"): + _base_exp(s5f_model="bogus") + + +def test_s5f_model_valid_accepted(): + for model in ("hh_s5f", "hkl_s5f", "hh_s5f_60", "hh_s5f_opposite"): + exp = _base_exp(s5f_model=model) + assert exp is not None + + +# --------------------------------------------------------------------------- +# Fix 4: unsupported run_records kwargs raise on lineage path +# --------------------------------------------------------------------------- + +def _compiled_lineage(): + return _base_exp() + + +def test_validate_records_true_raises_for_lineage(): + with pytest.raises((NotImplementedError, ValueError)): + _compiled_lineage().run_records(validate_records=True) + + +def test_expose_provenance_true_raises_for_lineage(): + with pytest.raises((NotImplementedError, ValueError)): + _compiled_lineage().run_records(expose_provenance=True) + + +def test_n_not_none_raises_for_lineage(): + with pytest.raises((NotImplementedError, ValueError)): + _compiled_lineage().run_records(n=5) + + +def test_seed_and_strict_still_work_for_lineage(): + """seed and strict must work — they are the only supported kwargs.""" + result = _compiled_lineage().run_records(seed=42, strict=False) + assert len(result.records) > 0 + + +# --------------------------------------------------------------------------- +# Smoke test: a valid call still works end-to-end +# --------------------------------------------------------------------------- + +def test_valid_clonal_lineage_call_works(): + result = _base_exp( + n_clones=2, + selection_strength=0.0, + target_aa=None, + ).run_records(seed=7) + assert len(result.records) > 0 + for rec in result.records: + assert "lineage_affinity" in rec + assert "clone_id" in rec From 449c7fb2892392ef683e5635a9c429ae4ed916ea Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 20:10:38 +0300 Subject: [PATCH 42/59] fix(lineage): self-consistent node Outcomes (synthesize SHM events + mutation_count) so validation passes Lineage node Outcomes were built with an empty event ledger but a nonzero Simulation.mutation_count, causing validate_record to false-fail the mutation-count sum invariant (MutationCountSumMismatch) on mutated nodes, and build_airr_record to report zero per-segment SHM counts while n_mutations was nonzero. Fix: synthesize_shm_event_record scans sim.pool for positions where base != germline and emits one SimulationEvent::BaseChanged per mutated site into a single MUTATE_S5F EventRecord. mutation_count is then set to the net pool-mutation count. The resulting Outcome is self-consistent: build_airr_record reads the events to derive per-segment and V-subregion counts, and mutation_count matches the event count so the validator passes. Consequence: PyFamilyOutcome::airr_records no longer needs the manual pool_mutation_counts dict-overwrite path. It now delegates to build_airr_record directly, removing the PoolMutCounts struct and pool_mutation_counts helper (~90 lines net reduction). --- engine_rs/src/python/lineage.rs | 187 +++++++---------------- tests/test_lineage_outcome_validation.py | 85 +++++++++++ 2 files changed, 143 insertions(+), 129 deletions(-) create mode 100644 tests/test_lineage_outcome_validation.py diff --git a/engine_rs/src/python/lineage.rs b/engine_rs/src/python/lineage.rs index 2bf49ce..6c20fb7 100644 --- a/engine_rs/src/python/lineage.rs +++ b/engine_rs/src/python/lineage.rs @@ -5,14 +5,14 @@ use pyo3::prelude::*; use pyo3::types::PyDict; use crate::airr_record::build_airr_record; -use crate::ir::Segment; +use crate::event::{EventRecord, StateSummary, TraceSpan}; +use crate::ir::{NucHandle, SimulationEvent}; use crate::ir::Simulation; use crate::lineage::export::{to_fasta, to_newick, to_node_table_tsv}; use crate::lineage::tree::{LineageNode, LineageTree}; use crate::lineage::{simulate_family, simulate_family_sims, simulate_family_with_affinity, sim_to_aa, AffinityModel, BranchingParams}; use crate::lineage::affinity::make_mature_target; -use crate::pass::Outcome; -use crate::refdata::RefDataConfig; +use crate::pass::{Outcome, PassCompileEffect}; use crate::rng::Rng; use crate::passes::S5FMutationPass; use crate::s5f::S5FKernel; @@ -296,11 +296,13 @@ impl PyFamilyOutcome { /// Build AIRR Rearrangement record dicts for every observed node, /// in node-id order (aligned with `observed_outcomes()`). /// - /// Per-segment and V-subregion mutation counts are recomputed from - /// the node's final simulation pool (net mutations from germline: - /// positions where `base != germline`), overwriting the zero - /// counts that `build_airr_record` would produce from the empty - /// event ledger carried by lineage node `Outcome`s. + /// Each node `Outcome` now carries a synthesized `MUTATE_S5F` + /// `EventRecord` encoding every pool position where + /// `base != germline` as a `BaseChanged` event, and + /// `mutation_count` is set to the number of such events. + /// `build_airr_record` therefore produces correct per-segment + /// and V-subregion mutation counts natively, without any manual + /// field overwrite. fn airr_records<'py>( &self, py: Python<'py>, @@ -312,33 +314,6 @@ impl PyFamilyOutcome { let sequence_id = format!("node{}", node_id); let rec = build_airr_record(outcome, refdata.inner(), &sequence_id); let dict = airr_record_to_pydict(py, &rec)?; - - // Recompute mutation counts from the pool (net mutations - // from germline) to fix the empty-event-ledger bug. - let sim = outcome.final_simulation(); - let counts = pool_mutation_counts(sim, refdata.inner()); - - dict.set_item("n_mutations", counts.n_mutations)?; - dict.set_item("n_v_mutations", counts.n_v_mutations)?; - dict.set_item("n_d_mutations", counts.n_d_mutations)?; - dict.set_item("n_j_mutations", counts.n_j_mutations)?; - dict.set_item("n_np_mutations", counts.n_np_mutations)?; - dict.set_item("n_fwr1_mutations", counts.n_fwr1_mutations)?; - dict.set_item("n_cdr1_mutations", counts.n_cdr1_mutations)?; - dict.set_item("n_fwr2_mutations", counts.n_fwr2_mutations)?; - dict.set_item("n_cdr2_mutations", counts.n_cdr2_mutations)?; - dict.set_item("n_fwr3_mutations", counts.n_fwr3_mutations)?; - dict.set_item("n_v_unannotated_mutations", counts.n_v_unannotated_mutations)?; - - // Recompute mutation_rate = n_mutations / sequence_length. - let seq_len = rec.sequence_length; - let mutation_rate = if seq_len > 0 { - counts.n_mutations as f64 / seq_len as f64 - } else { - 0.0 - }; - dict.set_item("mutation_rate", mutation_rate)?; - results.push(dict); } Ok(results) @@ -346,105 +321,48 @@ impl PyFamilyOutcome { } // ────────────────────────────────────────────────────────────────── -// Pool-derived mutation counter helper +// SHM event synthesis helper // ────────────────────────────────────────────────────────────────── -/// Per-segment and V-subregion mutation counts derived by scanning -/// the simulation pool for positions where `base != germline`. -/// This is the "net mutations from germline" quantity that lineage -/// tools use, computed without relying on the event ledger. -struct PoolMutCounts { - n_mutations: i64, - n_v_mutations: i64, - n_d_mutations: i64, - n_j_mutations: i64, - n_np_mutations: i64, - n_fwr1_mutations: i64, - n_cdr1_mutations: i64, - n_fwr2_mutations: i64, - n_cdr2_mutations: i64, - n_fwr3_mutations: i64, - n_v_unannotated_mutations: i64, -} - -/// Scan `sim.pool` and count positions where `base != germline`, -/// bucketing by segment and (for V) by V-subregion. Mirrors the -/// event-loop logic in `airr_record/builder.rs` exactly: same -/// `v_subregions` table lookup, same `germline_pos.get()` → Option -/// coordinate, same fallthrough to `n_v_unannotated_mutations`. -fn pool_mutation_counts(sim: &Simulation, refdata: &RefDataConfig) -> PoolMutCounts { - // Hoist the V-allele subregion table out of the per-nucleotide - // loop (invariant within a record) — same pattern as builder.rs. - let v_subregions: Option<&[crate::refdata::VSubregion]> = sim - .assignments - .get(Segment::V) - .and_then(|inst| refdata.v_pool.get(inst.allele_id)) - .map(|allele| allele.subregions.as_slice()); - - let mut n_v_mutations = 0i64; - let mut n_d_mutations = 0i64; - let mut n_j_mutations = 0i64; - let mut n_np_mutations = 0i64; - let mut n_fwr1_mutations = 0i64; - let mut n_cdr1_mutations = 0i64; - let mut n_fwr2_mutations = 0i64; - let mut n_cdr2_mutations = 0i64; - let mut n_fwr3_mutations = 0i64; - let mut n_v_unannotated_mutations = 0i64; - - for nuc in sim.pool.as_slice() { - // A position is mutated iff its current base differs from germline. - // No case folding — the engine stores mutations as lowercase bytes - // (SHM traces lowercase), so a raw byte comparison is correct and - // mirrors `base != germline` in the event-loop path. +/// Synthesize a single `EventRecord` that encodes all net SHM +/// mutations visible in `sim.pool` (positions where `base != +/// germline`) as `SimulationEvent::BaseChanged` events. +/// +/// The record is tagged with `address::MUTATE_S5F` as its pass +/// name so the AIRR builder's event-loop filter picks it up and +/// the per-segment / per-subregion mutation counts it produces are +/// identical to the pool-scan. The returned count equals the number +/// of synthesized events; callers should set `sim.mutation_count` +/// to this value before wrapping `sim` in an `Outcome`. +fn synthesize_shm_event_record(sim: &Simulation) -> (EventRecord, u32) { + let mut events: Vec = Vec::new(); + for (i, nuc) in sim.pool.as_slice().iter().enumerate() { if nuc.base == nuc.germline { continue; } - match nuc.segment { - Segment::V => { - n_v_mutations += 1; - // Dispatch into the V-subregion partition using the same - // logic as builder.rs:321-347. `germline_pos.get()` yields - // the allele-relative Option coordinate that the - // VSubregion [start, end) intervals are keyed on. - let label = v_subregions.and_then(|subs| { - nuc.germline_pos.get().and_then(|pos| { - subs.iter() - .find(|s| s.start <= pos && pos < s.end) - .map(|s| s.label) - }) - }); - match label { - Some(crate::refdata::VSubregionLabel::Fwr1) => n_fwr1_mutations += 1, - Some(crate::refdata::VSubregionLabel::Cdr1) => n_cdr1_mutations += 1, - Some(crate::refdata::VSubregionLabel::Fwr2) => n_fwr2_mutations += 1, - Some(crate::refdata::VSubregionLabel::Cdr2) => n_cdr2_mutations += 1, - Some(crate::refdata::VSubregionLabel::Fwr3) => n_fwr3_mutations += 1, - None => n_v_unannotated_mutations += 1, - } - } - Segment::D => n_d_mutations += 1, - Segment::J => n_j_mutations += 1, - Segment::Np1 | Segment::Np2 => n_np_mutations += 1, - } - } - - let n_mutations = n_v_mutations + n_d_mutations + n_j_mutations + n_np_mutations; - PoolMutCounts { - n_mutations, - n_v_mutations, - n_d_mutations, - n_j_mutations, - n_np_mutations, - n_fwr1_mutations, - n_cdr1_mutations, - n_fwr2_mutations, - n_cdr2_mutations, - n_fwr3_mutations, - n_v_unannotated_mutations, - } + events.push(SimulationEvent::BaseChanged { + handle: NucHandle::new(i as u32), + old_base: nuc.germline, + new_base: nuc.base, + segment: nuc.segment, + germline_pos: nuc.germline_pos.get(), + }); + } + let net = events.len() as u32; + let summary = StateSummary::from_simulation(sim); + let record = EventRecord::pass_committed( + 0, + crate::address::MUTATE_S5F, + vec![PassCompileEffect::EditBases], + TraceSpan::new(0, 0), + summary, + summary, + events, + ); + (record, net) } + /// Grow + sample a clonal lineage family from `founder` (a full `Outcome`) using /// an S5F mutator, returning a `FamilyOutcome` with per-node AIRR-projectable /// `Outcome`s for every observed (sampled) node. @@ -569,11 +487,22 @@ pub(crate) fn simulate_family_outcomes( .iter() .map(|n| { if n.observed { + let s = &sims[n.id as usize]; + // Synthesize a MUTATE_S5F EventRecord encoding every + // pool position where base != germline as a + // BaseChanged event. This makes the Outcome + // self-consistent: build_airr_record reads these + // events to derive per-segment / per-subregion + // mutation counts, and the net count is stored in + // mutation_count so the NMutationsMismatch validator + // check passes. + let (event_record, net) = synthesize_shm_event_record(s); + let s2 = s.with_mutation_count(net); Some(Outcome { - revisions: vec![sims[n.id as usize].clone()], + revisions: vec![s2], pass_names: Vec::new(), trace: founder_trace.clone(), - events: Vec::new(), + events: vec![event_record], }) } else { None diff --git a/tests/test_lineage_outcome_validation.py b/tests/test_lineage_outcome_validation.py new file mode 100644 index 0000000..6c9d238 --- /dev/null +++ b/tests/test_lineage_outcome_validation.py @@ -0,0 +1,85 @@ +""" +Correctness test: per-node Outcomes from simulate_family_outcomes must be +self-consistent so that native build_airr_record + validate_record produce +correct mutation-count fields and pass mutation-count invariants. + +Bug fixed: node Outcomes were built with an empty event ledger but a +nonzero Simulation.mutation_count, so validate_record would false-fail the +mutation-count sum invariant on mutated nodes. + +Note: AlleleCallTieSetMismatch issues on D can still appear for SHM-mutated +nodes (SHM changes D-segment match scores, making formerly-unique calls +ambiguous). These are a biological reality of the lineage path, not caused +by the event-ledger bug, and are not checked here. +""" +import GenAIRR as ga +from GenAIRR import _engine +from GenAIRR._s5f_loader import load_builtin_s5f_kernel + +# Mutation-count issue kinds that this fix must eliminate. +_MUTATION_COUNT_KINDS = { + "NMutationsMismatch", + "NVMutationsMismatch", + "NDMutationsMismatch", + "NJMutationsMismatch", + "NNpMutationsMismatch", + "MutationCountSumMismatch", + "NFwr1MutationsMismatch", + "NCdr1MutationsMismatch", + "NFwr2MutationsMismatch", + "NCdr2MutationsMismatch", + "NFwr3MutationsMismatch", + "NVUnannotatedMutationsMismatch", + "VSubregionMutationCountSumMismatch", +} + + +def _founder_refdata(): + c = ga.Experiment.on("human_igh").recombine().compile() + return c.run(n=1, seed=0)[0], c.refdata + + +def test_node_outcomes_no_mutation_count_issues(): + """After the fix, no mutation-count validation issues on any lineage node.""" + founder, refdata = _founder_refdata() + mut, sub = load_builtin_s5f_kernel("hh_s5f") + fam = _engine.simulate_family_outcomes(founder, mut, sub, 0.05, 1.6, 0.0, 6, 300, 30, 7) + saw_mut = False + for o in fam.observed_outcomes(): + issues = o.validate_record(refdata) + mut_issues = [i for i in issues if i["kind"] in _MUTATION_COUNT_KINDS] + assert len(mut_issues) == 0, f"mutation-count validate_record issues: {mut_issues}" + rec = o.airr_record(refdata) + if rec["n_mutations"] > 0: + saw_mut = True + per_seg = (rec["n_v_mutations"] + rec["n_d_mutations"] + + rec["n_j_mutations"] + rec["n_np_mutations"]) + assert rec["n_mutations"] == per_seg, ( + f"n_mutations {rec['n_mutations']} != per-segment sum {per_seg}" + ) + v_sub = (rec["n_fwr1_mutations"] + rec["n_cdr1_mutations"] + + rec["n_fwr2_mutations"] + rec["n_cdr2_mutations"] + + rec["n_fwr3_mutations"] + rec["n_v_unannotated_mutations"]) + assert rec["n_v_mutations"] == v_sub, ( + f"n_v_mutations {rec['n_v_mutations']} != subregion sum {v_sub}" + ) + assert saw_mut, "expected at least one mutated observed node" + + +def test_airr_records_mutation_counts_consistent(): + """PyFamilyOutcome.airr_records() produces consistent mutation counts.""" + founder, refdata = _founder_refdata() + mut, sub = load_builtin_s5f_kernel("hh_s5f") + fam = _engine.simulate_family_outcomes(founder, mut, sub, 0.05, 1.6, 0.0, 6, 300, 30, 7) + recs = fam.airr_records(refdata) + assert len(recs) >= 1 + saw_mut = False + for rec in recs: + per_seg = (rec["n_v_mutations"] + rec["n_d_mutations"] + + rec["n_j_mutations"] + rec["n_np_mutations"]) + assert rec["n_mutations"] == per_seg, ( + f"n_mutations {rec['n_mutations']} != per-segment sum {per_seg}" + ) + if rec["n_mutations"] > 0: + saw_mut = True + assert saw_mut, "expected at least one mutated node record" From 4d7b2c289bca36595de07f110af8037f6c5e4ac2 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 20:19:14 +0300 Subject: [PATCH 43/59] fix(lineage): refresh live-call cache on node sims so v/d/j calls match the mutated sequence --- engine_rs/src/live_call/mod.rs | 2 +- engine_rs/src/live_call/refresh_hook.rs | 20 ++++++++++++++++++++ engine_rs/src/python/lineage.rs | 16 ++++++++++++++-- src/GenAIRR/_compiled.py | 1 + tests/test_lineage_mutation_counts.py | 2 +- tests/test_lineage_outcome_validation.py | 21 +++++++++++++++++++-- 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/engine_rs/src/live_call/mod.rs b/engine_rs/src/live_call/mod.rs index 7b1d535..474d0c0 100644 --- a/engine_rs/src/live_call/mod.rs +++ b/engine_rs/src/live_call/mod.rs @@ -28,7 +28,7 @@ pub use reference_index::{ BaseBitsets, BaseEvidence, BoundaryIndex, IndexedAllele, KmerHit, KmerIndex, ReferenceMatchIndex, SegmentRefIndex, DEFAULT_REFERENCE_KMER_LEN, }; -pub use refresh_hook::LiveCallRefreshHook; +pub use refresh_hook::{refresh_live_calls, LiveCallRefreshHook}; pub use refresh_plan::{LiveCallRefreshPlan, LiveCallRefreshStep}; fn assert_live_segment(segment: Segment) { diff --git a/engine_rs/src/live_call/refresh_hook.rs b/engine_rs/src/live_call/refresh_hook.rs index 64b750c..90fa844 100644 --- a/engine_rs/src/live_call/refresh_hook.rs +++ b/engine_rs/src/live_call/refresh_hook.rs @@ -98,6 +98,26 @@ impl EffectHook for LiveCallRefreshHook { } } +/// Public, unconditional V/D/J live-call refresh for a simulation +/// whose pool was edited outside the compiled runtime (e.g. the +/// clonal-lineage branching loop, which mutates child sims with a +/// bare `Pass::execute` and so never runs `LiveCallRefreshHook`). +/// +/// Unlike [`refresh_segments_for_edit`], this recomputes the call +/// for *every* assignable segment from scratch against the current +/// pool, regardless of the dirty log — the caller may have applied +/// many independent edits whose dirty windows were never threaded. +/// The dirty log is drained so the returned sim is in a clean state. +pub fn refresh_live_calls( + mut sim: Simulation, + reference_index: &ReferenceMatchIndex, +) -> Simulation { + for &segment in Segment::assignable() { + sim = with_assembled_segment_live_call(&sim, reference_index, segment); + } + drain_dirty_windows(sim) +} + /// Refresh V/D/J live calls after a base-edit pass, using dirty /// windows stamped by the pass's `DirtySignalObserver` to skip /// segments whose region doesn't overlap any dirty position. diff --git a/engine_rs/src/python/lineage.rs b/engine_rs/src/python/lineage.rs index 6c20fb7..ee76990 100644 --- a/engine_rs/src/python/lineage.rs +++ b/engine_rs/src/python/lineage.rs @@ -12,6 +12,7 @@ use crate::lineage::export::{to_fasta, to_newick, to_node_table_tsv}; use crate::lineage::tree::{LineageNode, LineageTree}; use crate::lineage::{simulate_family, simulate_family_sims, simulate_family_with_affinity, sim_to_aa, AffinityModel, BranchingParams}; use crate::lineage::affinity::make_mature_target; +use crate::live_call::{refresh_live_calls, ReferenceMatchIndex}; use crate::pass::{Outcome, PassCompileEffect}; use crate::rng::Rng; use crate::passes::S5FMutationPass; @@ -368,13 +369,14 @@ fn synthesize_shm_event_record(sim: &Simulation) -> (EventRecord, u32) { /// `Outcome`s for every observed (sampled) node. #[pyfunction] #[pyo3(signature = ( - founder, mutability, substitution, rate, + founder, refdata, mutability, substitution, rate, lambda_base, lambda_mut, max_generations, n_max, n_sample, seed, selection_strength=0.0, beta=1.0, target_aa=None, mature_substitutions=5 ))] #[allow(clippy::too_many_arguments)] pub(crate) fn simulate_family_outcomes( founder: &PyOutcome, + refdata: &PyRefDataConfig, mutability: Vec, substitution: Vec, rate: f64, @@ -482,12 +484,22 @@ pub(crate) fn simulate_family_outcomes( let (tree, sims) = simulate_family_sims(&founder_sim, ¶ms, &mutator, model.as_ref()); + // The branching loop mutates child sims with a bare `Pass::execute` + // (no `LiveCallRefreshHook`), so each node sim's `segment_calls` + // sidecar still reflects the FOUNDER's alignment, not its own + // mutated pool. Recompute the V/D/J live calls from each observed + // node's pool before building its Outcome; otherwise the + // synthesized record carries stale v/d/j calls and validate_record + // false-fails with AlleleCallTieSetMismatch under heavy SHM. + let reference_index = ReferenceMatchIndex::build(refdata.inner()); + let node_outcomes: Vec> = tree .nodes .iter() .map(|n| { if n.observed { - let s = &sims[n.id as usize]; + let refreshed = refresh_live_calls(sims[n.id as usize].clone(), &reference_index); + let s = &refreshed; // Synthesize a MUTATE_S5F EventRecord encoding every // pool position where base != germline as a // BaseChanged event. This makes the Outcome diff --git a/src/GenAIRR/_compiled.py b/src/GenAIRR/_compiled.py index 448258a..679f9ce 100644 --- a/src/GenAIRR/_compiled.py +++ b/src/GenAIRR/_compiled.py @@ -561,6 +561,7 @@ def run_records( founder = self._pre.run(seed=clone_seed, strict=strict) fam = _eng.simulate_family_outcomes( founder, + self._refdata, mutability, substitution, step.rate, diff --git a/tests/test_lineage_mutation_counts.py b/tests/test_lineage_mutation_counts.py index c57c5f7..ae6771b 100644 --- a/tests/test_lineage_mutation_counts.py +++ b/tests/test_lineage_mutation_counts.py @@ -11,7 +11,7 @@ def _founder_and_refdata(): def test_lineage_record_mutation_counts_are_consistent(): founder, refdata = _founder_and_refdata() mut, sub = load_builtin_s5f_kernel("hh_s5f") - fam = _engine.simulate_family_outcomes(founder, mut, sub, 0.1, 1.6, 0.0, 8, 300, 30, 7) + fam = _engine.simulate_family_outcomes(founder, refdata, mut, sub, 0.1, 1.6, 0.0, 8, 300, 30, 7) recs = fam.airr_records(refdata) # NEW method (Task 1b) assert len(recs) >= 1 saw_mutated = False diff --git a/tests/test_lineage_outcome_validation.py b/tests/test_lineage_outcome_validation.py index 6c9d238..9e98ca8 100644 --- a/tests/test_lineage_outcome_validation.py +++ b/tests/test_lineage_outcome_validation.py @@ -43,7 +43,7 @@ def test_node_outcomes_no_mutation_count_issues(): """After the fix, no mutation-count validation issues on any lineage node.""" founder, refdata = _founder_refdata() mut, sub = load_builtin_s5f_kernel("hh_s5f") - fam = _engine.simulate_family_outcomes(founder, mut, sub, 0.05, 1.6, 0.0, 6, 300, 30, 7) + fam = _engine.simulate_family_outcomes(founder, refdata, mut, sub, 0.05, 1.6, 0.0, 6, 300, 30, 7) saw_mut = False for o in fam.observed_outcomes(): issues = o.validate_record(refdata) @@ -66,11 +66,28 @@ def test_node_outcomes_no_mutation_count_issues(): assert saw_mut, "expected at least one mutated observed node" +def test_node_outcomes_validate_clean_under_heavy_shm(): + """Heavy SHM lineage: every observed node Outcome must validate cleanly. + + Regression for the stale live-call cache bug: node sims carried the + founder's allele calls instead of the mutated sequence's, so under heavy + SHM validate_record reported AlleleCallTieSetMismatch. After refreshing + the live-call cache on each observed node sim, this must be clean. + """ + founder, refdata = _founder_refdata() + mut, sub = load_builtin_s5f_kernel("hh_s5f") + fam = _engine.simulate_family_outcomes(founder, refdata, mut, sub, 0.05, 1.6, 0.0, 6, 300, 30, 7) + issues_total = 0 + for o in fam.observed_outcomes(): + issues_total += len(o.validate_record(refdata)) + assert issues_total == 0, f"validate_record reported {issues_total} issues under heavy SHM" + + def test_airr_records_mutation_counts_consistent(): """PyFamilyOutcome.airr_records() produces consistent mutation counts.""" founder, refdata = _founder_refdata() mut, sub = load_builtin_s5f_kernel("hh_s5f") - fam = _engine.simulate_family_outcomes(founder, mut, sub, 0.05, 1.6, 0.0, 6, 300, 30, 7) + fam = _engine.simulate_family_outcomes(founder, refdata, mut, sub, 0.05, 1.6, 0.0, 6, 300, 30, 7) recs = fam.airr_records(refdata) assert len(recs) >= 1 saw_mut = False From 11bc982bd22615b1dd115d4427faf1c71d742bb7 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 15 Jun 2026 23:23:55 +0300 Subject: [PATCH 44/59] feat(lineage): apply post-fork corruption to clonal_lineage reads (per-cell library-prep artefacts) --- engine_rs/src/python/lineage.rs | 34 +++++++++++ engine_rs/src/python/mod.rs | 1 + src/GenAIRR/_compiled.py | 75 ++++++++++++++++++++----- src/GenAIRR/experiment.py | 48 ++++++++++++++-- tests/test_clonal_lineage_corruption.py | 74 ++++++++++++++++++++++++ 5 files changed, 212 insertions(+), 20 deletions(-) create mode 100644 tests/test_clonal_lineage_corruption.py diff --git a/engine_rs/src/python/lineage.rs b/engine_rs/src/python/lineage.rs index ee76990..391b8fd 100644 --- a/engine_rs/src/python/lineage.rs +++ b/engine_rs/src/python/lineage.rs @@ -364,6 +364,40 @@ fn synthesize_shm_event_record(sim: &Simulation) -> (EventRecord, u32) { } +/// Merge a per-node lineage `Outcome` with a corruption `Outcome` that was +/// run *from* the lineage node's final simulation. +/// +/// `base` is a lineage node `Outcome`: its `trace` is the founder +/// recombination trace, its `events` is the synthesized SHM `EventRecord`, +/// and its final simulation is post-SHM (`mutation_count` = net SHM). +/// +/// `corruption` is the corruption plan run from `base`'s final simulation: +/// its `trace` holds the corruption choices, its `events` holds the +/// corruption `EventRecord`s, and its final simulation is the corrupted +/// molecule (SHM `mutation_count` is preserved by the corruption passes). +/// +/// The merged `Outcome` carries the corrupted final simulation (so sequence +/// + quality bytes reflect the artefacts) but the *concatenation* of both +/// traces and both event ledgers, so the AIRR builder derives BOTH the SHM +/// per-segment counts (from the synthesized SHM event) AND the corruption +/// counters (`n_quality_errors`, `n_pcr_errors`, `n_indels`, …) from the +/// corruption events / trace. +#[pyfunction] +pub(crate) fn merge_lineage_corruption(base: &PyOutcome, corruption: &PyOutcome) -> PyOutcome { + let mut trace = base.inner.trace.clone(); + trace.append_delta(corruption.inner.trace.clone()); + + let mut events = base.inner.events.clone(); + events.extend(corruption.inner.events.iter().cloned()); + + PyOutcome::new(Outcome { + revisions: corruption.inner.revisions.clone(), + pass_names: corruption.inner.pass_names.clone(), + trace, + events, + }) +} + /// Grow + sample a clonal lineage family from `founder` (a full `Outcome`) using /// an S5F mutator, returning a `FamilyOutcome` with per-node AIRR-projectable /// `Outcome`s for every observed (sampled) node. diff --git a/engine_rs/src/python/mod.rs b/engine_rs/src/python/mod.rs index f579181..3a56fec 100644 --- a/engine_rs/src/python/mod.rs +++ b/engine_rs/src/python/mod.rs @@ -53,6 +53,7 @@ pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_function(pyo3::wrap_pyfunction!(lineage::simulate_lineage, m)?)?; m.add_function(pyo3::wrap_pyfunction!(lineage::simulate_family_outcomes, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(lineage::merge_lineage_corruption, m)?)?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/src/GenAIRR/_compiled.py b/src/GenAIRR/_compiled.py index 679f9ce..1959453 100644 --- a/src/GenAIRR/_compiled.py +++ b/src/GenAIRR/_compiled.py @@ -513,19 +513,33 @@ class CompiledLineageExperiment: per-observed-node AIRR dicts with lineage metadata. """ - __slots__ = ("_pre", "_step", "_refdata", "_dataconfig", "_metadata") + __slots__ = ( + "_pre", + "_step", + "_refdata", + "_post", + "_post_steps", + "_dataconfig", + "_metadata", + ) def __init__( self, pre_simulator: "_engine.CompiledSimulator", step: "_LineageForkStep", refdata: "_engine.RefDataConfig", + post_simulator: Optional["_engine.CompiledSimulator"] = None, + post_steps: Sequence[Any] = (), dataconfig: Optional["DataConfig"] = None, metadata: Optional[Dict[str, Any]] = None, ) -> None: self._pre = pre_simulator self._step = step self._refdata = refdata + # Per-observed-cell library-prep / sequencing corruption plan. + # ``None`` keeps the pristine-read path (no artefacts applied). + self._post = post_simulator + self._post_steps: Tuple[Any, ...] = tuple(post_steps) self._dataconfig = dataconfig self._metadata = dict(metadata) if metadata else {} @@ -545,6 +559,7 @@ def run_records( independent clones are reproducible and non-overlapping. """ from GenAIRR import _engine as _eng + from ._airr_record import outcome_to_airr_record from ._s5f_loader import load_builtin_s5f_kernel from .result import SimulationResultWithLineages @@ -579,24 +594,56 @@ def run_records( tree = fam.tree() lineage_trees.append(tree) - # Collect observed nodes in id order (same order as airr_records). - observed_nodes = [n for n in tree.nodes() if n.observed] - recs = fam.airr_records(self._refdata) - - for node, rec in zip(observed_nodes, recs): - rec["clone_id"] = clone_idx - rec["lineage_node_id"] = node.id - rec["lineage_parent_id"] = ( - node.parent_id if node.parent_id is not None else -1 + if self._post is None: + # Pristine-read path: project each observed node's + # synthesized SHM Outcome straight to an AIRR record. + observed_nodes = [n for n in tree.nodes() if n.observed] + recs = fam.airr_records(self._refdata) + for node, rec in zip(observed_nodes, recs): + self._stamp_lineage_metadata(rec, clone_idx, node) + records.append(rec) + continue + + # Corruption path: per observed node, run the library-prep / + # sequencing corruption plan FROM the node's post-SHM + # simulation, then merge the founder-recombination + SHM + # provenance with the corruption trace / events so the AIRR + # record reports trims, v/d/j, SHM counts AND artefact + # counters (n_quality_errors, n_pcr_errors, n_indels, …). + node_outcomes = fam.node_outcomes() + for node, base_outcome in zip(tree.nodes(), node_outcomes): + if base_outcome is None: + continue + node_seed = clone_seed + 1 + node.id + corruption_outcome = self._post.run_from( + base_outcome.final_simulation(), node_seed, strict=strict + ) + merged = _eng.merge_lineage_corruption( + base_outcome, corruption_outcome ) - rec["lineage_generation"] = node.generation - rec["lineage_abundance"] = node.abundance - rec["lineage_affinity"] = node.affinity - rec["sequence_id"] = f"clone{clone_idx}_node{node.id}" + rec = outcome_to_airr_record( + merged, + self._refdata, + sequence_id=f"clone{clone_idx}_node{node.id}", + ) + self._stamp_lineage_metadata(rec, clone_idx, node) records.append(rec) return SimulationResultWithLineages(records, lineage_trees=lineage_trees) + @staticmethod + def _stamp_lineage_metadata(rec: Dict[str, Any], clone_idx: int, node) -> None: + """Stamp clone + lineage-node provenance onto an AIRR record.""" + rec["clone_id"] = clone_idx + rec["lineage_node_id"] = node.id + rec["lineage_parent_id"] = ( + node.parent_id if node.parent_id is not None else -1 + ) + rec["lineage_generation"] = node.generation + rec["lineage_abundance"] = node.abundance + rec["lineage_affinity"] = node.affinity + rec["sequence_id"] = f"clone{clone_idx}_node{node.id}" + def __repr__(self) -> str: return ( f" 0 + # Corruption was actually applied to at least one observed cell. + assert any(r.get("n_quality_errors", 0) > 0 for r in result.records) + for r in result.records: + # Founder recombination provenance survives the merge. + assert r["v_call"] + assert r["clone_id"] in (0, 1, 2) + assert "lineage_node_id" in r + # SHM per-segment counts stay self-consistent after corruption merge. + assert r["n_mutations"] == ( + r["n_v_mutations"] + + r["n_d_mutations"] + + r["n_j_mutations"] + + r["n_np_mutations"] + ) + + +def test_clonal_lineage_rejects_mutate_after(): + with pytest.raises(Exception): + ( + ga.Experiment.on("human_igh") + .recombine() + .clonal_lineage(n_clones=2, n_sample=5) + .mutate(rate=0.05) + .compile() + ) + + +def test_clonal_lineage_rejects_paired_end_after(): + with pytest.raises(Exception): + ( + ga.Experiment.on("human_igh") + .recombine() + .clonal_lineage(n_clones=2, n_sample=5) + .paired_end(r1_length=150, insert_size=300) + .compile() + ) + + +def test_clonal_lineage_without_corruption_still_works(): + result = ( + ga.Experiment.on("human_igh") + .recombine() + .clonal_lineage(n_clones=2, n_sample=10, rate=0.02) + .run_records(seed=0) + ) + assert len(result.records) > 0 + # No corruption pass → no quality errors stamped. + assert all(r.get("n_quality_errors", 0) == 0 for r in result.records) From 58de262fdfda6997d052f0605a44e2f8247085c5 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 08:14:43 +0300 Subject: [PATCH 45/59] feat(lineage): validate_records/expose_provenance support + honest expand_clones deprecation + corruption docs --- site_docs/guides/clonal-lineage.md | 55 +++++++++++++++- src/GenAIRR/_compiled.py | 55 +++++++++++++++- src/GenAIRR/experiment.py | 26 ++++---- tests/test_clonal_lineage_validation.py | 87 +++++++++++++++++++++---- 4 files changed, 192 insertions(+), 31 deletions(-) diff --git a/site_docs/guides/clonal-lineage.md b/site_docs/guides/clonal-lineage.md index 26ec663..a8e150b 100644 --- a/site_docs/guides/clonal-lineage.md +++ b/site_docs/guides/clonal-lineage.md @@ -226,6 +226,40 @@ inference tools: abundance, observed, affinity, sequence`. - `nodes()` / `validate()` — node access and a structural-invariant check. +## Library-prep & sequencing artefacts + +Library-prep and sequencing passes can follow `clonal_lineage` — they run +**independently on each observed cell**, so every read picks up its own noise: + +```python +result = (ga.Experiment.on("human_igh").recombine() + .clonal_lineage(n_clones=10, n_sample=30, rate=0.01, selection_strength=10) + .sequencing_errors(rate=0.005) + .pcr_amplify(rate=0.002) + .run_records(seed=0)) +``` + +Each observed cell's post-SHM sequence is passed through the corruption plan with +its own seed, and the resulting artefacts are merged back onto the cell's record. +The founder's recombination provenance (`v_call`, `d_call`, `j_call`, trims, +junction) **and** the per-segment SHM counts are preserved; the record additionally +reports the artefact counters (`n_quality_errors`, `n_pcr_errors`, `n_indels`, …). +Supported passes are the same per-read library-prep set `expand_clones` allows: +`sequencing_errors`, `pcr_amplify`, `polymerase_indels`, `end_loss_*`, +`ambiguous_base_calls`, `random_strand_orientation`. + +`mutate` is **not** allowed after `clonal_lineage` — SHM is internal to the lineage +engine (set it via `clonal_lineage(rate=...)`). `paired_end` is **not** allowed yet +either (the read layout is not wired through the per-cell corruption merge — a future +addition). + +Validation works on lineage results too: `run_records(..., validate_records=True)` +runs the per-record postcondition check and the clonal-family consistency check +(by `clone_id`), with or without a corruption pass. `run_records(..., +expose_provenance=True)` adds `truth_v_call` / `truth_d_call` / `truth_j_call` +columns from the founder assignments, and `result.outcomes` carries the per-record +`Outcome` objects index-aligned with `result.records`. + ## Clone-size distributions (TCR and repertoire mix) Real repertoires are not uniform: a few clones are huge, most are singletons. The @@ -327,6 +361,21 @@ In short: the clones GenAIRR plants are the clones the ecosystem detects. ## Relationship to `expand_clones` `expand_clones` (the star model) is **deprecated** but still works — it remains -useful for "many reads sharing one V(D)J truth" without a genealogy. For real -clonal trees, ground-truth lineages, and affinity maturation, use -`clonal_lineage`. +useful for "many reads sharing one V(D)J truth" without a genealogy. + +`clonal_lineage` is **not a drop-in replacement.** It grows real +affinity-maturation trees rather than a flat star, so the surface differs: + +- **Different parameters.** There is no `per_clone`; the number of observed records + depends on `n_sample`, genotype collapse, and selection (not a fixed + `n_clones × per_clone` product). SHM is internal (`rate=...`), not a separate + `mutate` step. +- **Different return shape.** `clonal_lineage` returns a + `SimulationResultWithLineages` with per-clone `.lineage_trees` (Newick / FASTA / + node-table exporters) alongside the per-cell records. + +What *does* carry over: the same per-read library-prep / sequencing passes +(`sequencing_errors`, `pcr_amplify`, …) can follow `clonal_lineage` exactly as they +follow `expand_clones`, applied independently per observed cell (see +[Library-prep & sequencing artefacts](#library-prep-sequencing-artefacts)). And +`run_records(..., validate_records=True)` is supported on lineage results too. diff --git a/src/GenAIRR/_compiled.py b/src/GenAIRR/_compiled.py index 1959453..c2db5d3 100644 --- a/src/GenAIRR/_compiled.py +++ b/src/GenAIRR/_compiled.py @@ -552,21 +552,48 @@ def run_records( *, seed: int = 0, strict: bool = False, + expose_provenance: bool = False, + validate_records: bool = False, ) -> "SimulationResultWithLineages": """Grow lineage trees and return per-observed-node AIRR records. Each clone is seeded at ``seed + clone_idx * 1_000_000`` so independent clones are reproducible and non-overlapping. + + The returned :class:`SimulationResultWithLineages` carries the + per-record ``Outcome`` that produced each AIRR record on its + ``.outcomes`` attribute (index-aligned with ``.records``). On the + pristine-read path these are the per-observed-node SHM + ``Outcome`` objects (``fam.observed_outcomes()``); on the + corruption path they are the per-node merged + recombination+SHM+artefact ``Outcome`` objects. Since each node + ``Outcome`` is self-consistent, ``result.validate_records(refdata)`` + passes. + + ``expose_provenance=True`` appends ``truth_v_call`` / + ``truth_d_call`` / ``truth_j_call`` columns to each record from + the founder allele assignments carried on the per-record + ``Outcome``. + + ``validate_records=True`` runs + :meth:`SimulationResult.validate_records` (per-record + postcondition) and :meth:`SimulationResult.validate_families` + (clonal-family consistency by ``clone_id``) on the freshly built + batch, raising the matching validation error on any failure. """ from GenAIRR import _engine as _eng from ._airr_record import outcome_to_airr_record from ._s5f_loader import load_builtin_s5f_kernel - from .result import SimulationResultWithLineages + from .result import SimulationResultWithLineages, _inject_truth_columns step = self._step mutability, substitution = load_builtin_s5f_kernel(step.s5f_model) records: List[Dict[str, Any]] = [] + # Per-record source ``Outcome`` objects, index-aligned with + # ``records`` so ``result.validate_records`` can re-derive each + # record from the engine state that produced it. + outcomes: List["_engine.Outcome"] = [] lineage_trees = [] for clone_idx in range(step.n_clones): @@ -597,11 +624,19 @@ def run_records( if self._post is None: # Pristine-read path: project each observed node's # synthesized SHM Outcome straight to an AIRR record. + # ``observed_outcomes()`` is index-aligned with both the + # observed-node list and ``airr_records()``. observed_nodes = [n for n in tree.nodes() if n.observed] recs = fam.airr_records(self._refdata) - for node, rec in zip(observed_nodes, recs): + observed_outcomes = fam.observed_outcomes() + for node, rec, node_outcome in zip( + observed_nodes, recs, observed_outcomes + ): self._stamp_lineage_metadata(rec, clone_idx, node) + if expose_provenance and node_outcome is not None: + _inject_truth_columns(node_outcome, self._refdata, rec) records.append(rec) + outcomes.append(node_outcome) continue # Corruption path: per observed node, run the library-prep / @@ -627,9 +662,23 @@ def run_records( sequence_id=f"clone{clone_idx}_node{node.id}", ) self._stamp_lineage_metadata(rec, clone_idx, node) + if expose_provenance: + _inject_truth_columns(merged, self._refdata, rec) records.append(rec) + outcomes.append(merged) - return SimulationResultWithLineages(records, lineage_trees=lineage_trees) + result = SimulationResultWithLineages( + records, outcomes=outcomes, lineage_trees=lineage_trees + ) + if validate_records: + from ._validation import ( + _raise_on_family_validation_failure, + _raise_on_validation_failure, + ) + + _raise_on_validation_failure(result.validate_records(self._refdata)) + _raise_on_family_validation_failure(result.validate_families()) + return result @staticmethod def _stamp_lineage_metadata(rec: Dict[str, Any], clone_idx: int, node) -> None: diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index a78a075..8732eb0 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -989,9 +989,16 @@ def expand_clones( """ warnings.warn( "Experiment.expand_clones() is deprecated in favor of " - "Experiment.clonal_lineage(), which grows real clonal lineage " - "trees (affinity maturation) instead of a star topology. " - "expand_clones() remains supported for flat clonal expansion.", + "Experiment.clonal_lineage(). Note this is NOT a drop-in " + "replacement: clonal_lineage() grows real affinity-maturation " + "lineage trees (internal SHM, no per_clone — the observed count " + "depends on n_sample / selection), returns a " + "SimulationResultWithLineages with per-clone .lineage_trees, and " + "takes different parameters. Library-prep / sequencing passes " + "(sequencing_errors, pcr_amplify, …) can now follow " + "clonal_lineage() too, applied independently per observed cell. " + "expand_clones() remains supported for flat star-topology " + "expansion.", DeprecationWarning, stacklevel=2, ) @@ -2645,19 +2652,14 @@ def run_records( if n is not None: raise ValueError( "The 'n' parameter is not supported for clonal_lineage experiments. " - "The number of records is determined by n_clones and n_sample." - ) - if validate_records: - raise NotImplementedError( - "validate_records=True is not yet supported for clonal_lineage experiments." - ) - if expose_provenance: - raise NotImplementedError( - "expose_provenance=True is not yet supported for clonal_lineage experiments." + "The number of observed records depends on the lineage trees " + "grown from n_clones / n_sample / selection, not a fixed product." ) result = compiled.run_records( seed=seed, strict=strict, + expose_provenance=expose_provenance, + validate_records=validate_records, ) else: result = compiled.run_records( diff --git a/tests/test_clonal_lineage_validation.py b/tests/test_clonal_lineage_validation.py index fa74b01..9a0ed98 100644 --- a/tests/test_clonal_lineage_validation.py +++ b/tests/test_clonal_lineage_validation.py @@ -89,34 +89,95 @@ def test_s5f_model_valid_accepted(): # --------------------------------------------------------------------------- -# Fix 4: unsupported run_records kwargs raise on lineage path +# Fix 4: run_records kwargs on the lineage path # --------------------------------------------------------------------------- def _compiled_lineage(): return _base_exp() -def test_validate_records_true_raises_for_lineage(): - with pytest.raises((NotImplementedError, ValueError)): - _compiled_lineage().run_records(validate_records=True) - - -def test_expose_provenance_true_raises_for_lineage(): - with pytest.raises((NotImplementedError, ValueError)): - _compiled_lineage().run_records(expose_provenance=True) - - def test_n_not_none_raises_for_lineage(): - with pytest.raises((NotImplementedError, ValueError)): + """Record count is not a fixed product for lineage; passing n is rejected.""" + with pytest.raises(ValueError): _compiled_lineage().run_records(n=5) def test_seed_and_strict_still_work_for_lineage(): - """seed and strict must work — they are the only supported kwargs.""" + """seed and strict must work.""" result = _compiled_lineage().run_records(seed=42, strict=False) assert len(result.records) > 0 +# --------------------------------------------------------------------------- +# validate_records / expose_provenance now SUPPORTED on the lineage path +# --------------------------------------------------------------------------- + +def _lineage_exp(**kw): + """A clonal_lineage experiment that reliably yields observed records.""" + defaults = dict( + n_clones=2, max_generations=6, n_max=300, n_sample=15, + rate=0.05, lambda_base=1.5, selection_strength=0.0, target_aa=None, + ) + defaults.update(kw) + return ga.Experiment.on("human_igh").recombine().clonal_lineage(**defaults) + + +def test_validate_records_true_no_corruption_does_not_raise(): + result = _lineage_exp().run_records(seed=0, validate_records=True) + assert len(result.records) > 0 + + +def test_validate_records_true_with_corruption_does_not_raise(): + exp = _lineage_exp().sequencing_errors(rate=0.02) + result = exp.run_records(seed=0, validate_records=True) + assert len(result.records) > 0 + + +def test_outcomes_are_retained_and_index_aligned(): + result = _lineage_exp().run_records(seed=0) + assert result.outcomes is not None + assert len(result.outcomes) == len(result.records) + + +def test_outcomes_retained_with_corruption(): + result = _lineage_exp().sequencing_errors(rate=0.02).run_records(seed=0) + assert result.outcomes is not None + assert len(result.outcomes) == len(result.records) + + +def test_validate_records_called_directly_is_clean_no_corruption(): + exp = _lineage_exp() + refdata = exp.compile().refdata + result = exp.run_records(seed=0) + report = result.validate_records(refdata) + assert report.ok, report.summary() + + +def test_validate_records_called_directly_is_clean_with_corruption(): + exp = _lineage_exp().sequencing_errors(rate=0.02) + refdata = exp.compile().refdata + result = exp.run_records(seed=0) + report = result.validate_records(refdata) + assert report.ok, report.summary() + + +def test_expose_provenance_adds_truth_v_call_no_corruption(): + result = _lineage_exp().run_records(seed=0, expose_provenance=True) + assert len(result.records) > 0 + for rec in result.records: + assert "truth_v_call" in rec + assert rec["truth_v_call"] + + +def test_expose_provenance_adds_truth_v_call_with_corruption(): + exp = _lineage_exp().sequencing_errors(rate=0.02) + result = exp.run_records(seed=0, expose_provenance=True) + assert len(result.records) > 0 + for rec in result.records: + assert "truth_v_call" in rec + assert rec["truth_v_call"] + + # --------------------------------------------------------------------------- # Smoke test: a valid call still works end-to-end # --------------------------------------------------------------------------- From 07d60542ac5efa404f6dcd509273a7cfba77e4db Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 08:27:32 +0300 Subject: [PATCH 46/59] polish(lineage): unbiased mature-target RNG + uniform residue pick; honest sampled-tips docs --- engine_rs/src/lineage/affinity.rs | 18 +++++++++++------- site_docs/guides/clonal-lineage.md | 15 +++++++++------ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/engine_rs/src/lineage/affinity.rs b/engine_rs/src/lineage/affinity.rs index cefa088..f136ed3 100644 --- a/engine_rs/src/lineage/affinity.rs +++ b/engine_rs/src/lineage/affinity.rs @@ -72,14 +72,18 @@ pub fn make_mature_target(naive_aa: &[u8], m: u32, rng: &mut Rng) -> Vec { return target; } for _ in 0..m { - let pos = (rng.next_u64() % target.len() as u64) as usize; + // Unbiased uniform draws (Lemire `range_u32`), matching the engine's + // RNG convention rather than `% len` modulo bias. + let pos = rng.range_u32(target.len() as u32) as usize; let cur = target[pos]; - // pick a standard amino acid different from the current residue - let start = (rng.next_u64() % 20) as usize; - let mut pick = AA_ORDER[start]; - if pick == cur { - pick = AA_ORDER[(start + 1) % 20]; - } + // Pick a standard amino acid uniformly among the 19 != cur (no skew). + let pick = match AA_ORDER.iter().position(|&x| x == cur) { + Some(ci) => { + let r = rng.range_u32(19) as usize; + AA_ORDER[if r >= ci { r + 1 } else { r }] + } + None => AA_ORDER[rng.range_u32(20) as usize], + }; target[pos] = pick; } target diff --git a/site_docs/guides/clonal-lineage.md b/site_docs/guides/clonal-lineage.md index a8e150b..7995a39 100644 --- a/site_docs/guides/clonal-lineage.md +++ b/site_docs/guides/clonal-lineage.md @@ -168,12 +168,15 @@ i.e. a neutral tree** (byte-identical to growing with no selection at all). ### 6. Sampling and genotype collapse When growth stops (at `max_generations`, extinction, or capacity), `n_sample` -cells are sampled uniformly from the final population. Cells with **identical -genotypes** are then collapsed: the first cell seen for a genotype becomes the -**observed** representative and accumulates an **abundance** count. This is the -standard germinal-center convention — it produces observed tips with multiplicities -and "sampled ancestor" nodes, exactly the structure abundance-aware tree methods -(e.g. GCtree) expect. Observed nodes are the ones that become AIRR records. +cells are sampled uniformly from the tree's **tips** (cells that left no progeny). +Cells with **identical genotypes** are then collapsed: the first cell seen for a +genotype becomes the **observed** representative and accumulates an **abundance** +count — so abundance-aware tree methods (e.g. GCtree) get observed tips with +multiplicities. The observed cells are the ones that become AIRR records. The full +genealogy — including every unobserved **internal ancestor** — is still emitted in +the `LineageTree` (and its Newick/FASTA), so ancestral-sequence reconstruction can +be scored against truth; note, however, that observed/sampled nodes are always tips, +not internal ancestors (direct sampling of internal ancestors is a future addition). ## What you get back From 648729ae94ec32845e3580dfcb38f3110101e8ea Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 08:49:52 +0300 Subject: [PATCH 47/59] fix(lineage): sample the living final-generation population, not all tree leaves (extinct clones unobserved) --- engine_rs/src/lineage/branching.rs | 72 +++++++++++++++++++++---- engine_rs/src/lineage/family.rs | 69 +++++++++++++++++++++--- engine_rs/src/lineage/sampling.rs | 55 +++++++++++++------ tests/test_clonal_lineage_corruption.py | 4 +- tests/test_clonal_lineage_dsl.py | 7 ++- tests/test_clonal_lineage_validation.py | 13 +++-- tests/test_lineage_engine.py | 45 ++++++++++++++++ 7 files changed, 226 insertions(+), 39 deletions(-) diff --git a/engine_rs/src/lineage/branching.rs b/engine_rs/src/lineage/branching.rs index 75729a6..6c4c0a6 100644 --- a/engine_rs/src/lineage/branching.rs +++ b/engine_rs/src/lineage/branching.rs @@ -59,14 +59,18 @@ fn mutate_child(parent: &Simulation, mutator: &dyn Pass, child_seed: u64) -> Sim /// deterministic sub-seed and applies `m`. With `affinity = Some(model)`, each /// cell's offspring rate is modulated by the model's fitness; `None` leaves the /// rate unchanged (byte-identical to the pre-affinity path). Returns -/// (tree, peak_live_population, sims_arena) where sims_arena[node.id] is the -/// Simulation for that node. +/// (tree, peak_live_population, sims_arena, final_live) where sims_arena[node.id] +/// is the Simulation for that node, and `final_live` is the set of node ids that +/// are ALIVE when growth stopped — the cells produced in the final completed +/// generation (every cell is replaced by its offspring each generation, so the +/// living population at sampling time is exactly this set). It is empty when the +/// family went extinct (a cell drew 0 offspring and left no progeny). fn grow_core( founder: &Simulation, params: &BranchingParams, mutator: Option<&dyn Pass>, affinity: Option<&AffinityModel>, -) -> (LineageTree, usize, Vec) { +) -> (LineageTree, usize, Vec, Vec) { let mut nodes: Vec = Vec::new(); let mut sims: Vec = Vec::new(); let mut rng = Rng::new(params.seed); @@ -147,7 +151,11 @@ fn grow_core( } } - (LineageTree { nodes }, peak_live, sims) + // `live` now holds the cells alive when growth stopped: either the last + // completed generation's offspring (ran to max_generations) or empty (the + // population went extinct — every live cell drew 0 offspring). This is the + // living population sampling must draw from. + (LineageTree { nodes }, peak_live, sims, live) } /// Grow a full clonal lineage with per-division mutation via `mutator`. @@ -166,6 +174,17 @@ pub fn grow_topology(founder: &Simulation, params: &BranchingParams) -> LineageT grow_core(founder, params, None, None).0 } +/// Like `grow_topology`, but also returns the final living node ids (cells alive +/// when growth stopped). Empty when the family went extinct. +#[cfg(test)] +pub fn grow_topology_with_live( + founder: &Simulation, + params: &BranchingParams, +) -> (LineageTree, Vec) { + let (tree, _peak, _sims, live) = grow_core(founder, params, None, None); + (tree, live) +} + /// Grow a clonal lineage with per-division mutation AND affinity selection. /// Deterministic for `params.seed`. pub fn grow_lineage_with_affinity( @@ -178,15 +197,17 @@ pub fn grow_lineage_with_affinity( } /// Grow + mutate a lineage and ALSO return the per-node `Simulation` arena -/// (index == node id), for building per-node AIRR `Outcome`s. +/// (index == node id) and the final living node ids (cells alive when growth +/// stopped; empty when the family went extinct), for building per-node AIRR +/// `Outcome`s and sampling the living population. pub fn grow_lineage_retaining_sims( founder: &Simulation, params: &BranchingParams, mutator: &dyn Pass, affinity: Option<&AffinityModel>, -) -> (LineageTree, Vec) { - let (tree, _peak, sims) = grow_core(founder, params, Some(mutator), affinity); - (tree, sims) +) -> (LineageTree, Vec, Vec) { + let (tree, _peak, sims, live) = grow_core(founder, params, Some(mutator), affinity); + (tree, sims, live) } #[cfg(test)] @@ -257,7 +278,7 @@ mod tests { n_sample: 10, seed: 7, }; - let (_tree, peak_live, _sims) = grow_core(&founder(), ¶ms, None, None); + let (_tree, peak_live, _sims, _live) = grow_core(&founder(), ¶ms, None, None); // hard cap: live population never exceeds n_max assert!(peak_live <= params.n_max as usize, "peak live {peak_live} exceeded n_max {}", params.n_max); @@ -267,6 +288,39 @@ mod tests { "peak live {peak_live} did not approach capacity"); } + #[test] + fn final_live_is_empty_when_founder_never_divides() { + // lambda_base = 0 => the founder draws 0 offspring => after generation 1 + // the live set is empty and the loop breaks. The final living population + // is empty (the clone went extinct). + let params = BranchingParams { lambda_base: 0.0, ..neutral_params() }; + let (_tree, live) = grow_topology_with_live(&founder(), ¶ms); + assert!(live.is_empty(), "expected extinct (empty) final-live set, got {:?}", live); + } + + #[test] + fn final_live_is_the_max_generation_set() { + // In a grown family, every node in the final-live set is at the maximum + // generation reached, and the set is non-empty. A single founder has a + // real chance of immediate extinction at lambda_base ~1.5, so scan seeds + // for one that grows (the invariant under test is about non-extinct runs). + let mut grew = false; + for seed in 0..50u64 { + let params = BranchingParams { seed, ..neutral_params() }; + let (tree, live) = grow_topology_with_live(&founder(), ¶ms); + if live.is_empty() { + continue; // extinct for this seed; try another + } + grew = true; + let max_gen = tree.nodes.iter().map(|n| n.generation).max().unwrap(); + for &id in &live { + assert_eq!(tree.get(id).unwrap().generation, max_gen, + "final-live node {id} not at max generation {max_gen} (seed {seed})"); + } + } + assert!(grew, "no seed in 0..50 produced a surviving family"); + } + /// A mutator that applies exactly 2 substitutions per division. fn two_mut_mutator() -> UniformMutationPass { UniformMutationPass::new( diff --git a/engine_rs/src/lineage/family.rs b/engine_rs/src/lineage/family.rs index 870254b..06b61be 100644 --- a/engine_rs/src/lineage/family.rs +++ b/engine_rs/src/lineage/family.rs @@ -5,7 +5,7 @@ use crate::pass::Pass; use crate::rng::Rng; use super::affinity::AffinityModel; -use super::branching::{grow_lineage, grow_lineage_retaining_sims, grow_lineage_with_affinity, BranchingParams}; +use super::branching::{grow_lineage_retaining_sims, BranchingParams}; use super::sampling::sample_and_collapse; use super::tree::LineageTree; @@ -23,9 +23,9 @@ pub fn simulate_family( params: &BranchingParams, mutator: &dyn Pass, ) -> LineageTree { - let mut tree = grow_lineage(founder, params, mutator); + let (mut tree, _sims, live) = grow_lineage_retaining_sims(founder, params, mutator, None); let mut sample_rng = Rng::new(params.seed ^ SAMPLE_SEED_SALT); - sample_and_collapse(&mut tree, params.n_sample, &mut sample_rng); + sample_and_collapse(&mut tree, &live, params.n_sample, &mut sample_rng); tree } @@ -36,9 +36,10 @@ pub fn simulate_family_with_affinity( mutator: &dyn Pass, model: &AffinityModel, ) -> LineageTree { - let mut tree = grow_lineage_with_affinity(founder, params, mutator, model); + let (mut tree, _sims, live) = + grow_lineage_retaining_sims(founder, params, mutator, Some(model)); let mut sample_rng = Rng::new(params.seed ^ SAMPLE_SEED_SALT); - sample_and_collapse(&mut tree, params.n_sample, &mut sample_rng); + sample_and_collapse(&mut tree, &live, params.n_sample, &mut sample_rng); tree } @@ -52,9 +53,9 @@ pub fn simulate_family_sims( mutator: &dyn Pass, affinity: Option<&AffinityModel>, ) -> (LineageTree, Vec) { - let (mut tree, sims) = grow_lineage_retaining_sims(founder, params, mutator, affinity); + let (mut tree, sims, live) = grow_lineage_retaining_sims(founder, params, mutator, affinity); let mut sample_rng = Rng::new(params.seed ^ SAMPLE_SEED_SALT); - sample_and_collapse(&mut tree, params.n_sample, &mut sample_rng); + sample_and_collapse(&mut tree, &live, params.n_sample, &mut sample_rng); (tree, sims) } @@ -100,6 +101,60 @@ mod tests { assert_eq!(tree.len(), tree2.len()); } + #[test] + fn extinct_founder_yields_no_observed_cells() { + // lambda_base = 0 => founder never divides => the living final population + // is empty (extinct). An extinct clone must NOT be sampled: no observed node. + let params = BranchingParams { + lambda_base: 0.0, lambda_mut: 0.0, max_generations: 6, + n_max: 300, n_sample: 20, seed: 2024, + }; + let mutator = UniformMutationPass::new( + Box::new(EmpiricalLengthDist::from_pairs(vec![(1, 1.0)])), + Box::new(UniformBase), + ); + let (tree, _sims) = simulate_family_sims(&founder(), ¶ms, &mutator, None); + assert!( + tree.nodes.iter().all(|n| !n.observed && n.abundance == 0), + "extinct clone was sampled: {} observed nodes", + tree.nodes.iter().filter(|n| n.observed).count() + ); + } + + #[test] + fn observed_cells_are_all_at_the_final_living_generation() { + // In a grown family, every OBSERVED node must be a member of the final + // living generation (the maximum generation reached) — never an + // early-terminal/dead cell that drew 0 offspring at a lower generation. + // A single founder can go extinct, so scan seeds for one that grows. + let mutator = || UniformMutationPass::new( + Box::new(EmpiricalLengthDist::from_pairs(vec![(1, 1.0)])), + Box::new(UniformBase), + ); + let mut grew = false; + for seed in 0..50u64 { + let params = BranchingParams { + lambda_base: 1.6, lambda_mut: 0.0, max_generations: 6, + n_max: 300, n_sample: 20, seed, + }; + let (tree, _sims) = simulate_family_sims(&founder(), ¶ms, &mutator(), None); + let observed: Vec<&_> = tree.nodes.iter().filter(|n| n.observed).collect(); + if observed.is_empty() { + continue; // extinct for this seed + } + grew = true; + let max_gen = tree.nodes.iter().map(|n| n.generation).max().unwrap(); + for n in &observed { + assert_eq!( + n.generation, max_gen, + "observed node {} at generation {} is not at the final generation {} (seed {seed})", + n.id, n.generation, max_gen + ); + } + } + assert!(grew, "no seed in 0..50 produced a surviving family"); + } + #[test] fn simulate_family_sims_arena_length_matches_tree_and_observed_nodes_have_nonempty_pool() { let params = BranchingParams { diff --git a/engine_rs/src/lineage/sampling.rs b/engine_rs/src/lineage/sampling.rs index adbd6f7..a2e1991 100644 --- a/engine_rs/src/lineage/sampling.rs +++ b/engine_rs/src/lineage/sampling.rs @@ -7,20 +7,26 @@ use crate::rng::Rng; use super::tree::LineageTree; -/// Sample `n_sample` cells uniformly (with replacement) from the tree's leaves -/// and collapse identical genotypes: the first leaf *drawn* carrying a given -/// genotype becomes the observed representative and accumulates the abundance; -/// later draws of the same genotype fold into it. Mutates `tree` in place. -pub fn sample_and_collapse(tree: &mut LineageTree, n_sample: u32, rng: &mut Rng) { - let leaf_ids: Vec = { - let leaves = tree.leaves(); - leaves.iter().map(|n| n.id).collect() - }; - if leaf_ids.is_empty() || n_sample == 0 { +/// Sample `n_sample` cells uniformly (with replacement) from `sampleable_ids` +/// (the LIVING population at sampling time — the final-generation cells, NOT all +/// tree leaves) and collapse identical genotypes: the first cell *drawn* carrying +/// a given genotype becomes the observed representative and accumulates the +/// abundance; later draws of the same genotype fold into it. Mutates `tree` in +/// place. +/// +/// If `sampleable_ids` is empty (an extinct family — no living cells), nothing is +/// marked observed: an unobserved extinct clone yields zero records. +pub fn sample_and_collapse( + tree: &mut LineageTree, + sampleable_ids: &[u32], + n_sample: u32, + rng: &mut Rng, +) { + if sampleable_ids.is_empty() || n_sample == 0 { return; } - // genotype -> representative node id (first leaf drawn with that genotype) + // genotype -> representative node id (first cell drawn with that genotype) let mut rep_by_genotype: HashMap, u32> = HashMap::new(); // representative node id -> accumulated abundance let mut abundance: HashMap = HashMap::new(); @@ -28,10 +34,10 @@ pub fn sample_and_collapse(tree: &mut LineageTree, n_sample: u32, rng: &mut Rng) for _ in 0..n_sample { // Unbiased uniform index (Lemire), matching the engine's RNG convention; // avoids the modulo bias of `next_u64() % len`. - let idx = rng.range_u32(leaf_ids.len() as u32) as usize; - let leaf_id = leaf_ids[idx]; - let genotype = tree.get(leaf_id).unwrap().genotype.clone(); - let rep = *rep_by_genotype.entry(genotype).or_insert(leaf_id); + let idx = rng.range_u32(sampleable_ids.len() as u32) as usize; + let cell_id = sampleable_ids[idx]; + let genotype = tree.get(cell_id).unwrap().genotype.clone(); + let rep = *rep_by_genotype.entry(genotype).or_insert(cell_id); *abundance.entry(rep).or_insert(0) += 1; } @@ -66,12 +72,18 @@ mod tests { } } + // Sampleable set with duplicate genotypes among the living cells (nodes 1 & 2 + // share "AAAC") to exercise genotype-collapse. + fn sampleable() -> Vec { + vec![1, 2, 3] + } + #[test] fn sampling_sets_abundances_summing_to_n_sample() { let mut tree = tree_with_dupes(); let mut rng = Rng::new(5); let n_sample = 3; - sample_and_collapse(&mut tree, n_sample, &mut rng); + sample_and_collapse(&mut tree, &sampleable(), n_sample, &mut rng); let total: u32 = tree.nodes.iter().map(|n| n.abundance).sum(); assert_eq!(total, n_sample); @@ -86,7 +98,7 @@ mod tests { fn identical_genotypes_collapse_into_one_observed_node() { let mut tree = tree_with_dupes(); let mut rng = Rng::new(1); - sample_and_collapse(&mut tree, 3, &mut rng); + sample_and_collapse(&mut tree, &sampleable(), 3, &mut rng); use std::collections::HashSet; let mut seen: HashSet> = HashSet::new(); for n in tree.nodes.iter().filter(|n| n.observed) { @@ -94,4 +106,13 @@ mod tests { "genotype observed in more than one node (collapse failed)"); } } + + #[test] + fn empty_sampleable_set_marks_nothing_observed() { + // An extinct family (no living cells) yields zero observed cells. + let mut tree = tree_with_dupes(); + let mut rng = Rng::new(3); + sample_and_collapse(&mut tree, &[], 5, &mut rng); + assert!(tree.nodes.iter().all(|n| !n.observed && n.abundance == 0)); + } } diff --git a/tests/test_clonal_lineage_corruption.py b/tests/test_clonal_lineage_corruption.py index 92dba36..1de6e27 100644 --- a/tests/test_clonal_lineage_corruption.py +++ b/tests/test_clonal_lineage_corruption.py @@ -67,7 +67,9 @@ def test_clonal_lineage_without_corruption_still_works(): ga.Experiment.on("human_igh") .recombine() .clonal_lineage(n_clones=2, n_sample=10, rate=0.02) - .run_records(seed=0) + # seed chosen so the families survive: sampling draws from the living + # final generation, so an all-extinct seed (e.g. 0) yields zero records. + .run_records(seed=1) ) assert len(result.records) > 0 # No corruption pass → no quality errors stamped. diff --git a/tests/test_clonal_lineage_dsl.py b/tests/test_clonal_lineage_dsl.py index d293171..c3b87b4 100644 --- a/tests/test_clonal_lineage_dsl.py +++ b/tests/test_clonal_lineage_dsl.py @@ -11,9 +11,14 @@ def _exp(**kw): def test_clonal_lineage_runs_and_tags_records(): result = _exp().run_records(seed=0) + # Sampling now draws from the LIVING final-generation population, so an + # extinct clone (founder drew 0 offspring) contributes no records. We still + # expect at least one clone to survive and produce records, and every + # clone_id present must be within the requested range. assert len(result.records) > 0 cids = {r["clone_id"] for r in result.records} - assert cids == {0, 1, 2} + assert cids, "no clone produced any record" + assert cids <= {0, 1, 2} for r in result.records: assert r["v_call"] # real recombination provenance assert r["sequence"] diff --git a/tests/test_clonal_lineage_validation.py b/tests/test_clonal_lineage_validation.py index 9a0ed98..b7e09c9 100644 --- a/tests/test_clonal_lineage_validation.py +++ b/tests/test_clonal_lineage_validation.py @@ -122,14 +122,19 @@ def _lineage_exp(**kw): return ga.Experiment.on("human_igh").recombine().clonal_lineage(**defaults) +# seed chosen so the (single-founder) families survive: sampling now draws from +# the living final generation, so an all-extinct seed yields zero records. +_SURVIVING_SEED = 1 + + def test_validate_records_true_no_corruption_does_not_raise(): - result = _lineage_exp().run_records(seed=0, validate_records=True) + result = _lineage_exp().run_records(seed=_SURVIVING_SEED, validate_records=True) assert len(result.records) > 0 def test_validate_records_true_with_corruption_does_not_raise(): exp = _lineage_exp().sequencing_errors(rate=0.02) - result = exp.run_records(seed=0, validate_records=True) + result = exp.run_records(seed=_SURVIVING_SEED, validate_records=True) assert len(result.records) > 0 @@ -162,7 +167,7 @@ def test_validate_records_called_directly_is_clean_with_corruption(): def test_expose_provenance_adds_truth_v_call_no_corruption(): - result = _lineage_exp().run_records(seed=0, expose_provenance=True) + result = _lineage_exp().run_records(seed=_SURVIVING_SEED, expose_provenance=True) assert len(result.records) > 0 for rec in result.records: assert "truth_v_call" in rec @@ -171,7 +176,7 @@ def test_expose_provenance_adds_truth_v_call_no_corruption(): def test_expose_provenance_adds_truth_v_call_with_corruption(): exp = _lineage_exp().sequencing_errors(rate=0.02) - result = exp.run_records(seed=0, expose_provenance=True) + result = exp.run_records(seed=_SURVIVING_SEED, expose_provenance=True) assert len(result.records) > 0 for rec in result.records: assert "truth_v_call" in rec diff --git a/tests/test_lineage_engine.py b/tests/test_lineage_engine.py index f7bcb88..b8f4aba 100644 --- a/tests/test_lineage_engine.py +++ b/tests/test_lineage_engine.py @@ -151,6 +151,51 @@ def test_affinity_auto_target_is_deterministic(): assert [n.affinity for n in a.nodes()] == [n.affinity for n in b.nodes()] +def test_simulate_family_outcomes_extinct_founder_yields_no_observed(): + """lambda_base=0 => founder never divides => extinct => zero observed cells. + + Sampling must draw from the LIVING final-generation population, not all tree + leaves. An extinct clone (no living cells) is not observed, so it contributes + no node/observed Outcomes. + """ + c = ga.Experiment.on("human_igh").recombine().compile() + founder = c.run(n=1, seed=0)[0] # full Outcome (not just the Simulation) + refdata = c.refdata + mut, sub = _kernel() + fam = _engine.simulate_family_outcomes( + founder, refdata, mut, sub, 0.05, 0.0, 0.0, 6, 300, 30, 2024 + ) + assert len(fam.observed_outcomes()) == 0 + assert all(o is None for o in fam.node_outcomes()) + assert all(not n.observed and n.abundance == 0 for n in fam.tree().nodes()) + + +def test_simulate_family_outcomes_healthy_lambda_produces_observed(): + """A healthy lambda_base produces observed cells whose abundances sum to n_sample. + + Scans seeds because a single founder can go extinct even at a healthy rate. + """ + c = ga.Experiment.on("human_igh").recombine().compile() + founder = c.run(n=1, seed=0)[0] # full Outcome (not just the Simulation) + refdata = c.refdata + mut, sub = _kernel() + n_sample = 30 + for seed in range(20): + fam = _engine.simulate_family_outcomes( + founder, refdata, mut, sub, 0.05, 1.6, 0.0, 6, 300, n_sample, seed + ) + observed = [n for n in fam.tree().nodes() if n.observed] + if not observed: + continue # extinct for this seed + assert len(fam.observed_outcomes()) == len(observed) + assert sum(n.abundance for n in observed) == n_sample + max_gen = max(n.generation for n in fam.tree().nodes()) + assert all(n.generation == max_gen for n in observed) + break + else: + raise AssertionError("no seed in 0..20 produced a surviving family") + + def test_affinity_rejects_bad_params(): founder = _founder() mut, sub = _kernel() From 7307267524d290a83705609971fc324d16e35e34 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 09:06:01 +0300 Subject: [PATCH 48/59] fix(lineage): BCR-only guard, founder-survival retry, duplicate_count, honest n_max/affinity/TCR/validation docs --- site_docs/guides/clonal-lineage.md | 119 +++++++++++++++++------- src/GenAIRR/_compiled.py | 86 ++++++++++++----- src/GenAIRR/_pipeline_ir.py | 1 + src/GenAIRR/experiment.py | 43 ++++++++- tests/test_clonal_lineage_dsl.py | 11 +-- tests/test_clonal_lineage_validation.py | 95 +++++++++++++++++++ 6 files changed, 293 insertions(+), 62 deletions(-) diff --git a/site_docs/guides/clonal-lineage.md b/site_docs/guides/clonal-lineage.md index 7995a39..2147095 100644 --- a/site_docs/guides/clonal-lineage.md +++ b/site_docs/guides/clonal-lineage.md @@ -25,10 +25,13 @@ is fine for "many reads that share a V(D)J truth", but it has no genealogy, no generations, no selection, and no ancestral nodes — so it cannot serve as ground truth for lineage reconstruction. `clonal_lineage` adds the missing biology. -> T cells do **not** somatically hypermutate. A TCR "clone" is one rearrangement -> proliferated to many identical copies; the meaningful quantity is the -> **clone-size distribution**, not a mutation tree. GenAIRR models that with a -> separate heavy-tailed clone-size sampler (see +> **`clonal_lineage` is BCR-only.** T cells do **not** somatically hypermutate, and +> `clonal_lineage` applies S5F SHM, so calling it on a TCR locus raises a clear +> `ValueError`. A TCR "clone" is one rearrangement proliferated to many identical +> copies; the meaningful quantity is the **clone-size distribution**, not a mutation +> tree. GenAIRR has the heavy-tailed clone-size **primitives** in the engine, but +> they are **not yet exposed as a DSL workflow** — there is no `clonal_lineage` TCR +> path today (see > [Clone-size distributions](#clone-size-distributions-tcr-and-repertoire-mix)). ## Quick start @@ -42,7 +45,7 @@ result = ( .clonal_lineage( n_clones=20, # grow 20 independent families max_generations=6, # germinal-center rounds - n_max=300, # carrying capacity (cells per family) + n_max=300, # per-generation living-population carrying capacity n_sample=30, # cells sampled per family at the end rate=0.01, # per-base S5F SHM rate, per division lambda_base=1.6, # mean offspring per cell per generation @@ -133,18 +136,27 @@ introduced on that division. ### 5. Affinity selection This is what turns a neutral tree into affinity maturation. Each cell has an -**affinity** to a target antigen: +**affinity** to a target *sequence*: ``` affinity = exp(−beta · weighted_aa_distance(cell, target)) ``` +> **What "affinity" means here — read this.** This is a **sequence-distance +> proxy**, not a physically modeled antigen-binding affinity. It is a BLOSUM62 +> substitution-aware amino-acid distance from the cell's translated receptor to a +> **target amino-acid sequence**, mapped through `exp(−β · distance)`. There is +> **no Kd, no antigen concentration, no biophysical binding model** anywhere in +> the computation. Treat it as a tunable **selection pressure that pulls the +> lineage toward a target sequence** — the closer a cell's receptor gets to the +> target, the higher its "affinity" and the faster it divides. + `weighted_aa_distance` is a **BLOSUM62 substitution-aware** amino-acid distance between the cell's translated receptor and the target (region weights let CDRs be emphasized; v1 uses uniform weights, with CDR3-weighting as a planned refinement). -`affinity` is 1.0 at the target and decays toward 0 as the cell diverges. +`affinity` is 1.0 at the target sequence and decays toward 0 as the cell diverges. -The target is either supplied by you (`target_aa=...`, an antigen amino-acid +The target is either supplied by you (`target_aa=...`, a target amino-acid sequence) or auto-generated as a "mature" target — the founder's amino-acid sequence with `mature_substitutions` random residue changes (the standard benchmark convention). @@ -167,16 +179,31 @@ i.e. a neutral tree** (byte-identical to growing with no selection at all). ### 6. Sampling and genotype collapse -When growth stops (at `max_generations`, extinction, or capacity), `n_sample` -cells are sampled uniformly from the tree's **tips** (cells that left no progeny). -Cells with **identical genotypes** are then collapsed: the first cell seen for a -genotype becomes the **observed** representative and accumulates an **abundance** -count — so abundance-aware tree methods (e.g. GCtree) get observed tips with -multiplicities. The observed cells are the ones that become AIRR records. The full -genealogy — including every unobserved **internal ancestor** — is still emitted in -the `LineageTree` (and its Newick/FASTA), so ancestral-sequence reconstruction can -be scored against truth; note, however, that observed/sampled nodes are always tips, -not internal ancestors (direct sampling of internal ancestors is a future addition). +When growth stops (at `max_generations` or capacity), `n_sample` cells are sampled +from the **LIVING final-generation population** — the cells that are alive when +growth stops. Cells with **identical genotypes** are then **collapsed** into +**observed cells**: the first cell seen for a genotype becomes the observed +representative and accumulates an **abundance** count (surfaced as both +`lineage_abundance` and the AIRR-standard `duplicate_count`) — so abundance-aware +tools (GCtree, Change-O, SCOPer, dowser) get observed cells with multiplicities. +The observed cells are the ones that become AIRR records. + +Because sampling draws from the **living** population, an **extinct clone** — one +whose founder draws 0 offspring — has no living cells and therefore yields **zero +observed cells and zero records**. A single founder at `lambda_base ≈ 1.5` goes +extinct roughly 25 % of the time. By default (`allow_extinction=False`) each +requested clone is **conditioned on survival**: an extinct family is re-grown with +a fresh deterministic sub-seed (up to a bounded number of attempts) so you reliably +get all `n_clones` families back. Determinism is preserved — the same top-level +`seed` always reproduces the same result. Set `allow_extinction=True` to accept +extinction instead: extinct clones are skipped and you get **fewer** than `n_clones` +families. + +The full genealogy — including every unobserved **internal ancestor** — is still +emitted in the `LineageTree` (and its Newick/FASTA), so ancestral-sequence +reconstruction can be scored against truth; note, however, that observed/sampled +nodes are always tips, not internal ancestors (direct sampling of internal +ancestors is a future addition). ## What you get back @@ -190,7 +217,8 @@ counts (`n_mutations`, `n_v_mutations`, …, and the IMGT-subregion counters) ar recomputed **from the cell's sequence vs. germline** — these are **net differences from germline** (accumulated across all divisions from founder to leaf). Because identical genotypes are collapsed before sampling, the number of records per clone -is ≤ `n_sample`; the `lineage_abundance` field accounts for the collapsed copies. +is ≤ `n_sample`; the `lineage_abundance` field (mirrored by the AIRR-standard +`duplicate_count`) accounts for the collapsed copies. > **Branch lengths vs. record `n_mutations`.** Newick branch lengths > (as returned by `to_newick()`) count the **per-division substitution events** @@ -213,7 +241,8 @@ Lineage metadata stamped on every record: | `lineage_parent_id` | Parent node id (−1 for the founder) | | `lineage_generation` | Generation depth (founder = 0) | | `lineage_abundance` | Observation count after genotype collapse | -| `lineage_affinity` | Affinity to the target (0 in neutral mode) | +| `duplicate_count` | AIRR-standard alias of `lineage_abundance` (read by Change-O / SCOPer / dowser) | +| `lineage_affinity` | Sequence-distance proxy to the target (see [§5](#5-affinity-selection)). `0` **only** when no target is in play — fully neutral mode (`target_aa=None` **and** `selection_strength=0`). If a `target_aa` is supplied (or selection is on), affinities are computed and reported even when `selection_strength=0` | ### Ground-truth lineage trees @@ -265,6 +294,12 @@ columns from the founder assignments, and `result.outcomes` carries the per-reco ## Clone-size distributions (TCR and repertoire mix) +> **Engine primitives, not yet a DSL workflow.** `clonal_lineage` itself is +> **BCR-only** — there is **no `clonal_lineage` TCR path today**. The clone-size +> machinery below lives in the engine but is **not yet wired into a fluent DSL +> workflow**; it is documented here as a forward-looking capability, not as +> something you can drive from `clonal_lineage(...)`. + Real repertoires are not uniform: a few clones are huge, most are singletons. The engine includes heavy-tailed **clone-size distributions** (`CloneSizeDist`: power-law/Zipf by default, log-normal optional) and a repertoire-composition @@ -272,7 +307,8 @@ sampler that draws a set of clone sizes with a controllable **unexpanded fractio (size-1, never-expanded clones). For TCR — which has no SHM — a clone is simply one rearrangement at copy-number `size`, with within-clone variation coming only from the existing sequencing/PCR-error passes. These primitives are the basis for -mixing large expanded families with a realistic singleton tail. +mixing large expanded families with a realistic singleton tail, but exposing them +as a TCR clone-size DSL workflow is still future work. ## Determinism @@ -288,22 +324,34 @@ byte-for-byte. |---|---|---| | `n_clones` | — | Number of independent families to grow | | `max_generations` | 10 | Germinal-center rounds (≤ 1000) | -| `n_max` | 1000 | Carrying capacity (live cells per family) | +| `n_max` | 1000 | **Per-generation LIVING-population carrying capacity** — the live population each generation is capped at this. It is **not** a hard cap on total cells per clone; the tree can contain more total nodes across generations | | `n_sample` | 50 | Cells sampled per family at the end; records per clone ≤ this (genotype-collapsed) | | `rate` | 0.05 | Per-base S5F SHM rate, per division | | `lambda_base` | 1.5 | Mean offspring per cell per generation | -| `selection_strength` | 0.0 | Neutral drift by default (`lineage_affinity ≡ 0`); set `> 0` for affinity selection | +| `selection_strength` | 0.0 | `0` = neutral drift (`fitness ≡ 1`); set `> 0` for affinity selection. Note `0` makes selection neutral but does **not** force `lineage_affinity` to 0 — affinities are still computed/reported whenever a `target_aa` is supplied | | `beta` | 1.0 | Affinity steepness in `exp(−beta·distance)` | -| `target_aa` | `None` | Amino-acid sequence of the full receptor used as the antigen target (BLOSUM62-weighted distance, position-wise; only the overlapping prefix is scored when lengths differ). `None` ⇒ auto "mature" target | +| `target_aa` | `None` | Target amino-acid sequence (a full translated receptor) used as the selection target (BLOSUM62-weighted distance, position-wise; only the overlapping prefix is scored when lengths differ). A **sequence-distance proxy**, not a biophysical antigen. `None` ⇒ auto "mature" target | | `mature_substitutions` | 5 | aa substitutions for the auto target | | `s5f_model` | `"hh_s5f"` | Bundled S5F kernel | +| `allow_extinction` | `False` | `False` ⇒ condition each clone on survival (retry extinct founders with fresh deterministic sub-seeds), so you reliably get `n_clones` families. `True` ⇒ accept extinction and skip extinct clones, producing fewer families | -## Validated against community tools +## Clone recovery: what we actually ran The point of planting ground-truth clones is that **other people's tools can find -them**. They can. On a realistic-SHM run, Immcantation's **Change-O** -`DefineClones` at its **default** junction-distance threshold (0.16) recovers the -planted clones exactly. +them**. Two clusterers were actually run against the planted labels and both +recover them perfectly (adjusted Rand index = 1.0) at realistic SHM: + +1. **Immcantation Change-O `DefineClones`** at its **default** junction-distance + threshold (0.16) recovers the planted clones exactly. +2. An **in-repo, implementation-independent standard-heuristic clusterer** + (V/J + junction-length + single-linkage) recovers them just as cleanly. + +The export formats are **designed to feed** the broader B-cell lineage ecosystem — +tree-based tools like **GCtree, IgPhyML, and dowser** consume the +Newick/FASTA/node-table exports, and abundance-aware clustering tools like +**SCOPer** read the AIRR TSV with `duplicate_count`. Those tools were **not** run +as part of this validation; the claim here is scoped to the two clusterers above +and to format compatibility, not to having executed the full ecosystem. ![GenAIRR clonal_lineage clones are recovered by Change-O at default settings](../assets/clonal-lineage-detection.png) @@ -354,12 +402,15 @@ match. 6 generations → ~21 % SHM) you raise the cutoff (a threshold sweep climbs from ARI 0.26 at 0.16 → 0.91 at 0.30 → 1.0 at 0.45). This mirrors how these tools behave on real data and is **not** a property of the simulator. -- **Independent of any one tool.** The same recovery holds for an - implementation-independent V/J + junction-length + single-linkage clusterer, and - the exported Newick/FASTA feed tree-based methods (GCtree, IgPhyML, dowser) - directly. - -In short: the clones GenAIRR plants are the clones the ecosystem detects. +- **Independent of any one tool.** The same recovery holds for the in-repo + implementation-independent V/J + junction-length + single-linkage clusterer — so + the signal is not an artefact of Change-O's specific model. The exported + Newick/FASTA/AIRR-TSV (with `duplicate_count`) are **designed to feed** tree-based + and abundance-aware methods (GCtree, IgPhyML, dowser, SCOPer) directly; those + downstream tools were not run here. + +In short: the two clusterers we ran recover the planted clones exactly, and the +export formats are built to hand the same ground truth to the wider ecosystem. ## Relationship to `expand_clones` diff --git a/src/GenAIRR/_compiled.py b/src/GenAIRR/_compiled.py index c2db5d3..ce59654 100644 --- a/src/GenAIRR/_compiled.py +++ b/src/GenAIRR/_compiled.py @@ -596,30 +596,70 @@ def run_records( outcomes: List["_engine.Outcome"] = [] lineage_trees = [] + # Bound on founder-survival retries. Sampling draws from the LIVING + # final-generation population, so an extinct founder (drew 0 offspring) + # yields zero observed cells. With ``allow_extinction=False`` (default) + # we condition each clone on survival by re-growing the family with a + # fresh deterministic sub-seed until it survives or we exhaust the + # bound. The sub-seed offset (a prime times the attempt index) keeps the + # retries deterministic — same top-level seed => same result. + _MAX_SURVIVAL_ATTEMPTS = 50 + _SUBSEED_STRIDE = 7_919 # prime; keeps retry sub-seeds well-separated + allow_extinction = getattr(step, "allow_extinction", False) + for clone_idx in range(step.n_clones): clone_seed = int(seed) + clone_idx * 1_000_000 - # _pre.run() returns a single Outcome (not a list) — mirror - # CompiledClonalExperiment which calls self._pre.run(seed=...). - founder = self._pre.run(seed=clone_seed, strict=strict) - fam = _eng.simulate_family_outcomes( - founder, - self._refdata, - mutability, - substitution, - step.rate, - step.lambda_base, - 0.0, # lambda_mut: positional slot 6 (inert; hardcoded) - step.max_generations, - step.n_max, - step.n_sample, - clone_seed, - selection_strength=step.selection_strength, - beta=step.beta, - target_aa=step.target_aa, - mature_substitutions=step.mature_substitutions, - ) - tree = fam.tree() + fam = None + tree = None + for attempt in range(_MAX_SURVIVAL_ATTEMPTS): + family_seed = clone_seed + attempt * _SUBSEED_STRIDE + # _pre.run() returns a single Outcome (not a list) — mirror + # CompiledClonalExperiment which calls self._pre.run(seed=...). + founder = self._pre.run(seed=family_seed, strict=strict) + candidate = _eng.simulate_family_outcomes( + founder, + self._refdata, + mutability, + substitution, + step.rate, + step.lambda_base, + 0.0, # lambda_mut: positional slot 6 (inert; hardcoded) + step.max_generations, + step.n_max, + step.n_sample, + family_seed, + selection_strength=step.selection_strength, + beta=step.beta, + target_aa=step.target_aa, + mature_substitutions=step.mature_substitutions, + ) + survived = len(candidate.observed_outcomes()) > 0 + if survived or allow_extinction: + fam = candidate + tree = candidate.tree() + break + # extinct + survival required => retry with next sub-seed + else: + # Exhausted the retry bound without a surviving family. + raise ValueError( + f"clonal_lineage: clone {clone_idx} went extinct on every " + f"one of {_MAX_SURVIVAL_ATTEMPTS} survival attempts (each " + "founder drew 0 offspring). Increase lambda_base (offspring " + "Poisson mean) and/or max_generations so families reliably " + "survive, or pass allow_extinction=True to accept extinct " + "clones (producing fewer than n_clones families)." + ) + + if fam is None: + # allow_extinction=True and the final attempt was extinct: + # skip this clone (it contributes no observed cells/records). + continue + lineage_trees.append(tree) + if len(fam.observed_outcomes()) == 0: + # allow_extinction=True and this family is extinct: keep its + # (empty) tree for export parity but emit no records. + continue if self._post is None: # Pristine-read path: project each observed node's @@ -690,6 +730,10 @@ def _stamp_lineage_metadata(rec: Dict[str, Any], clone_idx: int, node) -> None: ) rec["lineage_generation"] = node.generation rec["lineage_abundance"] = node.abundance + # AIRR-standard abundance field that abundance-aware tools (Change-O / + # SCOPer / dowser) read. Mirror lineage_abundance so the genotype- + # collapsed observed cell carries its represented count both ways. + rec["duplicate_count"] = node.abundance rec["lineage_affinity"] = node.affinity rec["sequence_id"] = f"clone{clone_idx}_node{node.id}" diff --git a/src/GenAIRR/_pipeline_ir.py b/src/GenAIRR/_pipeline_ir.py index c381c18..2938098 100644 --- a/src/GenAIRR/_pipeline_ir.py +++ b/src/GenAIRR/_pipeline_ir.py @@ -209,6 +209,7 @@ class _LineageForkStep: target_aa: Optional[str] mature_substitutions: int s5f_model: str + allow_extinction: bool = False @dataclass(frozen=True) diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index 8732eb0..ceaa3d4 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -1059,6 +1059,7 @@ def clonal_lineage( target_aa: Optional[str] = None, mature_substitutions: int = 5, s5f_model: str = "hh_s5f", + allow_extinction: bool = False, ) -> "Experiment": """Grow BCR lineage trees (neutral by default; set ``selection_strength > 0`` and optionally ``target_aa`` to enable affinity maturation). @@ -1085,7 +1086,10 @@ def clonal_lineage( max_generations: Maximum depth of the lineage tree (≤ 1000). n_max: - Hard cap on total cells per clone (carrying capacity). + Per-generation LIVING-population carrying capacity: the live + population per generation is capped at this (the tree can contain + more total nodes across generations). It is NOT a hard cap on the + total number of cells per clone. n_sample: Number of cells to sample as observed leaves. Records returned per clone are ≤ ``n_sample`` because identical genotypes are @@ -1116,10 +1120,46 @@ def clonal_lineage( s5f_model: Bundled S5F kernel name for within-lineage mutation context (``"hh_s5f"``, ``"hkl_s5f"``, …). + allow_extinction: + Sampling draws from the LIVING final-generation population, so a + founder that draws 0 offspring goes extinct and yields zero + observed cells/records. With ``allow_extinction=False`` (default) + each requested clone is conditioned on survival: an extinct family + is retried with a fresh deterministic sub-seed (up to a bounded + number of attempts) so you reliably get ``n_clones`` families. With + ``allow_extinction=True`` extinction is accepted and the extinct + clone is skipped, producing fewer families than ``n_clones``. + + **BCR-only guard:** ``clonal_lineage`` applies S5F somatic + hypermutation, which is a B-cell process. Calling it on a TCR-configured + experiment raises ``ValueError`` (immunoglobulin / BCR loci only). TCR + clone-size primitives exist in the engine but are not yet exposed as a + DSL workflow. """ import math import warnings + # --- BCR-only guard (mirror mutate()'s TCR rejection) --- + # clonal_lineage applies S5F somatic hypermutation, a B-cell process, + # so it must reject TCR loci. ``_is_tcr_refdata`` inspects the first V + # allele name prefix (TR* => TCR, IG* => BCR) on the already-bound + # refdata, exactly as mutate() does. Firing here, at call time and + # before compile(), guarantees the clear BCR-only message instead of a + # downstream cartridge / compile error. + if self._is_tcr_refdata(): + locus = self._refdata.v_allele(0).name if self._refdata.v_pool_size() else "?" + raise ValueError( + "clonal_lineage models B-cell somatic hypermutation and " + "supports immunoglobulin (BCR) loci only; the locus " + f"'{locus}' is a TCR locus. (TCR clone-size simulation is not " + "yet exposed in the DSL.)" + ) + # --- allow_extinction --- + if not isinstance(allow_extinction, bool): + raise ValueError( + f"allow_extinction must be a bool, got {allow_extinction!r}" + ) + # --- n_clones --- if isinstance(n_clones, bool) or not isinstance(n_clones, int) or n_clones < 1: raise ValueError(f"n_clones must be a positive int, got {n_clones!r}") @@ -1224,6 +1264,7 @@ def clonal_lineage( target_aa=target_aa, mature_substitutions=mature_substitutions, s5f_model=s5f_model, + allow_extinction=allow_extinction, ) ) return self diff --git a/tests/test_clonal_lineage_dsl.py b/tests/test_clonal_lineage_dsl.py index c3b87b4..5642792 100644 --- a/tests/test_clonal_lineage_dsl.py +++ b/tests/test_clonal_lineage_dsl.py @@ -11,14 +11,13 @@ def _exp(**kw): def test_clonal_lineage_runs_and_tags_records(): result = _exp().run_records(seed=0) - # Sampling now draws from the LIVING final-generation population, so an - # extinct clone (founder drew 0 offspring) contributes no records. We still - # expect at least one clone to survive and produce records, and every - # clone_id present must be within the requested range. + # Sampling draws from the LIVING final-generation population, so an extinct + # clone (founder drew 0 offspring) contributes no records. By default the + # founder-survival guard retries extinct clones with fresh deterministic + # sub-seeds, so every requested clone survives and all clone_ids are present. assert len(result.records) > 0 cids = {r["clone_id"] for r in result.records} - assert cids, "no clone produced any record" - assert cids <= {0, 1, 2} + assert cids == {0, 1, 2} for r in result.records: assert r["v_call"] # real recombination provenance assert r["sequence"] diff --git a/tests/test_clonal_lineage_validation.py b/tests/test_clonal_lineage_validation.py index b7e09c9..4ae3267 100644 --- a/tests/test_clonal_lineage_validation.py +++ b/tests/test_clonal_lineage_validation.py @@ -183,6 +183,101 @@ def test_expose_provenance_adds_truth_v_call_with_corruption(): assert rec["truth_v_call"] +# --------------------------------------------------------------------------- +# TCR guard: clonal_lineage models B-cell SHM => BCR/Ig only +# --------------------------------------------------------------------------- + +def test_clonal_lineage_rejects_tcr_locus(): + """clonal_lineage applies S5F SHM (a B-cell process) so it must reject + TCR loci with a clear BCR-only message — mirroring mutate()'s TCR guard. + The guard must fire at clonal_lineage() call time (before compile) so the + error is the BCR-only message, not a cartridge/compile error. + """ + with pytest.raises(ValueError) as excinfo: + ( + ga.Experiment.on("human_tcrb") + .allow_curatable_refdata() + .recombine() + .clonal_lineage(n_clones=2, n_sample=10) + ) + msg = str(excinfo.value) + assert "BCR" in msg + assert "TCR" in msg + + +def test_clonal_lineage_allows_bcr_locus(): + """Sanity: the BCR-only guard does not fire on an Ig locus.""" + exp = ga.Experiment.on("human_igh").recombine().clonal_lineage( + n_clones=1, n_sample=5 + ) + assert exp is not None + + +# --------------------------------------------------------------------------- +# Founder-survival guard: every requested clone yields a surviving family +# --------------------------------------------------------------------------- + +def test_survival_guard_yields_all_clones_by_default(): + """By default (allow_extinction=False) every requested clone survives via + deterministic retry, so clone_ids == {0..n_clones-1} — even at a + lambda_base where a single founder goes extinct ~25% of the time. + """ + result = ( + ga.Experiment.on("human_igh") + .recombine() + .clonal_lineage(n_clones=5, max_generations=6, n_max=200, + n_sample=15, rate=0.05, lambda_base=1.5) + .run_records(seed=0) + ) + cids = {r["clone_id"] for r in result.records} + assert cids == {0, 1, 2, 3, 4} + + +def test_survival_guard_is_deterministic(): + """Same top-level seed => same result, even with retries.""" + def run(): + return ( + ga.Experiment.on("human_igh") + .recombine() + .clonal_lineage(n_clones=5, max_generations=6, n_max=200, + n_sample=15, rate=0.05, lambda_base=1.5) + .run_records(seed=0) + ) + a = run().records + b = run().records + assert len(a) == len(b) + assert [r["sequence"] for r in a] == [r["sequence"] for r in b] + assert [r["clone_id"] for r in a] == [r["clone_id"] for r in b] + + +def test_allow_extinction_true_allows_missing_clones(): + """allow_extinction=True accepts extinction and skips extinct clones, so + clone_ids is a (possibly proper) subset of the requested range. + """ + result = ( + ga.Experiment.on("human_igh") + .recombine() + .clonal_lineage(n_clones=5, max_generations=6, n_max=200, + n_sample=15, rate=0.05, lambda_base=1.5, + allow_extinction=True) + .run_records(seed=0) + ) + cids = {r["clone_id"] for r in result.records} + assert cids <= {0, 1, 2, 3, 4} + + +# --------------------------------------------------------------------------- +# AIRR-standard duplicate_count mirrors lineage_abundance +# --------------------------------------------------------------------------- + +def test_records_emit_duplicate_count_equal_to_abundance(): + result = _base_exp(n_clones=3, lambda_base=1.6).run_records(seed=0) + assert len(result.records) > 0 + for rec in result.records: + assert "duplicate_count" in rec + assert rec["duplicate_count"] == rec["lineage_abundance"] + + # --------------------------------------------------------------------------- # Smoke test: a valid call still works end-to-end # --------------------------------------------------------------------------- From f596dd0a0958a8f39619c4cc58afe92ddfee4ea2 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 09:12:12 +0300 Subject: [PATCH 49/59] docs(site): regenerate detection figure from corrected engine (final-live sampling + survival guard); ARI still 1.0 --- site_docs/assets/clonal-lineage-detection.png | Bin 97838 -> 92461 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/site_docs/assets/clonal-lineage-detection.png b/site_docs/assets/clonal-lineage-detection.png index 711f0fceff6316b74ed182f1a2da95515d1a45e6..1f075773dc3020eb4859f9bbd440e2b690d39b01 100644 GIT binary patch literal 92461 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 literal 97838 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 z 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 From 01b6d524c428a45b6d97e0e89a5c69c8710f9099 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 09:21:48 +0300 Subject: [PATCH 50/59] docs(lineage): correct selection_strength/target_aa affinity-reporting docstring --- src/GenAIRR/experiment.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index ceaa3d4..51e3156 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -1099,9 +1099,11 @@ def clonal_lineage( lambda_base: Poisson mean for offspring count at affinity 0. selection_strength: - Selection pressure; ``0.0`` = neutral drift (``lineage_affinity`` - will be 0.0 for every cell). Set ``> 0`` to enable affinity - maturation; combine with ``target_aa`` for a fixed antigen target. + Selection pressure; ``0.0`` = neutral drift (fitness is 1.0 for + every cell). This disables selection but does not force + ``lineage_affinity`` to 0 when a target sequence is supplied. + Set ``> 0`` to enable affinity maturation; combine with + ``target_aa`` for a fixed sequence target. beta: Scaling factor for the affinity term in ``exp(−beta·distance)``. target_aa: @@ -1112,8 +1114,9 @@ def clonal_lineage( a non-empty string of standard amino-acid letters (``ACDEFGHIKLMNPQRSTVWY``). When ``None``, an auto target is generated from the founder by applying ``mature_substitutions`` - random residue changes. ``selection_strength=0`` makes - ``lineage_affinity ≡ 0`` regardless. + random residue changes whenever selection is enabled. In fully + neutral mode (``selection_strength=0`` and ``target_aa=None``), + no affinity model is built and ``lineage_affinity`` is 0. mature_substitutions: Number of amino-acid substitutions used to build the auto target (when ``target_aa`` is ``None``). @@ -2867,4 +2870,3 @@ def _steps_with_locks_resolved(self) -> List[Any]: def __repr__(self) -> str: return f"" - From 592dbe817236878d65954f3d884ea40bf529e86e Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 09:31:38 +0300 Subject: [PATCH 51/59] feat(lineage): expose sample_clone_sizes to Python (clone-size distributions) --- engine_rs/src/python/lineage.rs | 52 ++++++++++++++++++++++++++++++++ engine_rs/src/python/mod.rs | 1 + tests/test_clone_size_sampler.py | 29 ++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 tests/test_clone_size_sampler.py diff --git a/engine_rs/src/python/lineage.rs b/engine_rs/src/python/lineage.rs index 391b8fd..c1c7ba9 100644 --- a/engine_rs/src/python/lineage.rs +++ b/engine_rs/src/python/lineage.rs @@ -558,3 +558,55 @@ pub(crate) fn simulate_family_outcomes( Ok(PyFamilyOutcome { tree, node_outcomes }) } + +/// Draw `n_clones` clone sizes (>=1) from a heavy-tailed distribution, with a +/// fraction `unexpanded_fraction` forced to size 1. Deterministic for `seed`. +/// `kind` is "power_law" or "lognormal". +#[pyfunction] +#[pyo3(signature = (n_clones, seed, kind="power_law", exponent=2.0, mu=1.0, sigma=1.0, max_size=100_000, unexpanded_fraction=0.0))] +#[allow(clippy::too_many_arguments)] +pub(crate) fn sample_clone_sizes( + n_clones: u32, + seed: u64, + kind: &str, + exponent: f64, + mu: f64, + sigma: f64, + max_size: u32, + unexpanded_fraction: f64, +) -> PyResult> { + use pyo3::exceptions::PyValueError; + if max_size == 0 { + return Err(PyValueError::new_err("sample_clone_sizes: max_size must be > 0")); + } + if !(unexpanded_fraction.is_finite() && (0.0..=1.0).contains(&unexpanded_fraction)) { + return Err(PyValueError::new_err( + "sample_clone_sizes: unexpanded_fraction must be in [0.0, 1.0]", + )); + } + let dist = match kind { + "power_law" => { + if !(exponent.is_finite() && exponent > 0.0) { + return Err(PyValueError::new_err( + "sample_clone_sizes: power_law exponent must be finite and > 0", + )); + } + crate::lineage::CloneSizeDist::PowerLaw { exponent, x_max: max_size } + } + "lognormal" => { + if !(mu.is_finite() && sigma.is_finite() && sigma >= 0.0) { + return Err(PyValueError::new_err( + "sample_clone_sizes: lognormal mu/sigma must be finite and sigma >= 0", + )); + } + crate::lineage::CloneSizeDist::LogNormal { mu, sigma, x_max: max_size } + } + other => { + return Err(PyValueError::new_err(format!( + "sample_clone_sizes: unknown kind {other:?}; use \"power_law\" or \"lognormal\"" + ))); + } + }; + let mut rng = crate::rng::Rng::new(seed); + Ok(crate::lineage::sample_repertoire_sizes(&mut rng, n_clones, &dist, unexpanded_fraction)) +} diff --git a/engine_rs/src/python/mod.rs b/engine_rs/src/python/mod.rs index 3a56fec..8186fdb 100644 --- a/engine_rs/src/python/mod.rs +++ b/engine_rs/src/python/mod.rs @@ -54,6 +54,7 @@ pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(pyo3::wrap_pyfunction!(lineage::simulate_lineage, m)?)?; m.add_function(pyo3::wrap_pyfunction!(lineage::simulate_family_outcomes, m)?)?; m.add_function(pyo3::wrap_pyfunction!(lineage::merge_lineage_corruption, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(lineage::sample_clone_sizes, m)?)?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/tests/test_clone_size_sampler.py b/tests/test_clone_size_sampler.py new file mode 100644 index 0000000..becb4ca --- /dev/null +++ b/tests/test_clone_size_sampler.py @@ -0,0 +1,29 @@ +from GenAIRR import _engine + + +def test_sample_clone_sizes_basic(): + sizes = _engine.sample_clone_sizes(500, 0) # defaults: power_law exp 2 + assert len(sizes) == 500 + assert all(s >= 1 for s in sizes) + assert max(sizes) > 1 # heavy tail reaches >1 + + +def test_sample_clone_sizes_deterministic(): + a = _engine.sample_clone_sizes(200, 7, kind="power_law", exponent=2.0) + b = _engine.sample_clone_sizes(200, 7, kind="power_law", exponent=2.0) + assert a == b + + +def test_unexpanded_fraction_forces_singletons(): + sizes = _engine.sample_clone_sizes(1000, 3, unexpanded_fraction=0.4) + assert sum(1 for s in sizes if s == 1) >= 400 + + +def test_lognormal_and_validation(): + import pytest + sizes = _engine.sample_clone_sizes(100, 1, kind="lognormal", mu=1.0, sigma=1.0, max_size=10000) + assert len(sizes) == 100 and all(1 <= s <= 10000 for s in sizes) + with pytest.raises(ValueError): + _engine.sample_clone_sizes(10, 0, kind="bogus") + with pytest.raises(ValueError): + _engine.sample_clone_sizes(10, 0, unexpanded_fraction=2.0) From 8a2e951f5eb09849d7257ae654607ed051ebc94b Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 09:49:50 +0300 Subject: [PATCH 52/59] feat(repertoire): clonal_repertoire DSL (heavy-tailed clone sizes, TCR+flat-BCR, duplicate_count collapse) --- src/GenAIRR/_compiled.py | 140 ++++++++++++++++++++- src/GenAIRR/_pipeline_ir.py | 24 ++++ src/GenAIRR/experiment.py | 212 +++++++++++++++++++++++++++++++- tests/test_clonal_repertoire.py | 101 +++++++++++++++ 4 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 tests/test_clonal_repertoire.py diff --git a/src/GenAIRR/_compiled.py b/src/GenAIRR/_compiled.py index ce59654..9c53d6d 100644 --- a/src/GenAIRR/_compiled.py +++ b/src/GenAIRR/_compiled.py @@ -24,7 +24,7 @@ _describe_step_sequence, _format_active_contracts, ) -from ._pipeline_ir import _ClonalForkStep, _LineageForkStep +from ._pipeline_ir import _ClonalForkStep, _LineageForkStep, _RepertoireForkStep if TYPE_CHECKING: from .dataconfig import DataConfig @@ -742,3 +742,141 @@ def __repr__(self) -> str: f"" ) + + +class CompiledRepertoireExperiment: + """A compiled non-tree clonal-repertoire experiment. + + Wraps a pre-fork :class:`GenAIRR._engine.CompiledSimulator` (the + founder recombination, run once per clone) and an optional + post-fork simulator (the per-read library-prep / sequencing passes). + Per clone a size is drawn from a heavy-tailed distribution via + ``_engine.sample_clone_sizes``; that many reads are emitted through + the post-fork passes and identical reads are genotype-collapsed into + AIRR records carrying a standard ``duplicate_count``. + + When there are no post-fork passes every read of a clone is + identical, so the clone collapses to a single record whose + ``duplicate_count`` equals the drawn size (the no-corruption + shortcut). Every record carries an integer ``clone_id``. + """ + + __slots__ = ( + "_pre", + "_post", + "_step", + "_refdata", + "_dataconfig", + "_metadata", + ) + + def __init__( + self, + pre_simulator: "_engine.CompiledSimulator", + post_simulator: Optional["_engine.CompiledSimulator"], + step: "_RepertoireForkStep", + refdata: "_engine.RefDataConfig", + dataconfig: Optional["DataConfig"] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> None: + self._pre = pre_simulator + self._post = post_simulator + self._step = step + self._refdata = refdata + self._dataconfig = dataconfig + self._metadata = dict(metadata) if metadata else {} + + @property + def refdata(self) -> "_engine.RefDataConfig": + return self._refdata + + def run_records( + self, + *, + seed: int = 0, + strict: bool = False, + validate_records: bool = False, + expose_provenance: bool = False, + ) -> "SimulationResult": + """Draw per-clone sizes, emit reads through the post-fork passes, + collapse identical reads, and return a :class:`SimulationResult` + whose record dicts carry ``clone_id`` and ``duplicate_count``. + """ + import GenAIRR._engine as _engine + + from ._airr_record import outcome_to_airr_record + from .result import SimulationResult, _inject_truth_columns + + step = self._step + sizes = _engine.sample_clone_sizes( + step.n_clones, + int(seed), + kind=step.size_distribution, + exponent=step.exponent, + mu=step.mu, + sigma=step.sigma, + max_size=step.max_size, + unexpanded_fraction=step.unexpanded_fraction, + ) + records: List[Dict[str, Any]] = [] + outcomes: List["_engine.Outcome"] = [] + for clone_idx, size in enumerate(sizes): + clone_seed = int(seed) + clone_idx * 1_000_000 + founder = self._pre.run(seed=clone_seed, strict=strict) + founder_sim = founder.final_simulation() + if self._post is None: + # No post-fork passes: all ``size`` copies are + # identical -> one record with duplicate_count = size. + rec = outcome_to_airr_record( + founder, + self._refdata, + sequence_id=f"clone{clone_idx}_read0", + ) + rec["clone_id"] = clone_idx + rec["duplicate_count"] = int(size) + if expose_provenance: + _inject_truth_columns(founder, self._refdata, rec) + records.append(rec) + outcomes.append(founder) + continue + # Post-fork passes present: simulate ``size`` reads and + # collapse by emitted sequence. + by_seq: Dict[str, list] = {} + for read_idx in range(int(size)): + desc = self._post.run_from( + founder_sim, clone_seed + 1 + read_idx, strict=strict + ) + rec = outcome_to_airr_record( + desc, + self._refdata, + sequence_id=f"clone{clone_idx}_read{read_idx}", + ) + key = rec["sequence"] + if key in by_seq: + by_seq[key][0] += 1 + else: + by_seq[key] = [1, desc, rec] + for count, desc, rec in by_seq.values(): + rec["clone_id"] = clone_idx + rec["duplicate_count"] = count + if expose_provenance: + _inject_truth_columns(desc, self._refdata, rec) + records.append(rec) + outcomes.append(desc) + result = SimulationResult(records, outcomes=outcomes) + if validate_records: + from ._validation import ( + _raise_on_family_validation_failure, + _raise_on_validation_failure, + ) + + _raise_on_validation_failure(result.validate_records(self._refdata)) + _raise_on_family_validation_failure(result.validate_families()) + return result + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/src/GenAIRR/_pipeline_ir.py b/src/GenAIRR/_pipeline_ir.py index 2938098..ac7b6c7 100644 --- a/src/GenAIRR/_pipeline_ir.py +++ b/src/GenAIRR/_pipeline_ir.py @@ -188,6 +188,30 @@ class _ClonalForkStep: size: int +@dataclass(frozen=True) +class _RepertoireForkStep: + """Marks a non-tree clonal-repertoire fork. + + Generalizes :class:`_ClonalForkStep`: instead of a fixed + ``per_clone`` size, each clone draws a size from a heavy-tailed + distribution (with an unexpanded-singleton fraction). That many + reads pass through the post-fork library-prep / sequencing passes + and identical reads collapse into AIRR records carrying a standard + ``duplicate_count``. + + Causes :meth:`Experiment.compile` to return a + :class:`~GenAIRR._compiled.CompiledRepertoireExperiment`. + """ + + n_clones: int + size_distribution: str + exponent: float + mu: float + sigma: float + max_size: int + unexpanded_fraction: float + + @dataclass(frozen=True) class _LineageForkStep: """Marks an affinity-maturation lineage fork. diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index 51e3156..11ab22c 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -49,7 +49,12 @@ _lower_recombine, lower_step, ) -from ._compiled import CompiledClonalExperiment, CompiledExperiment, CompiledLineageExperiment +from ._compiled import ( + CompiledClonalExperiment, + CompiledExperiment, + CompiledLineageExperiment, + CompiledRepertoireExperiment, +) from ._refdata_resolver import ( _CONFIG_ALIASES, ExperimentInput, @@ -72,8 +77,9 @@ _LineageForkStep, _MutateStep, _PairedEndStep, - _ReceptorRevisionStep, _RecombineStep, + _ReceptorRevisionStep, + _RepertoireForkStep, ) @@ -1045,6 +1051,145 @@ def expand_clones( self._steps.append(_ClonalForkStep(n_clones=n_clones, size=per_clone)) return self + def clonal_repertoire( + self, + *, + n_clones: int, + size_distribution: str = "power_law", + exponent: float = 2.0, + mu: float = 1.0, + sigma: float = 1.0, + max_size: int = 1000, + unexpanded_fraction: float = 0.0, + ) -> "Experiment": + """Expand the pipeline into a non-tree clonal **repertoire**. + + This is the non-tree clonal model (contrast with + :meth:`clonal_lineage`, which grows real affinity-maturation + lineage *trees*). It generalizes the deprecated + :meth:`expand_clones`: instead of a fixed ``per_clone`` size, + each clone draws a size from a heavy-tailed distribution (with + an unexpanded-singleton fraction). That many reads pass through + the post-fork library-prep / sequencing passes, and identical + reads are genotype-collapsed into AIRR records carrying a + standard ``duplicate_count`` that records abundance. + + Like :meth:`expand_clones`, this call marks the per-clone / + per-read boundary: steps appended *before* it run **once per + clone** (typically just :meth:`recombine`, establishing the + clonal V/D/J + trim + NP backbone); steps appended *after* it + run **once per read** (the library-prep / sequencing passes + that introduce per-read divergence). + + Concrete shape:: + + exp = (Experiment.on("human_igh") + .recombine() + .clonal_repertoire(n_clones=200, max_size=500, + unexpanded_fraction=0.3) + .sequencing_errors(rate=0.005)) + result = exp.run_records(seed=0) + # Each record carries a `clone_id` and a `duplicate_count`. + + Parameters: + - ``n_clones`` — number of clones (positive int). + - ``size_distribution`` — ``"power_law"`` or ``"lognormal"``. + - ``exponent`` — power-law exponent (``> 0``; used when + ``size_distribution="power_law"``). + - ``mu`` / ``sigma`` — lognormal parameters (``sigma >= 0``; + used when ``size_distribution="lognormal"``). + - ``max_size`` — clamps the largest clone. Because the total + number of reads simulated is roughly the **sum** of the drawn + sizes when post-fork passes are present, keep ``max_size`` + modest to bound runtime. + - ``unexpanded_fraction`` — fraction of clones forced to size 1 + (unexpanded singletons), in ``[0, 1]``. + + TCR works out of the box (no SHM): the reads diverge only + through the post-fork sequencing passes. Note that ``mutate`` + after this call is rejected on TCR by :meth:`mutate`'s own TCR + guard; on BCR an optional post-fork ``mutate`` adds flat SHM. + + **No-corruption shortcut:** a clone with no post-fork passes + emits identical copies, so it collapses to a single record whose + ``duplicate_count`` equals the drawn size. + + Constraints: + - At most one fork per pipeline — calling this when an + :meth:`expand_clones`, :meth:`clonal_lineage`, or + :meth:`clonal_repertoire` fork is already present raises + ``ValueError``. + - The same descendant-phase ordering guard as + :meth:`expand_clones` applies: a descendant-phase step (e.g. + ``.mutate()``) appended *before* this call is rejected; + :meth:`recombine` is fine. + """ + if not isinstance(n_clones, int) or isinstance(n_clones, bool) or n_clones < 1: + raise ValueError( + f"n_clones must be a positive int, got {n_clones!r}" + ) + if size_distribution not in ("power_law", "lognormal"): + raise ValueError( + "size_distribution must be 'power_law' or 'lognormal', got " + f"{size_distribution!r}" + ) + if not (isinstance(exponent, (int, float)) and exponent > 0): + raise ValueError(f"exponent must be > 0, got {exponent!r}") + if not (isinstance(sigma, (int, float)) and sigma >= 0): + raise ValueError(f"sigma must be >= 0, got {sigma!r}") + if not isinstance(max_size, int) or isinstance(max_size, bool) or max_size < 1: + raise ValueError(f"max_size must be a positive int, got {max_size!r}") + if not ( + isinstance(unexpanded_fraction, (int, float)) + and 0.0 <= unexpanded_fraction <= 1.0 + ): + raise ValueError( + "unexpanded_fraction must be in [0, 1], got " + f"{unexpanded_fraction!r}" + ) + if any( + isinstance(s, (_ClonalForkStep, _RepertoireForkStep, _LineageForkStep)) + for s in self._steps + ): + raise ValueError( + "clonal_repertoire() / expand_clones() / clonal_lineage() " + "can only be called once per pipeline" + ) + # Same descendant-phase ordering guard as expand_clones: a + # descendant-phase step (mutate / corruption / paired_end) + # appended before the fork is rejected. + for step in self._steps: + offending_method = _descendant_phase_step_classifier(step) + if offending_method is None: + continue + if offending_method == "mutate": + detail = ( + "SHM is descendant-specific in GenAIRR's current " + "clonal model" + ) + else: + detail = ( + "it is descendant-specific and must be sampled " + "independently for each read" + ) + raise ValueError( + f"{offending_method} must be called after " + f"clonal_repertoire(); {detail}. Move " + f"{offending_method}(...) after clonal_repertoire(...)." + ) + self._steps.append( + _RepertoireForkStep( + n_clones=n_clones, + size_distribution=size_distribution, + exponent=float(exponent), + mu=float(mu), + sigma=float(sigma), + max_size=max_size, + unexpanded_fraction=float(unexpanded_fraction), + ) + ) + return self + def clonal_lineage( self, *, @@ -2476,6 +2621,55 @@ def compile(self, *, allow_curatable_refdata: Optional[bool] = None): metadata=self._metadata, ) + # if a `_RepertoireForkStep` is present, split the step list + # at it and compile the pre-fork (per-clone) simulator plus an + # optional post-fork (per-read) simulator, mirroring the + # `_ClonalForkStep` branch. Per-clone sizes are drawn at run + # time from the heavy-tailed distribution; identical reads are + # collapsed into `duplicate_count`-carrying records. + repertoire_idx = next( + ( + i + for i, s in enumerate(self._steps) + if isinstance(s, _RepertoireForkStep) + ), + None, + ) + if repertoire_idx is not None: + repertoire_step: _RepertoireForkStep = self._steps[repertoire_idx] + pre_steps = self._steps[:repertoire_idx] + post_steps = self._steps[repertoire_idx + 1 :] + pre_simulator = self._build_simulator( + pre_steps, + contracts, + any_lock, + replace_fn=_replace, + allow_curatable_refdata=allow_curatable_refdata, + ) + # post_steps may be empty — that's the pure-copy case + # (each clone collapses to one record with + # duplicate_count = size). When present, the post-fork + # plan inherits the parent's V/D/J/NP backbone, so + # any_lock=False and no recombination facts on this side + # (same as the clonal branch). + post_simulator = None + if post_steps: + post_simulator = self._build_simulator( + post_steps, + contracts, + any_lock=False, + replace_fn=_replace, + allow_curatable_refdata=allow_curatable_refdata, + ) + return CompiledRepertoireExperiment( + pre_simulator, + post_simulator, + repertoire_step, + self._refdata, + dataconfig=self._dataconfig, + metadata=self._metadata, + ) + # if a `_LineageForkStep` is present, compile a # CompiledLineageExperiment: pre-fork steps (recombine) become # the founder simulator; post-fork steps must be empty (the @@ -2692,6 +2886,20 @@ def run_records( expose_provenance=expose_provenance, validate_records=validate_records, ) + elif isinstance(compiled, CompiledRepertoireExperiment): + if n is not None: + raise ValueError( + "The 'n' parameter is not supported for clonal_repertoire " + "experiments. The number of records depends on the per-clone " + "sizes drawn from the heavy-tailed distribution and the " + "read-collapse, not a fixed product." + ) + result = compiled.run_records( + seed=seed, + strict=strict, + expose_provenance=expose_provenance, + validate_records=validate_records, + ) elif isinstance(compiled, CompiledLineageExperiment): if n is not None: raise ValueError( diff --git a/tests/test_clonal_repertoire.py b/tests/test_clonal_repertoire.py new file mode 100644 index 0000000..3532446 --- /dev/null +++ b/tests/test_clonal_repertoire.py @@ -0,0 +1,101 @@ +"""Tests for the non-tree clonal repertoire DSL (``clonal_repertoire``). + +``clonal_repertoire`` generalizes the deprecated ``expand_clones``: per +clone a size is drawn from a heavy-tailed distribution (with an +unexpanded-singleton fraction), that many reads are emitted through the +post-fork library-prep / sequencing passes, and identical reads are +genotype-collapsed into AIRR records carrying ``duplicate_count``. +""" + +from collections import Counter + +import pytest + +import GenAIRR as ga + + +def test_bcr_repertoire_with_corruption_has_clone_ids_and_duplicate_count(): + r = (ga.Experiment.on("human_igh").recombine() + .clonal_repertoire(n_clones=20, max_size=50, unexpanded_fraction=0.3) + .sequencing_errors(rate=0.005) + .run_records(seed=0)) + assert len(r.records) > 0 + assert all("duplicate_count" in rec and rec["duplicate_count"] >= 1 for rec in r.records) + assert all("clone_id" in rec for rec in r.records) + per_clone = Counter(rec["clone_id"] for rec in r.records) + assert min(per_clone.values()) >= 1 + + +def test_pure_copy_no_postfork_one_record_per_clone(): + r = (ga.Experiment.on("human_igh").recombine() + .clonal_repertoire(n_clones=10, max_size=100).run_records(seed=1)) + # no post-fork passes -> each clone collapses to ONE record with + # duplicate_count = size + per_clone = Counter(rec["clone_id"] for rec in r.records) + assert all(c == 1 for c in per_clone.values()) + assert all(rec["duplicate_count"] >= 1 for rec in r.records) + + +def test_tcr_repertoire_runs_no_shm(): + r = (ga.Experiment.on("human_tcrb").allow_curatable_refdata().recombine() + .clonal_repertoire(n_clones=10, max_size=50).run_records(seed=0)) + assert len(r.records) > 0 + assert all(rec.get("n_mutations", 0) == 0 for rec in r.records) + assert all("duplicate_count" in rec for rec in r.records) + + +def test_tcr_repertoire_rejects_mutate(): + with pytest.raises(Exception): + (ga.Experiment.on("human_tcrb").allow_curatable_refdata().recombine() + .clonal_repertoire(n_clones=5, max_size=10).mutate(rate=0.05).compile()) + + +def test_determinism(): + mk = lambda: (ga.Experiment.on("human_igh").recombine() + .clonal_repertoire(n_clones=8, max_size=40).run_records(seed=3)) + a, b = mk(), mk() + assert [r["sequence"] for r in a.records] == [r["sequence"] for r in b.records] + assert [r["duplicate_count"] for r in a.records] == [r["duplicate_count"] for r in b.records] + + +def test_validation_works(): + r = (ga.Experiment.on("human_igh").recombine() + .clonal_repertoire(n_clones=5, max_size=30).sequencing_errors(rate=0.003) + .run_records(seed=0, validate_records=True)) + assert len(r.records) > 0 + + +def test_clonal_repertoire_rejects_premature_mutate(): + # .mutate() before clonal_repertoire is a descendant-phase step and + # must be rejected (SHM is descendant-specific). + with pytest.raises(ValueError): + (ga.Experiment.on("human_igh").recombine() + .mutate(rate=0.05).clonal_repertoire(n_clones=5, max_size=10)) + + +def test_clonal_repertoire_rejects_double_fork(): + with pytest.raises(ValueError): + (ga.Experiment.on("human_igh").recombine() + .clonal_repertoire(n_clones=5, max_size=10) + .clonal_repertoire(n_clones=3, max_size=5)) + + +def test_clonal_repertoire_validates_args(): + base = ga.Experiment.on("human_igh").recombine() + with pytest.raises(ValueError): + base.clonal_repertoire(n_clones=0) + with pytest.raises(ValueError): + ga.Experiment.on("human_igh").recombine().clonal_repertoire( + n_clones=5, size_distribution="bogus") + with pytest.raises(ValueError): + ga.Experiment.on("human_igh").recombine().clonal_repertoire( + n_clones=5, exponent=0.0) + with pytest.raises(ValueError): + ga.Experiment.on("human_igh").recombine().clonal_repertoire( + n_clones=5, sigma=-1.0) + with pytest.raises(ValueError): + ga.Experiment.on("human_igh").recombine().clonal_repertoire( + n_clones=5, max_size=0) + with pytest.raises(ValueError): + ga.Experiment.on("human_igh").recombine().clonal_repertoire( + n_clones=5, unexpanded_fraction=1.5) From aae6113dcbdb180c265d152220e82196c8206d30 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 09:53:58 +0300 Subject: [PATCH 53/59] docs(site): clonal_repertoire guide (TCR & abundance) + point TCR note at it --- mkdocs.yml | 1 + site_docs/guides/clonal-lineage.md | 36 ++--- site_docs/guides/clonal-repertoire.md | 216 ++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 site_docs/guides/clonal-repertoire.md diff --git a/mkdocs.yml b/mkdocs.yml index ad9ccb0..5829892 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -206,6 +206,7 @@ nav: - D inversion + receptor revision: guides/recombination-editing.md - Clonal families: guides/clonal-families.md - Clonal lineage trees: guides/clonal-lineage.md + - Clonal repertoires (TCR & abundance): guides/clonal-repertoire.md - Junction N/P additions: guides/junction-additions.md - Targeted SHM rates: guides/shm-targeting.md - Corruption + sequencing artefacts: guides/corruption-sequencing.md diff --git a/site_docs/guides/clonal-lineage.md b/site_docs/guides/clonal-lineage.md index 2147095..1758ae4 100644 --- a/site_docs/guides/clonal-lineage.md +++ b/site_docs/guides/clonal-lineage.md @@ -29,9 +29,9 @@ truth for lineage reconstruction. `clonal_lineage` adds the missing biology. > `clonal_lineage` applies S5F SHM, so calling it on a TCR locus raises a clear > `ValueError`. A TCR "clone" is one rearrangement proliferated to many identical > copies; the meaningful quantity is the **clone-size distribution**, not a mutation -> tree. GenAIRR has the heavy-tailed clone-size **primitives** in the engine, but -> they are **not yet exposed as a DSL workflow** — there is no `clonal_lineage` TCR -> path today (see +> tree. For **TCR and flat clonal repertoires**, use +> [`clonal_repertoire`](clonal-repertoire.html) — it draws a heavy-tailed clone size +> per clone and emits `clone_id` + `duplicate_count` (see > [Clone-size distributions](#clone-size-distributions-tcr-and-repertoire-mix)). ## Quick start @@ -294,21 +294,21 @@ columns from the founder assignments, and `result.outcomes` carries the per-reco ## Clone-size distributions (TCR and repertoire mix) -> **Engine primitives, not yet a DSL workflow.** `clonal_lineage` itself is -> **BCR-only** — there is **no `clonal_lineage` TCR path today**. The clone-size -> machinery below lives in the engine but is **not yet wired into a fluent DSL -> workflow**; it is documented here as a forward-looking capability, not as -> something you can drive from `clonal_lineage(...)`. - -Real repertoires are not uniform: a few clones are huge, most are singletons. The -engine includes heavy-tailed **clone-size distributions** (`CloneSizeDist`: -power-law/Zipf by default, log-normal optional) and a repertoire-composition -sampler that draws a set of clone sizes with a controllable **unexpanded fraction** -(size-1, never-expanded clones). For TCR — which has no SHM — a clone is simply one -rearrangement at copy-number `size`, with within-clone variation coming only from -the existing sequencing/PCR-error passes. These primitives are the basis for -mixing large expanded families with a realistic singleton tail, but exposing them -as a TCR clone-size DSL workflow is still future work. +> **For TCR, use [`clonal_repertoire`](clonal-repertoire.html).** `clonal_lineage` +> itself is **BCR-only** — it still rejects TCR loci. The heavy-tailed clone-size +> model described below is now exposed as a fluent DSL workflow via +> [`clonal_repertoire`](clonal-repertoire.html); that is the TCR (and flat-BCR-abundance) +> path. This section explains the model; the dedicated guide is the place to drive it. + +Real repertoires are not uniform: a few clones are huge, most are singletons. +[`clonal_repertoire`](clonal-repertoire.html) draws **clone sizes** from a +heavy-tailed distribution (power-law/Zipf by default, log-normal optional) with a +controllable **unexpanded fraction** (size-1, never-expanded clones). For TCR — +which has no SHM — a clone is simply one rearrangement at copy-number `size`, with +within-clone variation coming only from the post-fork sequencing/PCR-error passes; +identical reads collapse into AIRR records carrying `clone_id` + `duplicate_count`. +That mixes large expanded families with a realistic singleton tail. See the +[Clonal repertoires guide](clonal-repertoire.html) for the full workflow. ## Determinism diff --git a/site_docs/guides/clonal-repertoire.md b/site_docs/guides/clonal-repertoire.md new file mode 100644 index 0000000..864ee8d --- /dev/null +++ b/site_docs/guides/clonal-repertoire.md @@ -0,0 +1,216 @@ +# Clonal repertoires (TCR & abundance) + +

Where clonal_lineage +grows BCR affinity-maturation trees, clonal_repertoire builds +a non-tree clonal repertoire: each clone is one rearrangement proliferated +to a clone size drawn from a heavy-tailed distribution, and those +copies are emitted as reads through the library-prep / sequencing passes. Identical +reads collapse into AIRR records carrying the AIRR-standard duplicate_count. +It is the model for TCR repertoires (T cells don't somatically +hypermutate) and for flat BCR clonal abundance — the modern +replacement for the deprecated expand_clones, with realistic clone +sizes instead of a fixed per-clone count.

+ +## What it is & when to use it + +A real repertoire is a population of clones with wildly uneven sizes: a few huge +expanded clones and a long tail of singletons. `clonal_repertoire` reproduces that +structure. For each of `n_clones` clones it: + +1. runs the pre-fork plan (`recombine()`) **once** to fix the clone's + V/D/J + trim + NP backbone — the single rearrangement that defines the clone; +2. draws a **size** from a heavy-tailed clone-size distribution (power-law/Zipf by + default, log-normal optional), with a controllable **unexpanded-singleton fraction**; +3. emits that many **reads** through the post-fork library-prep / sequencing passes, + so reads diverge only by technical noise; +4. **genotype-collapses** identical reads into AIRR records, each carrying a + `clone_id` (ground-truth clone label) and a `duplicate_count` (abundance). + +Reach for it when you want a repertoire whose **ground truth is clone membership + +abundance** — the input clone-callers and abundance-aware tools expect — rather than +a per-clone mutation genealogy. + +### How it compares + +| | What it models | Ground truth | Loci | +|---|---|---|---| +| **`clonal_repertoire`** | Non-tree clonal abundance; one rearrangement × N copies + technical noise | `clone_id` + `duplicate_count` | **TCR** and flat **BCR** | +| [`clonal_lineage`](clonal-lineage.html) | BCR affinity-maturation **trees** (per-division SHM, selection) | Lineage tree + per-cell records | **BCR only** | +| `expand_clones` *(deprecated)* | Star: fixed `per_clone` count, **no** size distribution | `clone_id` | BCR / TCR | + +`clonal_repertoire` is the modern replacement for flat clonal expansion: instead of +`expand_clones`' fixed `per_clone` count, every clone draws a realistic heavy-tailed +size. For BCR **lineage trees** (genealogy, ancestral sequences, selection), use +[`clonal_lineage`](clonal-lineage.html) instead. + +## The biology + +A T-cell clone is the progeny of one rearranged T cell proliferated to many +**identical** copies. T cells do **not** somatically hypermutate, so all the +sequence diversity you observe *within* a TCR clone is **technical** — PCR and +sequencing error introduced during library prep — not biological. That is exactly +the shape `clonal_repertoire` produces: one rearrangement per clone, `size` copies, +divergence only through the post-fork sequencing passes. + +Clone **sizes** are empirically heavy-tailed — TCR clone-size distributions are +approximately **power-law** (a handful of enormous clones, a long tail of +singletons). The default `size_distribution="power_law"` (with `exponent≈2`) +captures that, and `unexpanded_fraction` sets the share of clones forced to be +**never-expanded singletons** (size 1) — the resting naive cells that were never +clonally expanded. + +For **BCR** the same flat model is useful when you want clonal abundance without a +genealogy. SHM is optional and applied flat across the clone's copies via a +post-fork `.mutate()` (see below) — there is no mutation tree. + +## Quick start (TCR) + +```python +import GenAIRR as ga + +result = (ga.Experiment.on("human_tcrb").allow_curatable_refdata().recombine() + .clonal_repertoire(n_clones=200, size_distribution="power_law", + exponent=2.0, max_size=500, unexpanded_fraction=0.5) + .sequencing_errors(rate=0.005) # per-read technical noise + .run_records(seed=0)) + +# Each record carries a ground-truth clone label + an abundance count: +for rec in result.records[:5]: + print(rec["clone_id"], rec["duplicate_count"], rec["v_call"], rec["j_call"]) + +# T cells don't hypermutate — every record has zero SHM: +assert all(rec.get("n_mutations", 0) == 0 for rec in result.records) +``` + +`allow_curatable_refdata()` is the usual opt-in for sampling from the bundled TCR +catalogue (which includes pseudogene/ORF alleles). `sequencing_errors(rate=...)` is +the per-read technical-noise pass — `rate` is a per-base error probability in +`[0, 1]` (drawn as `count ~ Poisson(rate × read_len)` per read). `pcr_amplify(rate=...)` +has the same shape; any of the post-fork library-prep passes +(`sequencing_errors`, `pcr_amplify`, `polymerase_indels`, `end_loss_*`, +`ambiguous_base_calls`, `random_strand_orientation`) can follow the fork. + +## Quick start (flat BCR with SHM) + +The same model works for BCR. Optionally add a post-fork `.mutate()` to apply flat +SHM independently to each copy (this is **not** a lineage tree — it's per-read +mutation off the shared founder): + +```python +import GenAIRR as ga + +result = (ga.Experiment.on("human_igh").recombine() + .clonal_repertoire(n_clones=100, max_size=300, unexpanded_fraction=0.3) + .mutate(model="s5f", rate=0.01) # flat SHM on each copy + .sequencing_errors(rate=0.005) + .run_records(seed=0)) +``` + +With **no** post-fork passes at all, every copy of a clone is identical, so the +clone collapses to a **single** record whose `duplicate_count` equals the drawn +size: + +```python +r = (ga.Experiment.on("human_igh").recombine() + .clonal_repertoire(n_clones=10, max_size=100) + .run_records(seed=1)) + +from collections import Counter +per_clone = Counter(rec["clone_id"] for rec in r.records) +assert all(c == 1 for c in per_clone.values()) # one record per clone +assert all(rec["duplicate_count"] >= 1 for rec in r.records) +``` + +> **`mutate` is BCR-only.** A post-fork `.mutate()` on a **TCR** experiment is +> rejected by `mutate`'s own TCR guard — T cells don't hypermutate. On TCR, leave +> SHM out; the post-fork sequencing passes provide all within-clone variation. + +## How it works + +```mermaid +flowchart TB + A["For each clone c = 0..n_clones:
recombine() once → founder rearrangement"] --> B["Draw size_c from the clone-size distribution
(deterministic for seed)"] + B --> C{"Post-fork passes?"} + C -->|"no"| D["1 record, duplicate_count = size_c"] + C -->|"yes"| E["Emit size_c reads through the
library-prep / sequencing passes
(each read its own seed)"] + E --> F["Collapse identical sequences →
records with duplicate_count"] + D --> G["Stamp clone_id = c"] + F --> G +``` + +Per clone, the engine draws the size, runs the founder recombination once, then +plays `size` reads through the post-fork plan (each with its own derived seed) and +collapses by emitted `sequence`. `clone_id` is the ground-truth clone index; +`duplicate_count` is the post-collapse abundance. + +**Determinism.** Everything is keyed on `seed`: clone sizes come from a seeded draw, +clone *c* recombines from `seed + c × 1_000_000`, and each read within a clone draws +from a derived sub-seed. Re-running with the same `seed` reproduces the records and +`duplicate_count`s byte-for-byte. + +**Read-count cost.** When post-fork passes are present, the **total reads simulated +is roughly the sum of the drawn clone sizes** (before collapse). A heavy tail with a +large `max_size` can therefore blow up runtime — keep `max_size` modest (the default +is 1000) and remember a few clones near `max_size` dominate the cost. + +## Parameters + +| Parameter | Default | Meaning | +|---|---|---| +| `n_clones` | — | Number of clones to simulate (positive int). Sets the number of distinct `clone_id`s. | +| `size_distribution` | `"power_law"` | Clone-size law: `"power_law"` (Zipf-like, heavy-tailed) or `"lognormal"`. | +| `exponent` | `2.0` | Power-law exponent (`> 0`), used when `size_distribution="power_law"`. Higher ⇒ steeper tail / more singletons; `~2–3` is typical for TCR. | +| `mu` | `1.0` | Log-normal location parameter, used when `size_distribution="lognormal"`. | +| `sigma` | `1.0` | Log-normal scale (`>= 0`), used when `size_distribution="lognormal"`. Larger ⇒ heavier tail. | +| `max_size` | `1000` | Upper clamp on any clone size. Bounds runtime (total reads ≈ Σ sizes when post-fork passes are present) — keep it modest. | +| `unexpanded_fraction` | `0.0` | Fraction of clones **forced** to size 1 (never-expanded singletons), in `[0, 1]`. The forced count is `round(n_clones × fraction)`. | + +> **Singletons come from two places.** The power-law size is drawn by a **continuous +> inverse-CDF** and then **rounded** to an integer in `[1, max_size]`, so even with +> `unexpanded_fraction=0` a large share of clones round to size 1 (for `exponent=2` +> the singleton share is **~1/3**). `unexpanded_fraction` adds a *forced* floor of +> size-1 clones **on top of** that natural tail, so raise it when you want an even +> larger never-expanded population. Because the power-law is a continuous draw that +> is rounded — not an exact discrete Zipf PMF — the realized distribution is an +> approximation of true discrete Zipf. + +## Ground truth & tooling + +Each record carries the two ground-truth fields the downstream ecosystem consumes: + +- **`clone_id`** — the planted **clone membership** label (which rearrangement this + read descends from). +- **`duplicate_count`** — the **abundance** of the collapsed record (AIRR-standard + field name). + +Write the records to an AIRR TSV (keeping the ground-truth label under a non-AIRR +column name so it doesn't collide with a tool's inferred `clone_id`): + +```python +import pandas as pd +df = pd.DataFrame(result.records).rename(columns={"clone_id": "true_clone_id"}) +df.to_csv("repertoire.tsv", sep="\t", index=False) +``` + +The output format is **what clone-callers and abundance-aware tools consume** — +Change-O `DefineClones` and SCOPer read AIRR TSV with `duplicate_count`, and TCR +tools like tcrdist read V/J + junction. You can score a tool's inferred clusters +against the planted `true_clone_id` (e.g. with `sklearn.metrics.adjusted_rand_score`). +We frame the records as **format-compatible** with these tools; this page does **not** +claim they were validated against `clonal_repertoire` output here. + +## Limitations + +- **No mutation tree.** `clonal_repertoire` is a flat, non-tree model — there are no + ancestral nodes, generations, or selection. For a BCR genealogy (Newick / FASTA / + node table, affinity maturation), use [`clonal_lineage`](clonal-lineage.html). + A post-fork `.mutate()` on BCR applies *flat* SHM per copy, not a lineage. +- **Power-law is continuous-rounded, not exact discrete Zipf.** Sizes come from a + continuous inverse-CDF rounded to integers; this approximates true discrete Zipf + rather than matching its PMF exactly. +- **Cost scales with Σ sizes.** With post-fork passes present, the simulator emits + roughly the sum of the drawn sizes in reads before collapse. Large clones combined + with heavy post-fork passes mean many reads — keep `max_size` modest. +- **One fork per pipeline.** `clonal_repertoire`, `expand_clones`, and + `clonal_lineage` are mutually exclusive in a single chain; descendant-phase steps + (e.g. `.mutate()`, the corruption passes) must come **after** the fork. From 9a6e272462a5dae191889407f29dda1f3d673716 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 12:36:53 +0300 Subject: [PATCH 54/59] docs(site): homepage advertises BCR clonal_lineage (flagship, ground-truth trees) instead of confusing repertoire+mutate --- site_docs/index.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/site_docs/index.md b/site_docs/index.md index 96037ac..c6c28fd 100644 --- a/site_docs/index.md +++ b/site_docs/index.md @@ -81,9 +81,9 @@ an aligner.

// Lineage · 03

-

Expand clones

-

Parent rearrangement forks N descendants. SHM accumulates - per-descendant after the fork.

+

Simulate clones

+

BCR lineage trees, TCR clone-size repertoires, and flat + abundance benchmarks with planted clone IDs.

@@ -112,17 +112,18 @@ an aligner.

```python import GenAIRR as ga +# Grow real BCR clonal lineage trees — affinity maturation, with ground truth result = ( ga.Experiment.on("human_igh") .recombine() - .expand_clones(n_clones=50, per_clone=20) - .mutate(rate=0.05) - .pcr_amplify(count=(0, 3)) - .ambiguous_base_calls(count=(0, 2)) - .productive_only().run_records(seed=42) + .clonal_lineage(n_clones=50, max_generations=6, n_sample=30, + rate=0.01, selection_strength=10.0) + .sequencing_errors(rate=0.001) + .run_records(seed=42) ) -result.to_tsv("repertoire.tsv") +result.to_tsv("repertoire.tsv") # per-cell AIRR records (clone_id, lineage_*) +newick = result.lineage_trees[0].to_newick() # ground-truth lineage tree per clone ``` ## Install. One command. No compiler. @@ -145,10 +146,10 @@ your task. | If you want to ... | Start here | |---|---| -| **Simulate sequences** | [Quick start](getting-started/quick-start.md) → [The Experiment builder](guides/experiment-builder.md) | +| **Simulate sequences** | [Quick start](getting-started/quick-start.md) → [The Experiment builder](guides/experiment-builder.md) → [API reference](reference/index.md) | | **Build a reference cartridge** | [Reference cartridge concept](concepts/reference-cartridge.md) → [Build a reference cartridge](guides/build-reference-cartridge.md) | -| **Get reproducible / validated output** | [Validation hub](validation/index.md) → [Trace, replay, reproducibility](guides/trace-replay.md) | -| **Understand the biological mechanisms** | [Recombination + junction biology](guides/recombination-junction.md), [SHM and mutation targeting](guides/shm-targeting.md), [Corruption + sequencing artefacts](guides/corruption-sequencing.md), [Clonal families](guides/clonal-families.md) | +| **Get reproducible / validated output** | [Validation hub](validation/index.md) → [Validate AIRR records](validation/validate-records.md) → [Trace, replay, reproducibility](guides/trace-replay.md) | +| **Understand the biological mechanisms** | [Recombination + junction biology](guides/recombination-junction.md), [SHM and mutation targeting](guides/shm-targeting.md), [Corruption + sequencing artefacts](guides/corruption-sequencing.md), [Clonal simulation overview](guides/clonal-families.md) | | **Contribute to GenAIRR** | [Architecture (Contributor)](architecture/index.md) | The **[Choose your path](learn.md)** page expands each of these From e6726f6727852c6b3c84221651af94eb3ac80d7f Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 12:44:23 +0300 Subject: [PATCH 55/59] test(clonal): update plan-split pin to generalized multi-fork rejection message --- tests/test_clonal_plan_split_contract.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_clonal_plan_split_contract.py b/tests/test_clonal_plan_split_contract.py index b65d1b9..6d4a45c 100644 --- a/tests/test_clonal_plan_split_contract.py +++ b/tests/test_clonal_plan_split_contract.py @@ -188,7 +188,10 @@ def test_pin_scaffold_ancestor_phase_post_fork_rejects(method) -> None: .recombine() .expand_clones(n_clones=1, per_clone=2) ) - with pytest.raises(ValueError, match="before expand_clones"): + # Message generalized to cover all three clonal forks + # (clonal_lineage / clonal_repertoire / expand_clones); the + # rejection behaviour is unchanged. + with pytest.raises(ValueError, match="before the clonal fork"): _ANCESTOR_PHASE_BUILDERS[method](exp) From 8ed5fbdd031b7d8e944b0bbc86174d4d2deb947e Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 12:48:15 +0300 Subject: [PATCH 56/59] docs(readme): add Clonal lineages & repertoires section (clonal_lineage + clonal_repertoire); flag expand_clones as legacy --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index 0db8d28..12cbd79 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ result = ( # Passes BEFORE this point apply to the parent rearrangement; # passes AFTER apply per-descendant. So each clone shares the # same V(D)J recombination but accumulates its own SHM + errors. + # NOTE: expand_clones is the legacy fixed-size *star* model. For + # real clonal trees and repertoires, see "Clonal lineages & + # repertoires" below (clonal_lineage / clonal_repertoire). .expand_clones(n_clones=50, per_clone=20) # 3. Somatic hypermutation per descendant — S5F context-dependent # model at 5% per-base rate (matches memory-B-cell SHM). @@ -145,6 +148,41 @@ result[0]["experiment_id"], result[0]["tissue"] # ('exp001', 'peripher result.to_tsv("repertoire.tsv") ``` +## Clonal lineages & repertoires + +GenAIRR models clonal structure three ways. Pick by what you need: + +| Method | Model | Use for | +|---|---|---| +| **`clonal_lineage`** | BCR affinity-maturation **trees** — generation-synchronous birth–death, per-division S5F SHM, optional affinity selection, sample + genotype-collapse | B-cell lineage trees with **ground truth** (Newick/FASTA per clone), benchmarking lineage/clone inference, ML training data | +| **`clonal_repertoire`** | Non-tree **abundance** repertoires — heavy-tailed clone sizes (power-law/lognormal) + unexpanded-singleton fraction, reads through library-prep, collapsed to `duplicate_count` | **TCR** repertoires (no SHM) and flat-BCR abundance; clone-calling benchmarks | +| `expand_clones` *(deprecated)* | Fixed-size **star**: one founder + `per_clone` independent descendants | legacy; kept working, but prefer the two above | + +```python +import GenAIRR as ga + +# BCR clonal lineage trees — affinity maturation, with ground truth +bcr = (ga.Experiment.on("human_igh").recombine() + .clonal_lineage(n_clones=50, max_generations=6, n_sample=30, + rate=0.01, selection_strength=10.0) # 0 = neutral + .sequencing_errors(rate=0.001) + .run_records(seed=0)) +bcr.records # per-cell AIRR records: clone_id, lineage_node_id, lineage_affinity, ... +bcr.lineage_trees[0].to_newick() # ground-truth tree (branch length = per-edge mutations) + +# TCR clone-size repertoire — proliferated rearrangements, no SHM, abundance +tcr = (ga.Experiment.on("human_tcrb").allow_curatable_refdata().recombine() + .clonal_repertoire(n_clones=200, size_distribution="power_law", + exponent=2.0, max_size=500, unexpanded_fraction=0.5) + .sequencing_errors(rate=0.005) + .run_records(seed=0)) +tcr.records # AIRR records: clone_id (membership) + duplicate_count (abundance) +``` + +`clonal_lineage` is BCR-only (it applies S5F SHM and rejects TCR loci); `clonal_repertoire` covers TCR and flat BCR. See the guides: +[Clonal lineage trees](site_docs/guides/clonal-lineage.md) · +[Clonal repertoires (TCR & abundance)](site_docs/guides/clonal-repertoire.md). + Other feature flags worth knowing: | Step | What it does | From a6746c579e2a85efcf3011b78008ab155bc96e16 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 12:52:59 +0300 Subject: [PATCH 57/59] docs: link new clonal guides at live mkdocs URLs; fix README-URL contract to resolve against the published mkdocs site (site_docs/) --- README.md | 6 +++--- tests/test_docs_website_contract.py | 14 +++++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 12cbd79..1e111f4 100644 --- a/README.md +++ b/README.md @@ -180,8 +180,8 @@ tcr.records # AIRR records: clone_id (membership) + duplicate_count ``` `clonal_lineage` is BCR-only (it applies S5F SHM and rejects TCR loci); `clonal_repertoire` covers TCR and flat BCR. See the guides: -[Clonal lineage trees](site_docs/guides/clonal-lineage.md) · -[Clonal repertoires (TCR & abundance)](site_docs/guides/clonal-repertoire.md). +[Clonal lineage trees](https://mutejester.github.io/GenAIRR/guides/clonal-lineage.html) · +[Clonal repertoires (TCR & abundance)](https://mutejester.github.io/GenAIRR/guides/clonal-repertoire.html). Other feature flags worth knowing: @@ -658,7 +658,7 @@ The full documentation site is at **[mutejester.github.io/GenAIRR](https://mutej - **Learn** — [Lesson 1: V(D)J recombination](https://mutejester.github.io/GenAIRR/lesson-1.html) · [Lesson 2: Pipeline scrubber](https://mutejester.github.io/GenAIRR/lesson-2.html) · [Lesson 3: S5F SHM](https://mutejester.github.io/GenAIRR/lesson-3.html) · [Lesson 4: Sequencing artifacts](https://mutejester.github.io/GenAIRR/lesson-4.html) · [Lesson 5: Ground-truth payoff](https://mutejester.github.io/GenAIRR/lesson-5.html) - **Concepts** — [Simulation Pipeline](https://mutejester.github.io/GenAIRR/concept-pipeline.html) · [Persistent IR](https://mutejester.github.io/GenAIRR/concept-persistent-ir.html) · [Contracts](https://mutejester.github.io/GenAIRR/concept-contracts.html) · [AIRR Record](https://mutejester.github.io/GenAIRR/concept-airr-record.html) · [Live Calls](https://mutejester.github.io/GenAIRR/concept-live-call.html) -- **Guides** — [Build a config](https://mutejester.github.io/GenAIRR/guide-build-config.html) · [Productive sampling](https://mutejester.github.io/GenAIRR/guide-productive.html) · [Clonal families](https://mutejester.github.io/GenAIRR/guide-clonal-families.html) · [Export](https://mutejester.github.io/GenAIRR/guide-export.html) · [Replay](https://mutejester.github.io/GenAIRR/guide-replay.html) · [Reproduce a dataset](https://mutejester.github.io/GenAIRR/guide-reproduce.html) · [Compare SHM models](https://mutejester.github.io/GenAIRR/guide-compare-shm.html) · [Tune corruption](https://mutejester.github.io/GenAIRR/guide-tune-corruption.html) +- **Guides** — [Build a config](https://mutejester.github.io/GenAIRR/guide-build-config.html) · [Productive sampling](https://mutejester.github.io/GenAIRR/guide-productive.html) · [Clonal lineage trees](https://mutejester.github.io/GenAIRR/guides/clonal-lineage.html) · [Clonal repertoires](https://mutejester.github.io/GenAIRR/guides/clonal-repertoire.html) · [Export](https://mutejester.github.io/GenAIRR/guide-export.html) · [Replay](https://mutejester.github.io/GenAIRR/guide-replay.html) · [Reproduce a dataset](https://mutejester.github.io/GenAIRR/guide-reproduce.html) · [Compare SHM models](https://mutejester.github.io/GenAIRR/guide-compare-shm.html) · [Tune corruption](https://mutejester.github.io/GenAIRR/guide-tune-corruption.html) - **Reference** — [API + Configs + AIRR fields](https://mutejester.github.io/GenAIRR/reference.html) --- diff --git a/tests/test_docs_website_contract.py b/tests/test_docs_website_contract.py index 1078e0b..f6c9712 100644 --- a/tests/test_docs_website_contract.py +++ b/tests/test_docs_website_contract.py @@ -42,6 +42,7 @@ _AUDIT_DOC = _REPO_ROOT / "audit-docs" / "docs_website_audit.md" _README = _REPO_ROOT / "README.md" _WEBSITE = _REPO_ROOT / "website" +_SITE_DOCS = _REPO_ROOT / "site_docs" _DOCS = _REPO_ROOT / "docs" _OLD_DOCS = _REPO_ROOT / "_old_docs" _DEPLOY_WORKFLOW = _REPO_ROOT / ".github" / "workflows" / "deploy-docs.yml" @@ -302,9 +303,16 @@ def test_pin_present_readme_hosted_doc_urls_resolve_to_live_website_pages() -> N continue # Strip query string / fragment if present. path = path.split("?", 1)[0].split("#", 1)[0] - target = _WEBSITE / path - if not target.is_file(): - missing.append((url, str(target))) + # The live site is the MkDocs build of `site_docs/` (deploy-docs.yml). + # A hosted `

.html` URL resolves if EITHER it exists as a flat page + # in the preserved `website/` rollback tree, OR it maps to a MkDocs + # source page `site_docs/

.md` (use_directory_urls: false ⇒ + # `foo/bar.md` → `foo/bar.html`). + resolves = (_WEBSITE / path).is_file() + if not resolves and path.endswith(".html"): + resolves = (_SITE_DOCS / (path[:-len(".html")] + ".md")).is_file() + if not resolves: + missing.append((url, str(_WEBSITE / path))) assert not missing, ( "README hosted-doc URLs do not resolve to live website " "pages:\n" From 28fcd44f449763bd36af7f33819a81636bbfeb23 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 13:09:46 +0300 Subject: [PATCH 58/59] docs+test: mkdocs internal-link/claim polish; clonal-fork ordering-guard tests for lineage + repertoire --- mkdocs.yml | 2 +- site_docs/concepts/airr-record.md | 57 +-- site_docs/demo.md | 8 +- site_docs/guides/biology-map.md | 47 +- site_docs/guides/clonal-families.md | 486 ++++++++++----------- site_docs/guides/clonal-lineage.md | 25 +- site_docs/guides/clonal-repertoire.md | 16 +- site_docs/guides/corruption-sequencing.md | 34 +- site_docs/guides/experiment-builder.md | 69 +-- site_docs/guides/index.md | 10 +- site_docs/guides/paired-end-fastq.md | 46 +- site_docs/guides/recombination-editing.md | 27 +- site_docs/guides/recombination-junction.md | 9 +- site_docs/learn.md | 14 +- site_docs/reference/experiment.md | 12 +- site_docs/reference/index.md | 8 +- site_docs/reference/simulation-result.md | 11 +- site_docs/validation/index.md | 47 +- site_docs/validation/validate-records.md | 35 +- src/GenAIRR/experiment.py | 67 +-- tests/test_clonal_dsl_ordering_guards.py | 12 +- tests/test_clonal_repertoire.py | 22 + 22 files changed, 570 insertions(+), 494 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 5829892..d864b06 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -204,7 +204,7 @@ nav: - Biology map: guides/biology-map.md - Recombination + junction biology: guides/recombination-junction.md - D inversion + receptor revision: guides/recombination-editing.md - - Clonal families: guides/clonal-families.md + - Clonal simulation overview: guides/clonal-families.md - Clonal lineage trees: guides/clonal-lineage.md - Clonal repertoires (TCR & abundance): guides/clonal-repertoire.md - Junction N/P additions: guides/junction-additions.md diff --git a/site_docs/concepts/airr-record.md b/site_docs/concepts/airr-record.md index 87eb982..0960592 100644 --- a/site_docs/concepts/airr-record.md +++ b/site_docs/concepts/airr-record.md @@ -59,7 +59,7 @@ The canonical groups, in the order they appear on the record: 11. D-inversion provenance 12. Receptor-revision provenance 13. Read layout (paired-end) -14. Clonal stamping (when expand_clones runs) +14. Clonal stamping (when a clonal workflow runs) 15. Truth columns (when `expose_provenance=True`) The remaining sections walk through each. @@ -127,7 +127,7 @@ load-bearing in two cases: - **Family validation** uses them to confirm every descendant of a clonal family shares the recombination-time identity (see - [Clonal families](../guides/clonal-families.md)). + [Clonal simulation overview](../guides/clonal-families.md)). - **Aligner benchmarking** treats `v_call` as the aligner's prediction and `truth_*_call` as the ground truth, even when both came from the same simulator — the column split makes the @@ -343,20 +343,25 @@ for the quality model and the layout coordinates. ## Clonal fields -Two fields stamped Python-side when `.expand_clones(n_clones=..., -per_clone=...)` runs: - -| Field | Type | Meaning | -|---|---|---| -| `clone_id` | int | Identifier of the clonal family this record belongs to (0-based) | -| `parent_id` | int | Identifier of the ancestor outcome; equals `clone_id` for the records expanded from that ancestor | - -On non-clonal runs (no `expand_clones`), both fields are -**absent** from the record dict. Don't write code that assumes -they're always present; the family validators (`validate_families`, -`validate_families_with_parents`) are safe no-ops on non-clonal -batches because they handle the absent case explicitly. See -[Clonal families](../guides/clonal-families.md). +All modern clonal workflows stamp `clone_id`, but the rest of the +surface depends on which clonal model produced the record: + +| Field | Type | Produced by | Meaning | +|---|---|---|---| +| `clone_id` | int | `clonal_lineage`, `clonal_repertoire`, `expand_clones` | Planted clone / family label (0-based) | +| `duplicate_count` | int | `clonal_lineage`, `clonal_repertoire` | AIRR-standard abundance after genotype collapse | +| `parent_id` | int | legacy `expand_clones` | Identifier of the ancestor `Outcome`; equals `clone_id` for records expanded from that ancestor | +| `lineage_node_id` | int | `clonal_lineage` | Node id of the observed cell in the ground-truth lineage tree | +| `lineage_parent_id` | int | `clonal_lineage` | Parent node id in the lineage tree (`-1` for founder) | +| `lineage_generation` | int | `clonal_lineage` | Generation depth of the observed cell | +| `lineage_abundance` | int | `clonal_lineage` | Observation count after final-cell sampling and genotype collapse | +| `lineage_affinity` | float | `clonal_lineage` | Sequence-distance proxy to the target; 0 only when no affinity model is active | + +On non-clonal runs, these fields are **absent** from the record dict. Don't write +code that assumes they're always present; check with `"clone_id" in rec`. +`validate_families()` is a safe no-op on non-clonal batches because it handles +the absent case explicitly. See +[Clonal simulation overview](../guides/clonal-families.md). ## Validation @@ -369,10 +374,9 @@ The records page composes cleanly with the validation surface: - **`validate_families(refdata=None)`** groups records by `clone_id` and asserts each family agrees on `truth_v_call`, `truth_d_call`, `truth_j_call` (when present). -- **`validate_families_with_parents(refdata)`** compares each - descendant against its actual parent `Outcome` — catches a - whole class of family-stamping bugs the cheaper validator - misses. +- **`validate_families_with_parents(refdata)`** is for legacy + `expand_clones` results with `result.parents`; it compares each + descendant against its actual parent `Outcome`. The full validation picture lives at the [Validation hub](../validation/index.md). @@ -423,10 +427,11 @@ appear only when `expose_provenance=True` is passed to `run_records(...)`. Without the flag, the columns are absent entirely — not `None`-valued, absent. -**Expecting `clone_id` on non-clonal records.** Without -`.expand_clones(...)`, neither `clone_id` nor `parent_id` is -stamped on the record dict at all. Check for presence with -`"clone_id" in rec`, not `rec.get("clone_id") is not None`. +**Expecting `clone_id` on non-clonal records.** Without a clonal +workflow (`clonal_lineage`, `clonal_repertoire`, or legacy +`expand_clones`), clonal fields are not stamped on the record dict at +all. Check for presence with `"clone_id" in rec`, not +`rec.get("clone_id") is not None`. ## Where to go next @@ -438,7 +443,7 @@ stamped on the record dict at all. Check for presence with — the SHM counters in depth. - **[Corruption and sequencing artefacts](../guides/corruption-sequencing.md)** — the artefact counters in depth. -- **[Clonal families](../guides/clonal-families.md)** — the - `clone_id` / `parent_id` surface and family validation. +- **[Clonal simulation overview](../guides/clonal-families.md)** — + `clone_id`, `duplicate_count`, lineage metadata, and family validation. - **[Validation hub](../validation/index.md)** — re-deriving every field from the underlying `Outcome`. diff --git a/site_docs/demo.md b/site_docs/demo.md index 57fc53f..33810ef 100644 --- a/site_docs/demo.md +++ b/site_docs/demo.md @@ -287,10 +287,14 @@ ValueError: replay_from_trace_file: pass plan signature mismatch. --- -## 4. Clonal families +## 4. Legacy fixed-size clonal families One parent recombination can fork into many independently mutated -descendants. +descendants. This demo uses legacy `expand_clones` for the simple +fixed-size star shape; for new clone benchmarks, see +[`clonal_lineage`](guides/clonal-lineage.md) for BCR trees and +[`clonal_repertoire`](guides/clonal-repertoire.md) for TCR / abundance +repertoires.

Code
diff --git a/site_docs/guides/biology-map.md b/site_docs/guides/biology-map.md index 372a0bb..6d4b703 100644 --- a/site_docs/guides/biology-map.md +++ b/site_docs/guides/biology-map.md @@ -37,7 +37,9 @@ better starting point. | **End-loss (5′ / 3′)** | `.end_loss_5prime(length=...)`, `.end_loss_3prime(length=...)` (or `primer_trim_*prime` aliases) | Library / sequencing artefact — descendant | `end_loss_5_length`, `end_loss_3_length` | [Corruption + sequencing artefacts](corruption-sequencing.md) | | **Random strand orientation** | `.random_strand_orientation(prob=0.5)` | Read layout — descendant | `rev_comp` | [Corruption + sequencing artefacts](corruption-sequencing.md) | | **Paired-end layout** | `.paired_end(r1_length=..., insert_size=...)` | Read layout — descendant | `read_layout`, `r1_sequence`, `r2_sequence`, `r1_start`, `r1_end`, `r2_start`, `r2_end`, `insert_size` | [Paired-end reads and FASTQ](paired-end-fastq.md) | -| **Clonal expansion** | `.expand_clones(n_clones=..., per_clone=...)` | Ancestor / descendant fork | `clone_id`, `parent_id` (stamped Python-side) | [Clonal families](clonal-families.md) | +| **BCR lineage trees** | `.clonal_lineage(...)` | BCR affinity-maturation tree | `clone_id`, `lineage_*`, `duplicate_count`, `result.lineage_trees` | [Clonal lineage trees](clonal-lineage.md) | +| **TCR / flat-BCR clone-size repertoires** | `.clonal_repertoire(...)` | Non-tree clonal abundance | `clone_id`, `duplicate_count` | [Clonal repertoires](clonal-repertoire.md) | +| **Legacy fixed-size clonal stars** | `.expand_clones(n_clones=..., per_clone=...)` | Ancestor / descendant fork | `clone_id`, `parent_id`, `result.parents` | [Clonal simulation overview](clonal-families.md) | | **Contamination** | `.contaminate(prob=...)` | Library / sequencing artefact — descendant | `is_contaminant` | [Experiment builder](experiment-builder.md) | | **Sample metadata** | `.with_metadata(**fields)` | Bookkeeping — post-run | Arbitrary user-stamped columns | [Experiment builder](experiment-builder.md) | @@ -56,24 +58,32 @@ insertion. Productivity constraints (`productive_only`, `.receptor_revision()` edit the just-recombined molecule. Each can fire at most once per record. -**3. Ancestor / descendant fork (clonal pipelines only).** -`.expand_clones()` partitions the pipeline. Everything before -the fork runs once per ancestor; everything after fires per -descendant. +**3. Clonal structure (optional).** Choose one clonal surface: +`clonal_lineage()` for BCR trees, `clonal_repertoire()` for TCR / +flat-BCR clone-size repertoires, or legacy `expand_clones()` for a +fixed-size star. For flat forks, everything before the fork runs once +per clone and everything after fires per emitted copy. For +`clonal_lineage`, the tree growth and SHM happen inside the lineage +engine. **4. Biology — descendant phase.** `.mutate(...)` accumulates -biological SHM on top of recombination. On clonal pipelines -this fires *after* `expand_clones`; SHM is per-descendant, not -shared across the family. +biological SHM on top of recombination. On flat clonal pipelines +(`clonal_repertoire` / `expand_clones`) this fires after the fork so +SHM is per copy, not shared across the clone. TCR refdata rejects +`.mutate(...)`. `clonal_lineage` has its own tree-internal SHM rate. **5. Library / sequencing artefacts + read layout — descendant phase.** All corruption passes (`pcr_amplify`, `sequencing_errors`, `ambiguous_base_calls`, `polymerase_indels`, `end_loss_5prime`, `end_loss_3prime`, `random_strand_orientation`, `contaminate`) plus the read-layout projection -(`paired_end`). On clonal pipelines these must come after -`.expand_clones()`; calling any of them *before* the fork raises -`ValueError`. +(`paired_end`). On flat clonal pipelines these must come after the +fork; calling any of them before `clonal_repertoire()` or +`expand_clones()` raises `ValueError`. `clonal_lineage` accepts the +corruption passes after the fork but not `paired_end` yet. With +`clonal_repertoire`, paired-end records remain abundance-collapsed: +`duplicate_count` carries copy number and FASTQ export does not expand +it into repeated read pairs. **Per-batch bookkeeping** (`.with_metadata(...)`) stamps the result after every other stage has run. @@ -84,8 +94,8 @@ The two main ordering invariants: mutation; the corruption passes model the wet lab. Reversing the order would model SHM mutating an already-corrupted sequence, which doesn't match reality. -- **All descendant-phase passes follow `expand_clones`.** That's - what makes them per-descendant. Putting them earlier would +- **All descendant/read-phase passes follow flat clonal forks.** + That's what makes them per emitted copy. Putting them earlier would share their effects across the whole family. ## Cartridge-controlled vs Experiment-controlled @@ -135,7 +145,8 @@ The `Experiment` DSL carries the experimental design: - **Targeting overrides** — `segment_rates`, `v_subregion_rates` on `mutate(...)`; per-experiment `trim(v_3=..., d_5=..., ...)` distributions that override the cartridge defaults. -- **Clonal structure** — `expand_clones(n_clones, per_clone)`. +- **Clonal structure** — `clonal_lineage(...)`, + `clonal_repertoire(...)`, or legacy `expand_clones(...)`. - **Read layout** — `paired_end(...)`, `random_strand_orientation(...)`. - **Run-time flags** — `strict`, `expose_provenance`, @@ -170,10 +181,10 @@ priors. - **Paired-end geometry** — when `read_layout == "paired_end"`, R1/R2 coordinates checked against `insert_size`; reads are consistent with their parent assembled sequence. -- **Family invariants** — `validate_families` and - `validate_families_with_parents` assert each `clone_id` group - agrees on `truth_v_call` / `truth_d_call` / `truth_j_call`. - The parent-aware form additionally compares descendants +- **Family invariants** — `validate_families` groups by `clone_id` + and, when truth columns are present, asserts each group agrees on + `truth_v_call` / `truth_d_call` / `truth_j_call`. The parent-aware + form additionally compares legacy `expand_clones` descendants against their actual parent `Outcome`. **NOT validated:** diff --git a/site_docs/guides/clonal-families.md b/site_docs/guides/clonal-families.md index ba6847a..d1c4693 100644 --- a/site_docs/guides/clonal-families.md +++ b/site_docs/guides/clonal-families.md @@ -1,314 +1,298 @@ -# Generate clonal families - -

A clonal family is one parent recombination plus -many descendants that share the parent's V(D)J truth but diverge -through somatic hypermutation, library prep, and sequencing -artefacts. expand_clones is the DSL marker that turns -a flat pipeline into a clonal one — and once it's in the chain, -the API rejects misordered steps so you can't accidentally collapse -SHM diversity or split a recombination decision across descendants.

- -## What clonal simulation means - -In real biology, a clonal family is the lineage descended from a -single B cell whose V(D)J recombination has happened. Every cell -in that lineage shares the same V/D/J allele choices, the same -trims, the same NP bases, the same D orientation — those are -*recombination-time* decisions, frozen at fork. What diverges -between cousins is somatic hypermutation (each B cell mutates -independently), library prep artefacts (PCR errors don't cross -between reads), and sequencing geometry (R1/R2 windows are per-read). - -GenAIRR models that with a single DSL marker, `expand_clones`. The -methods you append BEFORE it run *once per clone* (and that -outcome is shared across every descendant). The methods you -append AFTER run *once per descendant* (independent draws per -read). - -## A minimal clonal example +# Clonal simulation overview -```python -import GenAIRR as ga +

GenAIRR has three clonal surfaces. Use +clonal_lineage when you need B-cell affinity-maturation trees, +clonal_repertoire when you need TCR or flat-BCR clone-size / +abundance repertoires, and legacy expand_clones only when you +need the older fixed-size star model. All three stamp planted clone labels so +AIRR clone-calling and ML benchmarks can compare inferred groups against the +truth the simulator created.

-result = ( - ga.Experiment.on("human_igh") - .recombine() # ancestor phase - .expand_clones(n_clones=5, per_clone=10) # fork - .mutate(model="s5f", rate=0.02) # descendant phase - .run_records(seed=1) -) +## Choose the right clonal model -print(len(result)) # 50 = 5 clones × 10 descendants -print(result[0]["clone_id"], result[0]["parent_id"]) # 0 0 -print(result[10]["clone_id"]) # 1 -``` +| Use case | DSL | Biology | Output truth | +|---|---|---|---| +| **BCR lineage reconstruction / affinity maturation** | [`clonal_lineage(...)`](clonal-lineage.md) | Generation-synchronous B-cell tree, per-division S5F SHM, optional sequence-distance selection, final live-cell sampling | AIRR records with `clone_id`, `lineage_*`, `duplicate_count`; one `LineageTree` per clone | +| **TCR clone-size / abundance benchmark** | [`clonal_repertoire(...)`](clonal-repertoire.md) | One rearranged T cell copied to a heavy-tailed clone size; no SHM; optional per-read technical noise | AIRR records with `clone_id` and AIRR `duplicate_count` | +| **Flat BCR abundance without genealogy** | [`clonal_repertoire(...)`](clonal-repertoire.md) | One BCR rearrangement copied to a heavy-tailed size; optional flat post-fork SHM per copy | AIRR records with `clone_id` and `duplicate_count` | +| **Legacy fixed-size star** | `expand_clones(...)` | One parent rearrangement and a fixed `per_clone` descendant count | AIRR records with `clone_id`, `parent_id`; parent `Outcome`s on `result.parents` | -`expand_clones(n_clones=5, per_clone=10)` produces exactly 5 × 10 = -50 records. Note that `run_records(seed=1)` doesn't take an `n` -argument — the clonal pipeline computes its own total. You can -pass `n=50` as a consistency check, but any other value (including -`n=100`) raises `ValueError` at run time. The pipeline knows how -many records it produces; you don't override it. +`expand_clones` is still supported for old scripts, but new clone-related +benchmarks should usually start with `clonal_lineage` or `clonal_repertoire`. +Those two encode the distinction AIRR users usually care about: BCR lineages are +mutation trees, while TCR clones are abundance groups with technical read noise. -## Ancestor vs descendant phase +## Shared DSL shape -The DSL partitions steps into two phases: +Clonal workflows all start by creating one founder rearrangement per clone: -**Ancestor-phase** — runs once per clonal parent. Use for any -mechanism whose decision must be inherited by every descendant -of a family. +```python +ga.Experiment.on("human_igh").recombine() +``` -| Pass | Why ancestor | -|---|---| -| `.recombine()` | V/D/J allele choices + trims + NP define the family's identity | -| `.invert_d(prob=...)` | D orientation is a recombination-time event; the family shares it | -| `.receptor_revision(prob=...)` | V replacement happens once during B-cell development; the family shares the post-revision V | +Everything before the clonal fork runs once per clone. Everything after a flat +fork (`clonal_repertoire` or `expand_clones`) runs once per emitted read/copy. +`clonal_lineage` is different: it grows the SHM tree internally, then optional +library-prep / sequencing artefact passes run once per observed cell. -**Descendant-phase** — runs independently per descendant. Use for -mechanisms that should vary within a family. +Only one clonal fork is allowed in a pipeline: -| Pass | Why descendant | -|---|---| -| `.mutate(...)` | Each memory B cell mutates independently | -| `.pcr_amplify(...)` | PCR errors are read-specific | -| `.polymerase_indels(...)` | Same — per-read library artefacts | -| `.ambiguous_base_calls(...)` | Per-read N-injection | -| `.sequencing_errors(...)` | Per-read quality artefacts | -| `.end_loss_5prime(...)`, `.end_loss_3prime(...)` | Per-read 5'/3' adapter loss | -| `.random_strand_orientation(...)` | Per-read strand decision | -| `.paired_end(...)` | R1/R2 windows are per-read | - -The DSL enforces ordering at chain time — not at compile time, not -silently at run time. If you call a descendant-phase method -*before* `expand_clones`, the next call to `expand_clones` raises: - -```text -ValueError: mutate must be called after expand_clones(); -SHM is descendant-specific in GenAIRR's current clonal model. -Move mutate(...) after expand_clones(...). +```python +.clonal_lineage(...) +.clonal_repertoire(...) +.expand_clones(...) ``` -If you call an ancestor-phase method (`invert_d` / `receptor_revision`) -*after* `expand_clones`, the offending method itself raises with -the symmetric message: +are mutually exclusive. -```text -ValueError: invert_d must be called before expand_clones(); -D inversion is a recombination-time decision and must be inherited -by all clone descendants. Move the invert_d(...) call before -expand_clones(...). -``` +`run_records(n=...)` is not the way to set clone output size for modern clonal +models. Use the clonal parameters instead: -A second call to `expand_clones` raises `expand_clones() can only -be called once per pipeline`. - -In practice the rule of thumb is: anything biology fixed once per -cell goes before the fork, anything happening per read goes after. -The DSL catches violations the moment you write them. +| Model | Output-size knobs | +|---|---| +| `clonal_lineage` | `n_clones`, `max_generations`, `n_max`, `n_sample`, extinction/survival, genotype collapse | +| `clonal_repertoire` | `n_clones`, `size_distribution`, `max_size`, `unexpanded_fraction`, genotype collapse | +| `expand_clones` | `n_clones × per_clone` fixed descendants | -## Reading clone IDs and parents +## BCR lineage trees -Every descendant record carries two integer fields that wire it -back to its clonal family: +Use `clonal_lineage` when you need a real B-cell genealogy: ```python -rec = result[0] - -rec["clone_id"] # 0 — family identity; every descendant of clone 0 carries 0 -rec["parent_id"] # 0 — addressing index into result.parents -``` +import GenAIRR as ga -**Today `clone_id == parent_id` by construction** — the two are -stamped separately because they carry distinct semantics -(`clone_id` is the family identity, `parent_id` is the lookup -index into the parents collection), so a future change to the -addressing scheme can move one without retrofitting the other. -Treat them as a pair; address downstream joins by `clone_id`. +result = ( + ga.Experiment.on("human_igh") + .recombine() + .clonal_lineage( + n_clones=20, + max_generations=6, + n_max=300, + n_sample=30, + rate=0.01, + lambda_base=1.6, + selection_strength=10.0, + ) + .sequencing_errors(rate=0.005) + .run_records(seed=0, validate_records=True) +) -The parent outcomes live separately on the result: +rec = result.records[0] +print(rec["clone_id"], rec["lineage_node_id"], rec["lineage_generation"]) +print(rec["lineage_abundance"], rec["duplicate_count"]) -```python -result.parents # list of length n_clones -len(result.parents) # 5 - -parent_outcome = result.parents[rec["parent_id"]] -# Outcome carrying the pre-fork plan's full trace + event ledger -parent_outcome.final_simulation() # post-recombine IR -parent_outcome.trace() # pre-fork addressed-choice trace -parent_outcome.events() # pre-fork event ledger +tree = result.lineage_trees[rec["clone_id"]] +newick = tree.to_newick() +node_table = tree.to_node_table_tsv() ``` -The flat `result.outcomes` list continues to hold exactly one -entry per descendant record (length `n_clones × per_clone`); -parents are exposed on the separate `.parents` collection so -clonal consumers get extra information without changing the -descendant-list shape. +What happens: -!!! info "Non-clonal results" - `result.parents` is `None` when `expand_clones` is NOT in the - pipeline. Record dicts from non-clonal runs also have - `record.get("clone_id") is None` and `record.get("parent_id") - is None`. Code that may receive either shape can safely use - `result.parents or []` and `record.get("clone_id")` without - branching on clonal-ness. +1. `recombine()` creates one naive BCR founder per clone. +2. The Rust lineage engine grows a tree for `max_generations`, with live-cell + carrying capacity `n_max`. +3. Each child receives per-division S5F SHM at `rate`. +4. If selection is enabled, offspring rates are modulated by a BLOSUM62 + sequence-distance proxy, not a physical antigen-binding model. +5. `n_sample` cells are sampled from the living final-generation population and + identical genotypes are collapsed into `lineage_abundance` / + `duplicate_count`. -## Validation +`clonal_lineage` is BCR-only. Calling it on TCR refdata raises `ValueError` +because T cells do not somatically hypermutate. -The validation hub covers the layers in depth; here's the clonal -slice. Three calls compose for the strongest gate: +Deep dive: [Clonal lineage trees](clonal-lineage.md). + +## TCR and flat-BCR abundance repertoires + +Use `clonal_repertoire` when the clone truth is membership and abundance, not a +lineage tree: ```python -# 1. Per-record AIRR consistency. -record_report = result.validate_records(refdata) -assert record_report, record_report.summary() +import GenAIRR as ga -# 2. Within-family invariants (no refdata required). -family_report = result.validate_families() -assert family_report, family_report.summary() +result = ( + ga.Experiment.on("human_tcrb") + .allow_curatable_refdata() + .recombine() + .clonal_repertoire( + n_clones=200, + size_distribution="power_law", + exponent=2.0, + max_size=500, + unexpanded_fraction=0.5, + ) + .sequencing_errors(rate=0.005) + .run_records(seed=0, validate_records=True) +) -# 3. Parent-aware checks: descendant truth fields agree with their parent. -full_report = result.validate_families_with_parents(refdata) -assert full_report, full_report.summary() +for rec in result.records[:5]: + print(rec["clone_id"], rec["duplicate_count"], rec["v_call"], rec["j_call"]) ``` -What each adds: - -- **`validate_families()`** groups records by `clone_id` and asserts - the recombination-time truth fields (`truth_v_call`, `truth_d_call`, - `truth_j_call`) are invariant across every descendant of a clone. - No refdata required; works on records-only results (e.g. - round-tripped from TSV). On a non-clonal batch (no record carries - a non-null `clone_id`) it's a safe no-op and returns ok with - `family_count == 0` — you don't need to branch on whether the - pipeline was clonal. -- **`validate_families_with_parents(refdata)`** adds structural - checks (`ParentsMissing` / `ParentIdMissing` / `ParentIdOutOfRange`) - and value-comparison checks against the actual parent `Outcome` - on `result.parents` (`ParentDInvertedMismatch`, - `ParentOriginalVCallMismatch`, `ParentTruthVCallMismatch`, - etc.). It does NOT re-run the within-family checks - `validate_families` does; it's a sibling validator, not a - superset. - -The runtime opt-in `run_records(..., validate_records=True)` runs -the per-record validator *and* the field-level family checks — but -NOT the parent-aware checks. If you want -`validate_families_with_parents` in CI, call it explicitly: +What happens: -```python -result = exp.run_records(seed=42, validate_records=True) -assert result.validate_families_with_parents(refdata), "parent mismatch" -``` +1. `recombine()` creates one rearrangement per clone. +2. A clone size is drawn from a heavy-tailed distribution: rounded continuous + power-law by default, or log-normal. +3. That many copies pass through post-fork per-read passes such as + `sequencing_errors`, `pcr_amplify`, `polymerase_indels`, `end_loss_*`, + `ambiguous_base_calls`, `random_strand_orientation`, or `paired_end`. +4. Identical output sequences collapse into one AIRR record whose + `duplicate_count` is the represented abundance. -## Common workflows +For TCR, do not add `.mutate(...)`; the API rejects it. TCR within-clone sequence +diversity should come from technical artefact passes only. For flat BCR +abundance, you may add post-fork `.mutate(...)`, but that is independent SHM off +the founder, not a tree. -A few patterns that come up repeatedly with clonal output. +Deep dive: [Clonal repertoires](clonal-repertoire.md). -**Clone-level SHM benchmark.** Hold the parent V(D)J fixed and -let SHM diverge: +## Legacy fixed-size stars + +`expand_clones` remains available for old fixed-size star benchmarks: ```python result = ( ga.Experiment.on("human_igh") .recombine() - .expand_clones(n_clones=100, per_clone=50) - .mutate(model="s5f", rate=0.05) - .run_records(seed=42) + .expand_clones(n_clones=5, per_clone=10) + .mutate(model="s5f", rate=0.02) + .run_records(seed=1) ) -# Per-clone SHM distribution: -import pandas as pd -df = pd.DataFrame(result.records) -df.groupby("clone_id")["n_mutations"].describe() +print(len(result.records)) # 50 +print(result.records[0]["clone_id"]) # 0 +print(result.records[0]["parent_id"]) # 0 +parent = result.parents[result.records[0]["parent_id"]] ``` -**Parent / descendant comparison.** Pull the parent IR for each -descendant and compare against the post-SHM sequence: +`expand_clones` records carry `parent_id` and `result.parents` because the old +star model keeps an explicit parent `Outcome`. Modern `clonal_repertoire` does +not expose `parents`; it collapses abundance into records. `clonal_lineage` +exposes lineage truth through `lineage_*` fields and `result.lineage_trees` +instead. + +## Output fields by model + +| Field / object | `clonal_lineage` | `clonal_repertoire` | `expand_clones` | +|---|---|---|---| +| `clone_id` | Yes: planted family label | Yes: planted clone label | Yes: planted family label | +| `duplicate_count` | Yes: alias of `lineage_abundance` after final-cell sampling | Yes: collapsed abundance | No standard abundance field in the legacy star model | +| `lineage_node_id` / `lineage_parent_id` / `lineage_generation` | Yes | No | No | +| `lineage_abundance` / `lineage_affinity` | Yes | No | No | +| `parent_id` | No; use `lineage_parent_id` for tree parent | No | Yes | +| `result.parents` | No | No | Yes | +| `result.lineage_trees` | Yes | No | No | +| `result.outcomes` | One per observed record | One per emitted/collapsed record | One per descendant record | + +For external clone-calling tools, keep the planted label under a non-AIRR name so +it does not collide with the tool's inferred `clone_id`: ```python -for rec in result.records: - parent = result.parents[rec["parent_id"]] - parent_seq = parent.final_simulation().bases() - # rec["sequence"] is the post-SHM descendant; parent_seq is - # the pre-fork assembled IR. +import pandas as pd + +df = pd.DataFrame(result.records).rename(columns={"clone_id": "true_clone_id"}) +df.to_csv("repertoire.tsv", sep="\t", index=False) ``` -**Export records with clone IDs.** Every export format carries the -`clone_id` and `parent_id` columns as-is — they ship with the -record dict: +`duplicate_count` is the AIRR-standard abundance column consumed by +abundance-aware workflows. `clonal_repertoire` and `clonal_lineage` emit it +directly. + +## Validation + +Use record validation on every clonal workflow: ```python -result.to_tsv("repertoire.tsv") # clone_id + parent_id columns included -df = result.to_dataframe() # same in the DataFrame -result.to_fasta("seqs.fa", prefix="seq") # headers include sequence_id; clone IDs in TSV +result = exp.run_records(seed=42, validate_records=True) ``` -**Paired FASTQ from clonal output.** `paired_end` is descendant- -phase, so it composes with `expand_clones` naturally: +or explicitly: ```python -result = ( - ga.Experiment.on("human_igh") - .recombine() - .expand_clones(n_clones=50, per_clone=20) - .mutate(model="s5f", rate=0.05) - .paired_end(r1_length=150, insert_size=300) - .run_records(seed=1) -) +record_report = result.validate_records(refdata) +assert record_report, record_report.summary() +``` + +Family validation is records-only and works across all clonal models that carry +`clone_id`: -result.to_paired_fastq("reads_R1.fastq", "reads_R2.fastq") -# 1,000 R1 records + 1,000 R2 records, sequence_ids match +```python +family_report = result.validate_families() +assert family_report, family_report.summary() ``` +Currently `validate_families()` checks that every record in a clonal batch has a +`clone_id` and, when `truth_v_call` / `truth_d_call` / `truth_j_call` are present +from `expose_provenance=True`, that those truth calls are invariant within each +clone. It does not validate lineage topology, clone-size priors, or biological +realism. + +`validate_families_with_parents(refdata)` is specific to legacy +`expand_clones`, because it compares descendant records against +`result.parents`. For `clonal_lineage`, validate the tree objects directly with +`tree.validate()` and use the exported Newick/FASTA/node table for lineage-tool +scoring. + +## Ordering rules + +Ancestor-phase steps go before the clonal fork: + +| Step | Why | +|---|---| +| `.recombine()` | Defines the clone's V/D/J, trims, NP sequence, and junction | +| `.invert_d(...)` | Recombination-time D orientation; inherited by the clone | +| `.receptor_revision(...)` | Recombination/development-time V replacement; inherited by the clone | +| `.productive_only()` / `.restrict_alleles(...)` | Constraints on the founder draw | + +Descendant/read-phase steps go after a flat fork (`clonal_repertoire` or +`expand_clones`): + +| Step | Notes | +|---|---| +| `.mutate(...)` | BCR-only flat SHM; not allowed on TCR | +| `.pcr_amplify(...)`, `.sequencing_errors(...)`, `.polymerase_indels(...)` | Per-read technical artefacts | +| `.ambiguous_base_calls(...)`, `.end_loss_*prime(...)`, `.random_strand_orientation(...)` | Per-read observation artefacts | +| `.paired_end(...)` | Supported after legacy `expand_clones`; accepted after `clonal_repertoire` with abundance-collapse caveats; not yet supported after `clonal_lineage` | + +For `clonal_lineage`, do not add `.mutate(...)` afterward: SHM is internal to the +tree and controlled by `clonal_lineage(rate=...)`. Library-prep and sequencing +artefact passes may follow; `paired_end` is still a future addition for lineage +output. + +When `paired_end` follows `clonal_repertoire`, records are still collapsed by +assembled `sequence` and carry `duplicate_count`. TSV/DataFrame output preserves +that abundance. FASTQ exporters do not expand `duplicate_count` back into multiple +read pairs, so use this path for paired fields on collapsed records, not for exact +per-copy paired FASTQ depth. + ## Common mistakes -A handful of issues that show up with clonal pipelines. - -**Calling `invert_d()` after `expand_clones()`.** D orientation is -a recombination-time decision; it must be inherited by every -descendant. `invert_d` rejects this immediately at chain time with -"D inversion is a recombination-time decision and must be -inherited by all clone descendants." Move it before -`expand_clones`. Same goes for `receptor_revision`. - -**Putting `.paired_end()` before `expand_clones()`.** R1/R2 windows -are per-read; placing `paired_end` in the ancestor phase would -share one R1/R2 window across the whole family. `expand_clones` -scans the prior steps when called and rejects descendant-phase -methods that appear before it: "paired_end must be called after -expand_clones(); it is descendant-specific and must be sampled -independently for each clone member." - -**Expecting child traces to include the full parent trace.** A -descendant `Outcome.trace()` carries only the post-fork plan's -addressed choices — SHM substitutions, PCR errors, indels, etc. -The pre-fork plan's trace (recombination choices, NP bases, D -inversion) lives on `result.parents[i].trace()` instead. They're -two separate addressing namespaces because the pre-fork plan ran -once per parent and the post-fork plan ran once per descendant. - -**Expecting mutation-distance aggregation fields today.** The -clonal validator's per-clone mutation-distance distribution and -pre-SHM junction invariance checks are deliberately deferred -(future-slice scope). Today's `validate_families` checks the -recombination-time truth fields are invariant within a clone; it -does NOT verify that descendant SHM counts cluster around a -biologically plausible distribution. If you need that, compute it -yourself from `df.groupby("clone_id")["n_mutations"]`. +**Using `clonal_lineage` for TCR.** TCR clones do not SHM. Use +`clonal_repertoire` for TCR clone-size and abundance benchmarks. + +**Expecting exact discrete Zipf from `clonal_repertoire`.** The default +`power_law` sampler is a rounded continuous inverse-CDF draw. It is heavy-tailed +and Zipf-like, but not an exact discrete Zipf PMF. + +**Expecting `parent_id` on every clonal model.** `parent_id` belongs to legacy +`expand_clones`. Use `lineage_parent_id` for BCR lineage-tree parentage, and use +`clone_id` + `duplicate_count` for `clonal_repertoire`. + +**Using `n=` with modern clonal models.** `clonal_lineage` and +`clonal_repertoire` compute record counts from their own parameters and genotype +collapse. Passing `n` to `run_records` raises. ## Where to go next -- **[Validation & reproducibility](../validation/index.md)** — the - hub explaining the three validation layers and how the runtime - opt-in composes with `validate_families`. -- **[SHM and mutation targeting](shm-targeting.md)** — what runs - per descendant in the SHM pass. -- **[Export the results](../getting-started/export-results.md)** — - TSV / DataFrame / paired FASTQ formats; `clone_id` ships on - every record. -- **[The Experiment builder](experiment-builder.md)** — the full - control-panel page including the canonical ancestor-phase / - descendant-phase rule. -- For the engine-side mechanics of the plan split and the - pre-fork / post-fork compile path, see the contributor audit - [`docs/clonal_plan_split_design.md`](https://github.com/MuteJester/GenAIRR/blob/master/docs/clonal_plan_split_design.md). +- **[Clonal lineage trees](clonal-lineage.md)** — full BCR lineage model, + affinity-selection proxy, tree exporters, and Change-O validation example. +- **[Clonal repertoires](clonal-repertoire.md)** — TCR and flat-BCR abundance + model, clone-size parameters, `duplicate_count`, and tool export notes. +- **[Validation & reproducibility](../validation/index.md)** — record and family + validation layers. +- **[Corruption + sequencing artefacts](corruption-sequencing.md)** — technical + noise passes that compose with clonal workflows. +- **[Paired-end reads and FASTQ](paired-end-fastq.md)** — paired-end output; + currently available for non-lineage clonal workflows. diff --git a/site_docs/guides/clonal-lineage.md b/site_docs/guides/clonal-lineage.md index 1758ae4..98a802d 100644 --- a/site_docs/guides/clonal-lineage.md +++ b/site_docs/guides/clonal-lineage.md @@ -1,12 +1,11 @@ # Clonal lineage trees (affinity maturation) -

Where expand_clones -produces a star — one founder and many independent descendants — +

Where expand_clones produces a star — one founder and many independent descendants — clonal_lineage grows a real tree: a generation-by-generation birth–death process in which cells divide, somatically hypermutate, are selected for antigen affinity, and are finally sampled. The output is a set of per-cell AIRR records plus the ground-truth lineage tree (topology, -ancestral sequences, abundances) — exactly what B-cell lineage-inference tools +ancestral sequences, abundances) — the kind of object B-cell lineage-inference tools (GCtree, IgPhyML, dowser, Change-O) are built to reconstruct. This page explains precisely how it works under the hood; nothing here is a black box.

@@ -30,7 +29,7 @@ truth for lineage reconstruction. `clonal_lineage` adds the missing biology. > `ValueError`. A TCR "clone" is one rearrangement proliferated to many identical > copies; the meaningful quantity is the **clone-size distribution**, not a mutation > tree. For **TCR and flat clonal repertoires**, use -> [`clonal_repertoire`](clonal-repertoire.html) — it draws a heavy-tailed clone size +> [`clonal_repertoire`](clonal-repertoire.md) — it draws a heavy-tailed clone size > per clone and emits `clone_id` + `duplicate_count` (see > [Clone-size distributions](#clone-size-distributions-tcr-and-repertoire-mix)). @@ -276,7 +275,8 @@ its own seed, and the resulting artefacts are merged back onto the cell's record The founder's recombination provenance (`v_call`, `d_call`, `j_call`, trims, junction) **and** the per-segment SHM counts are preserved; the record additionally reports the artefact counters (`n_quality_errors`, `n_pcr_errors`, `n_indels`, …). -Supported passes are the same per-read library-prep set `expand_clones` allows: +Supported passes are the per-read library-prep / sequencing artefact set also +used by `clonal_repertoire` and legacy `expand_clones`: `sequencing_errors`, `pcr_amplify`, `polymerase_indels`, `end_loss_*`, `ambiguous_base_calls`, `random_strand_orientation`. @@ -294,21 +294,22 @@ columns from the founder assignments, and `result.outcomes` carries the per-reco ## Clone-size distributions (TCR and repertoire mix) -> **For TCR, use [`clonal_repertoire`](clonal-repertoire.html).** `clonal_lineage` +> **For TCR, use [`clonal_repertoire`](clonal-repertoire.md).** `clonal_lineage` > itself is **BCR-only** — it still rejects TCR loci. The heavy-tailed clone-size > model described below is now exposed as a fluent DSL workflow via -> [`clonal_repertoire`](clonal-repertoire.html); that is the TCR (and flat-BCR-abundance) +> [`clonal_repertoire`](clonal-repertoire.md); that is the TCR (and flat-BCR-abundance) > path. This section explains the model; the dedicated guide is the place to drive it. Real repertoires are not uniform: a few clones are huge, most are singletons. -[`clonal_repertoire`](clonal-repertoire.html) draws **clone sizes** from a -heavy-tailed distribution (power-law/Zipf by default, log-normal optional) with a -controllable **unexpanded fraction** (size-1, never-expanded clones). For TCR — +[`clonal_repertoire`](clonal-repertoire.md) draws **clone sizes** from a +heavy-tailed distribution (rounded power-law / Zipf-like by default, log-normal +optional) with a controllable **unexpanded fraction** (size-1, never-expanded +clones). For TCR — which has no SHM — a clone is simply one rearrangement at copy-number `size`, with within-clone variation coming only from the post-fork sequencing/PCR-error passes; identical reads collapse into AIRR records carrying `clone_id` + `duplicate_count`. That mixes large expanded families with a realistic singleton tail. See the -[Clonal repertoires guide](clonal-repertoire.html) for the full workflow. +[Clonal repertoires guide](clonal-repertoire.md) for the full workflow. ## Determinism @@ -430,6 +431,6 @@ affinity-maturation trees rather than a flat star, so the surface differs: What *does* carry over: the same per-read library-prep / sequencing passes (`sequencing_errors`, `pcr_amplify`, …) can follow `clonal_lineage` exactly as they -follow `expand_clones`, applied independently per observed cell (see +follow other clonal workflows, applied independently per observed cell (see [Library-prep & sequencing artefacts](#library-prep-sequencing-artefacts)). And `run_records(..., validate_records=True)` is supported on lineage results too. diff --git a/site_docs/guides/clonal-repertoire.md b/site_docs/guides/clonal-repertoire.md index 864ee8d..a209d4f 100644 --- a/site_docs/guides/clonal-repertoire.md +++ b/site_docs/guides/clonal-repertoire.md @@ -1,7 +1,6 @@ # Clonal repertoires (TCR & abundance) -

Where clonal_lineage -grows BCR affinity-maturation trees, clonal_repertoire builds +

Where clonal_lineage grows BCR affinity-maturation trees, clonal_repertoire builds a non-tree clonal repertoire: each clone is one rearrangement proliferated to a clone size drawn from a heavy-tailed distribution, and those copies are emitted as reads through the library-prep / sequencing passes. Identical @@ -19,8 +18,9 @@ structure. For each of `n_clones` clones it: 1. runs the pre-fork plan (`recombine()`) **once** to fix the clone's V/D/J + trim + NP backbone — the single rearrangement that defines the clone; -2. draws a **size** from a heavy-tailed clone-size distribution (power-law/Zipf by - default, log-normal optional), with a controllable **unexpanded-singleton fraction**; +2. draws a **size** from a heavy-tailed clone-size distribution (rounded + power-law / Zipf-like by default, log-normal optional), with a controllable + **unexpanded-singleton fraction**; 3. emits that many **reads** through the post-fork library-prep / sequencing passes, so reads diverge only by technical noise; 4. **genotype-collapses** identical reads into AIRR records, each carrying a @@ -35,13 +35,13 @@ a per-clone mutation genealogy. | | What it models | Ground truth | Loci | |---|---|---|---| | **`clonal_repertoire`** | Non-tree clonal abundance; one rearrangement × N copies + technical noise | `clone_id` + `duplicate_count` | **TCR** and flat **BCR** | -| [`clonal_lineage`](clonal-lineage.html) | BCR affinity-maturation **trees** (per-division SHM, selection) | Lineage tree + per-cell records | **BCR only** | -| `expand_clones` *(deprecated)* | Star: fixed `per_clone` count, **no** size distribution | `clone_id` | BCR / TCR | +| [`clonal_lineage`](clonal-lineage.md) | BCR affinity-maturation **trees** (per-division SHM, selection) | Lineage tree + per-cell records | **BCR only** | +| `expand_clones` *(deprecated)* | Star: fixed `per_clone` count, **no** size distribution | `clone_id` + `parent_id` | BCR / TCR | `clonal_repertoire` is the modern replacement for flat clonal expansion: instead of `expand_clones`' fixed `per_clone` count, every clone draws a realistic heavy-tailed size. For BCR **lineage trees** (genealogy, ancestral sequences, selection), use -[`clonal_lineage`](clonal-lineage.html) instead. +[`clonal_lineage`](clonal-lineage.md) instead. ## The biology @@ -203,7 +203,7 @@ claim they were validated against `clonal_repertoire` output here. - **No mutation tree.** `clonal_repertoire` is a flat, non-tree model — there are no ancestral nodes, generations, or selection. For a BCR genealogy (Newick / FASTA / - node table, affinity maturation), use [`clonal_lineage`](clonal-lineage.html). + node table, affinity maturation), use [`clonal_lineage`](clonal-lineage.md). A post-fork `.mutate()` on BCR applies *flat* SHM per copy, not a lineage. - **Power-law is continuous-rounded, not exact discrete Zipf.** Sizes come from a continuous inverse-CDF rounded to integers; this approximates true discrete Zipf diff --git a/site_docs/guides/corruption-sequencing.md b/site_docs/guides/corruption-sequencing.md index 20679c8..93c75d8 100644 --- a/site_docs/guides/corruption-sequencing.md +++ b/site_docs/guides/corruption-sequencing.md @@ -253,18 +253,18 @@ A few facts to know: reflect the strand decision automatically. Don't apply a second flip downstream. -## Ordering with clonal families +## Ordering with clonal workflows **All seven corruption passes are descendant-phase.** They model -per-read artefacts — every descendant of a clone gets independent -PCR errors, independent indel events, independent end-loss -draws, etc. +per-read artefacts — every emitted clone member or observed lineage +cell gets independent PCR errors, independent indel events, +independent end-loss draws, etc. ```python result = ( ga.Experiment.on("human_igh") .recombine() - .expand_clones(n_clones=10, per_clone=20) + .clonal_repertoire(n_clones=10, max_size=50) .mutate(model="s5f", rate=0.03) # biology — descendant-phase .pcr_amplify(count=(0, 3)) # corruption — descendant-phase .polymerase_indels(count=(0, 2)) @@ -278,9 +278,12 @@ result = ( ) ``` -Calling any of them *before* `.expand_clones(...)` raises -`ValueError` at chain time. See [Clonal families](clonal-families.md) -for the ancestor / descendant phase discipline in full. +Calling corruption before a flat fork (`clonal_repertoire` or legacy +`expand_clones`) raises `ValueError` at chain time because the artefact would be +shared by the whole clone. Corruption may also follow `clonal_lineage`; in that +case it is applied independently to each observed sampled cell. See +[Clonal simulation overview](clonal-families.md) for the phase discipline in +full. ## Per-platform calibrated profiles @@ -402,12 +405,11 @@ separate passes, separate fields, separate biology stages. The [Recombination + junction biology](recombination-junction.md#trims-vs-end-loss) guide has the side-by-side comparison. -**Putting corruption before `.expand_clones()`.** All seven -corruption passes are descendant-phase — they're per-read -artefacts that need to vary within a clonal family. The DSL -rejects this at chain time with the uniform message: " -must be called after expand_clones(); it is descendant-specific -and must be sampled independently for each clone member." +**Putting corruption before a clonal fork.** All seven corruption +passes are descendant-phase — they're per-read artefacts that need to +vary within a clonal family. The DSL rejects this before +`clonal_repertoire()` or `expand_clones()` with a message naming the +offending method and telling you to move it after the fork. **Reverse-complementing R2 again after random strand orientation.** When `.random_strand_orientation(...)` is in the pipeline, @@ -430,8 +432,8 @@ count them yourself with `rec["sequence"].count("N")`. partition. - **[Paired-end reads and FASTQ](paired-end-fastq.md)** — the read-layout projection that usually sits alongside corruption. -- **[Clonal families](clonal-families.md)** — the - ancestor / descendant phase discipline that gates every +- **[Clonal simulation overview](clonal-families.md)** — the + clonal model chooser and phase discipline that gates every corruption pass. - **[Recombination + junction biology](recombination-junction.md)** — the trim vs end-loss boundary in detail. diff --git a/site_docs/guides/experiment-builder.md b/site_docs/guides/experiment-builder.md index 7bdc1a1..d3d1916 100644 --- a/site_docs/guides/experiment-builder.md +++ b/site_docs/guides/experiment-builder.md @@ -86,7 +86,7 @@ pass actually does: | **Recombination-stage mechanisms** | `.invert_d(prob=...)`, `.receptor_revision(prob=...)` | D in reverse-complement orientation; post-recombine V replacement | | **Constraints** | `.productive_only()`, `.restrict_alleles(v=[...], d=[...], j=[...])` | Constrain the sample space (productive triad; allele subsetting) | | **Biological mutation** | `.mutate(model="s5f"\|"uniform", rate=..., segment_rates={...}, v_subregion_rates={...})` | Somatic hypermutation per descendant | -| **Clonal structure** | `.expand_clones(n_clones=..., per_clone=...)` | Fork: passes before run once per parent, passes after run per descendant | +| **Clonal structure** | `.clonal_lineage(...)`, `.clonal_repertoire(...)`, legacy `.expand_clones(...)` | BCR trees, TCR / flat-BCR abundance repertoires, or fixed-size star families | | **Library / sequencing corruption** | `.pcr_amplify(count=...)`, `.polymerase_indels(count=...)`, `.ambiguous_base_calls(count=...)`, `.sequencing_errors(count=...)`, `.end_loss_5prime(length=...)`, `.end_loss_3prime(length=...)` | Library-prep + sequencer artefacts | | **Read layout** | `.paired_end(r1_length=..., r2_length=..., insert_size=...)`, `.random_strand_orientation(prob=...)` | R1/R2 windows; strand flips | | **Bookkeeping** | `.with_metadata(experiment_id=..., tissue=...)`, `.contaminate(prob=...)` | Stamp user fields onto every record; inject background contaminants | @@ -101,9 +101,16 @@ controls how much N is added and what bases get drawn; the ## Order matters GenAIRR's API rejects pipelines whose steps biologically cannot -compose. The single most important ordering rule is the **clonal -fork**: `expand_clones(...)` partitions the pipeline into an -ancestor phase and a descendant phase. +compose. The clonal methods are the main ordering boundary: + +| Method | Use it for | Post-fork behavior | +|---|---|---| +| `clonal_lineage(...)` | BCR affinity-maturation trees | SHM is internal to the tree; optional library-prep / sequencing artefacts run once per observed cell | +| `clonal_repertoire(...)` | TCR or flat-BCR abundance repertoires | Copies are emitted through post-fork per-read passes and collapsed into `duplicate_count` | +| `expand_clones(...)` | Legacy fixed-size star families | Fixed `n_clones × per_clone` descendants | + +For flat clonal models (`clonal_repertoire` and `expand_clones`), steps before +the fork run once per clone; steps after run once per emitted read/copy. ```python exp = ( @@ -113,7 +120,7 @@ exp = ( .invert_d(prob=0.05) .receptor_revision(prob=0.05) # ──── fork ───────────────────────────────────────────── - .expand_clones(n_clones=50, per_clone=20) + .clonal_repertoire(n_clones=50, max_size=100, unexpanded_fraction=0.3) # ──── descendant phase (runs ONCE per descendant) ────── .mutate(model="s5f", rate=0.05) .pcr_amplify(count=(0, 3)) @@ -122,34 +129,30 @@ exp = ( ) result = exp.run_records(seed=42) -# Output: n_clones × per_clone = 1000 records. Every clone shares -# the same V(D)J recombination + D orientation + receptor revision; -# every descendant within a clone has independent SHM, PCR errors, -# end-loss, and R1/R2 windows. +# Each clone shares the same V(D)J recombination + D orientation + +# receptor revision; each emitted read has independent SHM, PCR errors, +# end-loss, and R1/R2 windows. Identical reads collapse into duplicate_count. ``` -**Ancestor-phase passes** (anything before `expand_clones`) run once -per clonal parent and propagate to every descendant. Use them for -the V(D)J recombination event itself, the recombination-stage -mechanisms (D inversion, receptor revision), and any constraint -that pertains to the parent (`productive_only`, `restrict_alleles`). - -**Descendant-phase passes** (anything after `expand_clones`) run -independently per descendant. Use them for somatic hypermutation -(every memory B cell mutates independently), all library / -sequencer corruption (PCR errors don't share across descendants), -and read layout (R1/R2 windows are per-read). - -Putting an SHM pass *before* `expand_clones` would mean every -descendant of every clone shares the same mutations — biologically -wrong. Putting `invert_d` *after* `expand_clones` would mean -descendants of the same parent see different D orientations — -also wrong. The DSL raises `ValueError` at chain time when these -orderings are violated; you don't have to remember the rules -exhaustively, the API does. - -If `expand_clones` is absent, every pass is "descendant phase" and -runs per-record (since each record is its own one-off lineage). +**Ancestor-phase passes** (anything before a flat clonal fork) run once per +clonal parent and propagate to every emitted copy. Use them for the V(D)J +recombination event itself, recombination-stage mechanisms (D inversion, +receptor revision), and constraints on the founder draw (`productive_only`, +`restrict_alleles`). + +**Descendant/read-phase passes** (anything after a flat clonal fork) run +independently per emitted copy. Use them for BCR flat SHM, all library / +sequencer corruption, and read layout. TCR rejects `.mutate(...)`; T cells do +not SHM. If `paired_end` follows `clonal_repertoire`, remember the result is +still abundance-collapsed by assembled sequence: `duplicate_count` carries copy +number, and FASTQ export does not expand it into multiple read pairs. + +`clonal_lineage` handles its own tree-internal SHM through +`clonal_lineage(rate=...)`; do not add `.mutate(...)` after it. Library-prep and +sequencing artefacts may follow lineage output, but `paired_end` is not wired +through `clonal_lineage` yet. + +If no clonal method is present, every pass runs per record. ## Choosing counts vs rates @@ -260,7 +263,9 @@ seed produces the same draws, which is how golden tests work. | Biology → API surface lookup | [Biology map](biology-map.md) | | Per-segment + per-V-subregion SHM targeting | [Targeted SHM rates](shm-targeting.md) | | R1/R2 windows + insert sizes + FASTQ output | [Paired-end reads and FASTQ](paired-end-fastq.md) | -| Forking into clonal families | [Clonal families](clonal-families.md) | +| Choosing a clonal model | [Clonal simulation overview](clonal-families.md) | +| BCR lineage trees | [Clonal lineage trees](clonal-lineage.md) | +| TCR / flat-BCR abundance repertoires | [Clonal repertoires](clonal-repertoire.md) | | Authoring or tuning a custom reference cartridge | [Reference cartridge](../concepts/reference-cartridge.md) | | Confirming output integrity post-run | [`validate_records`](../validation/validate-records.md) | | Per-record AIRR field catalogue | [Your first AIRR record](../getting-started/first-airr-record.md) | diff --git a/site_docs/guides/index.md b/site_docs/guides/index.md index b17a2d6..1c38e39 100644 --- a/site_docs/guides/index.md +++ b/site_docs/guides/index.md @@ -21,8 +21,14 @@ specific biological mechanisms or pipeline patterns.

- **[SHM and mutation targeting](shm-targeting.md)** — uniform vs S5F, per-segment and per-V-subregion rates, counter partitions. -- **[Clonal families](clonal-families.md)** — `expand_clones`, - the ancestor / descendant phase split, family validation. +- **[Clonal simulation overview](clonal-families.md)** — choose + between BCR lineage trees, TCR / flat-BCR clone-size repertoires, + and legacy fixed-size stars. +- **[Clonal lineage trees](clonal-lineage.md)** — BCR + affinity-maturation trees with SHM, selection, sampling, and + ground-truth exports. +- **[Clonal repertoires](clonal-repertoire.md)** — TCR and flat-BCR + abundance models with heavy-tailed clone sizes and `duplicate_count`. ## Library + sequencing diff --git a/site_docs/guides/paired-end-fastq.md b/site_docs/guides/paired-end-fastq.md index 11608a5..146ee76 100644 --- a/site_docs/guides/paired-end-fastq.md +++ b/site_docs/guides/paired-end-fastq.md @@ -178,31 +178,37 @@ few interactions are worth knowing: at projection time, exactly once; the writer never applies a second flip. -## Clonal families +## Clonal workflows `paired_end` is a descendant-phase pass — R1/R2 windows are -per-read, so each clone member gets its own independent layout -draw. If you put `.paired_end(...)` before `.expand_clones(...)`, -the DSL raises at chain time. The right order: +per-read, so each emitted clone member gets its own independent layout +draw. It is fully supported after legacy `expand_clones(...)`, and +accepted after `clonal_repertoire(...)` with one important caveat: +`clonal_repertoire` collapses identical assembled sequences into one +record with `duplicate_count`, and FASTQ export does not expand that +abundance back into multiple read pairs. `clonal_lineage(...)` does +not support paired-end projection yet. If you put `.paired_end(...)` +before a flat clonal fork, the DSL raises at chain time. The right +order for a collapsed clonal-repertoire record surface: ```python result = ( ga.Experiment.on("human_igh") .recombine() - .expand_clones(n_clones=50, per_clone=20) + .clonal_repertoire(n_clones=50, max_size=100) .mutate(model="s5f", rate=0.05) .paired_end(r1_length=150, insert_size=300) .run_records(seed=1) ) result.to_paired_fastq("reads_R1.fastq", "reads_R2.fastq") -# 1,000 paired records (50 clones × 20 descendants). Each -# descendant has its own R1 / R2 windows, possibly drawn -# from a different insert_size. +# Each emitted read has its own R1 / R2 windows, possibly drawn from +# a different insert_size; identical reads may have collapsed into +# duplicate_count before export. ``` -See [Clonal families](clonal-families.md) for the ancestor / descendant -phase rules in full. +See [Clonal simulation overview](clonal-families.md) for the clonal model +chooser and phase rules in full. ## Validation @@ -242,12 +248,14 @@ discipline. A handful of issues that show up repeatedly with paired-end. -**Calling `.paired_end()` before `.expand_clones()`.** R1/R2 -windows are per-read, so `paired_end` is descendant-phase. The -DSL rejects this at chain time: "paired_end must be called after -expand_clones(); it is descendant-specific and must be sampled -independently for each clone member." Move `.paired_end(...)` -after `.expand_clones(...)`. +**Calling `.paired_end()` before a flat clonal fork.** R1/R2 windows +are per-read, so `paired_end` is descendant-phase. Move +`.paired_end(...)` after `clonal_repertoire(...)` or legacy +`expand_clones(...)`. For exact per-copy paired FASTQ depth today, +use legacy `expand_clones`; `clonal_repertoire` is abundance-collapsed +and exposes copy number through `duplicate_count`. `clonal_lineage(...)` +currently rejects `.paired_end(...)` even after the fork; paired-end +lineage output is a future addition. **Expecting two AIRR rows per molecule.** There's still one row per record — paired-end is a layered projection, not a record @@ -278,8 +286,8 @@ instead. - **[Validation & reproducibility](../validation/index.md)** — the validator that checks paired-end geometry, plus the reproducibility model. -- **[Clonal families](clonal-families.md)** — the ancestor / - descendant phase rules and why `paired_end` must come after - `expand_clones`. +- **[Clonal simulation overview](clonal-families.md)** — the clonal + model chooser and why `paired_end` must come after flat clonal + forks. - **[The Experiment builder](experiment-builder.md)** — where `paired_end` sits in the full DSL pipeline. diff --git a/site_docs/guides/recombination-editing.md b/site_docs/guides/recombination-editing.md index 1ffab07..6a62598 100644 --- a/site_docs/guides/recombination-editing.md +++ b/site_docs/guides/recombination-editing.md @@ -65,7 +65,7 @@ print(sum(1 for r in result.records if r["d_inverted"])) - **Single call per pipeline.** A second `.invert_d(...)` raises `ValueError: invert_d already configured on this experiment; v1 accepts at most one inversion step per pipeline`. -- **Must come before `.expand_clones(...)`** (ancestor-phase). See +- **Must come before clonal forks** (ancestor-phase). See [Clonal placement](#clonal-placement) below. ### The `d_inverted` AIRR field @@ -148,7 +148,7 @@ print(sum(1 for r in result.records if r["receptor_revision_applied"])) - **Single call per pipeline.** A second call raises `ValueError: receptor_revision already configured on this experiment`. -- **Must come before `.expand_clones(...)`** (ancestor-phase). +- **Must come before clonal forks** (ancestor-phase). - The compile path also checks that the cartridge has a V pool in refdata — required because the replacement allele draws from it. @@ -209,24 +209,25 @@ after V assembly). ## Clonal placement -Both methods must be configured BEFORE `.expand_clones(...)` — +Both methods must be configured BEFORE a clonal fork +(`clonal_lineage`, `clonal_repertoire`, or legacy `expand_clones`) — they're recombination-time decisions that the family inherits. The DSL enforces this at chain time with two symmetric guards: ```python -# WRONG — invert_d after expand_clones raises ValueError +# WRONG — invert_d after a clonal fork raises ValueError ga.Experiment.on("human_igh") \ .recombine() \ - .expand_clones(n_clones=10, per_clone=20) \ + .clonal_repertoire(n_clones=10, max_size=20) \ .invert_d(prob=0.05) -# ValueError: invert_d must be called before expand_clones(); +# ValueError: invert_d must be called before the clonal fork; # D inversion is a recombination-time decision and must be # inherited by all clone descendants. Move the invert_d(...) -# call before expand_clones(...). +# call before clonal_lineage(...), clonal_repertoire(...), or expand_clones(...). ``` Symmetric message for `receptor_revision` placed after -`expand_clones`. The right order is: +a clonal fork. The right order is: ```python result = ( @@ -234,7 +235,7 @@ result = ( .recombine() .invert_d(prob=0.05) # ancestor phase .receptor_revision(prob=0.02) # ancestor phase - .expand_clones(n_clones=10, per_clone=20) + .clonal_repertoire(n_clones=10, max_size=20) .mutate(model="s5f", rate=0.03) # descendant phase .run_records(seed=1) ) @@ -248,7 +249,7 @@ checks (via the `truth_*_call` fields when provenance exposure is on; the parent-aware validator also compares `d_inverted` and `original_v_call` against the parent Outcome). -See [Clonal families](clonal-families.md) for the full +See [Clonal simulation overview](clonal-families.md) for the full ancestor-vs-descendant phase discipline. ## Validation and replay @@ -329,8 +330,8 @@ re-RCs the bytes before comparing, do it on `rec["sequence"][d_sequence_start:d_sequence_end]` — don't remap the coordinates. -**Putting `.invert_d()` or `.receptor_revision()` after -`.expand_clones()`.** The DSL rejects this immediately at chain +**Putting `.invert_d()` or `.receptor_revision()` after a clonal +fork.** The DSL rejects this immediately at chain time with the symmetric message above. Both decisions must be shared across every descendant of a clonal family; the API will not let you accidentally split them. @@ -340,7 +341,7 @@ not let you accidentally split them. - **[Recombination and junction biology](recombination-junction.md)** — what `.recombine()` produces in the first place, before either of these mechanisms run. -- **[Clonal families](clonal-families.md)** — the ancestor-vs- +- **[Clonal simulation overview](clonal-families.md)** — the ancestor-vs- descendant phase discipline both mechanisms participate in. - **[Validation & reproducibility](../validation/index.md)** — the validator's issue catalogue (including the three issue diff --git a/site_docs/guides/recombination-junction.md b/site_docs/guides/recombination-junction.md index 0cb4193..146ee32 100644 --- a/site_docs/guides/recombination-junction.md +++ b/site_docs/guides/recombination-junction.md @@ -23,10 +23,11 @@ analysis would call "the receptor": | **Assembled regions** | The final structural regions (`V`, `NP1`, `D`, `NP2`, `J`) carrying their coordinates on the record | | **Junction** | The canonical V Cys → J W/F + 3 window exposed on `junction` / `junction_aa` / `junction_length` | -Everything before `.mutate()`, `.expand_clones()`, or any -corruption pass is recombination's responsibility. After the -pass, the molecule is "finished" in the recombination-biology -sense — SHM and library prep happen on top of it. +Everything before `.mutate()`, a clonal fork (`clonal_lineage`, +`clonal_repertoire`, legacy `expand_clones`), or any corruption pass is +recombination's responsibility. After the pass, the molecule is "finished" in +the recombination-biology sense — SHM, clonal expansion, and library prep happen +on top of it. ## A minimal recombination diff --git a/site_docs/learn.md b/site_docs/learn.md index bfc7f5b..9e5536a 100644 --- a/site_docs/learn.md +++ b/site_docs/learn.md @@ -73,10 +73,16 @@ where the v1 boundary sits. 4. **[SHM and mutation targeting](guides/shm-targeting.md)** — uniform vs S5F, per-segment and per-V-subregion rates, counter partitions. -5. **[Clonal families](guides/clonal-families.md)** — ancestor / - descendant phase discipline, `clone_id` / `parent_id`, family - validation. -6. **[Corruption + sequencing artefacts](guides/corruption-sequencing.md)** +5. **[Clonal simulation overview](guides/clonal-families.md)** — + choose `clonal_lineage` for BCR trees, `clonal_repertoire` for + TCR / abundance repertoires, or legacy `expand_clones`. +6. **[Clonal lineage trees](guides/clonal-lineage.md)** — BCR SHM + trees, selection, final-cell sampling, lineage metadata, and + tree exports. +7. **[Clonal repertoires](guides/clonal-repertoire.md)** — TCR and + flat-BCR clone sizes, `duplicate_count`, and AIRR clone-caller + export. +8. **[Corruption + sequencing artefacts](guides/corruption-sequencing.md)** — the observation-stage mechanisms (PCR, sequencing errors, indels, end-loss, N corruption, strand). diff --git a/site_docs/reference/experiment.md b/site_docs/reference/experiment.md index 6578b25..074eb33 100644 --- a/site_docs/reference/experiment.md +++ b/site_docs/reference/experiment.md @@ -5,21 +5,23 @@ builder. Every method returns the same Experiment extended by one pipeline stage; the pipeline runs when you call .run_records(...), .run(...), or .compile().run(...). For the conceptual walk-through — -which methods are ancestor-phase vs descendant-phase, how clonal -expansion partitions the pipeline, how compile reuse works — see +which methods are ancestor-phase vs descendant-phase, which clonal +model to choose, how compile reuse works — see the Experiment builder guide. The reference below catalogues the public surface.

## Common methods -The six methods you'll reach for in 90 % of real pipelines: +The methods you'll reach for in most real pipelines: | Method | Purpose | |---|---| | `.recombine(...)` | Add the V(D)J recombination pass — the foundational ancestor-phase mechanism | | `.productive_only()` | Constrain sampling so only productive rearrangements survive | | `.mutate(...)` | Apply biological SHM (uniform or S5F) on top of recombination | -| `.expand_clones(...)` | Partition the pipeline into ancestor + descendant phases for clonal families | +| `.clonal_lineage(...)` | Grow BCR affinity-maturation lineage trees | +| `.clonal_repertoire(...)` | Generate TCR / flat-BCR abundance repertoires with clone sizes and `duplicate_count` | +| `.expand_clones(...)` | Legacy fixed-size clonal star model | | `.paired_end(...)` | Project assembled sequences as paired R1 / R2 reads | | `.run_records(...)` | Compile + run + return a `SimulationResult` | @@ -43,6 +45,8 @@ pilot for the wider generated-reference effort. - recombine - productive_only - mutate + - clonal_lineage + - clonal_repertoire - expand_clones - paired_end - run_records diff --git a/site_docs/reference/index.md b/site_docs/reference/index.md index 17883a7..c666399 100644 --- a/site_docs/reference/index.md +++ b/site_docs/reference/index.md @@ -86,9 +86,9 @@ access). | Symbol | Purpose | Guide | |---|---|---| | `ValidationReport` | Per-record AIRR-validator output | [`validate_records`](../validation/validate-records.md) | -| `FamilyValidationReport` | Family-validator output | [Clonal families](../guides/clonal-families.md) | +| `FamilyValidationReport` | Family-validator output | [Clonal simulation overview](../guides/clonal-families.md) | | `RecordValidationFailedError` | Raised when strict record validation fails | [`validate_records`](../validation/validate-records.md) | -| `FamilyValidationFailedError` | Raised when strict family validation fails | [Clonal families](../guides/clonal-families.md) | +| `FamilyValidationFailedError` | Raised when strict family validation fails | [Clonal simulation overview](../guides/clonal-families.md) | | `StrictSamplingError` | Raised under `strict=True` on empty admissible support | [Validation hub](../validation/index.md#strict-vs-permissive) | | `productive` | Convenience contract bundle for productive sampling | [Recombination biology](../guides/recombination-junction.md#productivity) | @@ -105,7 +105,7 @@ is called. | **Recombination** | `.recombine()`, `.invert_d(prob=...)`, `.receptor_revision(prob=...)` | Ancestor-phase mechanisms | | **Constraints** | `.productive_only()`, `.restrict_alleles(v=..., d=..., j=...)` | Constrain the sample space | | **Biological mutation** | `.mutate(model="s5f"\|"uniform", rate=..., count=..., segment_rates=..., v_subregion_rates=...)` | The only pass that increments `n_mutations` | -| **Clonal structure** | `.expand_clones(n_clones=..., per_clone=...)` | Marks the ancestor/descendant fork | +| **Clonal structure** | `.clonal_lineage(...)`, `.clonal_repertoire(...)`, legacy `.expand_clones(...)` | BCR trees, TCR / flat-BCR abundance repertoires, or fixed-size star families | | **Trims override** | `.trim(v_3=..., d_5=..., d_3=..., j_5=..., enabled=...)` | Per-experiment trim distribution overrides | | **Library / sequencer artefacts** | `.pcr_amplify(...)`, `.polymerase_indels(...)`, `.ambiguous_base_calls(...)`, `.sequencing_errors(...)`, `.end_loss_5prime(...)`, `.end_loss_3prime(...)` | All descendant-phase | | **Read layout** | `.paired_end(r1_length=..., r2_length=..., insert_size=...)`, `.random_strand_orientation(prob=...)` | Per-read projection | @@ -125,7 +125,7 @@ record dicts plus the underlying `Outcome` objects. | Surface | Methods / properties | Notes | |---|---|---| | **List-like access** | `len(result)`, `result[i]`, `result[a:b]`, `for rec in result:` | One AIRR dict per element | -| **Underlying state** | `.records`, `.outcomes`, `.parents` | `outcomes` is `None` when built from records only; `parents` is `None` on non-clonal results | +| **Underlying state** | `.records`, `.outcomes`, `.parents`, `.lineage_trees` | `outcomes` is `None` when built from records only; `parents` exists only for legacy `expand_clones`; `lineage_trees` exists on lineage results | | **Validation** | `.validate_records(refdata)`, `.validate_families()`, `.validate_families_with_parents(refdata)` | See [Validation hub](../validation/index.md) | | **Export** | `.to_tsv(path, *, airr_strict=False)`, `.to_csv(path, *, airr_strict=False)`, `.to_fasta(path, *, prefix="seq")`, `.to_fastq(path, *, quality="illumina", **kw)`, `.to_paired_fastq(r1, r2, *, quality="illumina", overwrite=False, **kw)`, `.to_dataframe(*, airr_strict=False)` | See [Export the results](../getting-started/export-results.md) | | **Construction** | `SimulationResult.from_outcomes(outcomes, refdata, *, id_prefix="seq", expose_provenance=False)` | Build a result from Rust `Outcome` objects + refdata; `expose_provenance=True` injects `truth_*_call` columns | diff --git a/site_docs/reference/simulation-result.md b/site_docs/reference/simulation-result.md index 789fffd..3ca7dd6 100644 --- a/site_docs/reference/simulation-result.md +++ b/site_docs/reference/simulation-result.md @@ -3,8 +3,10 @@

SimulationResult is the output wrapper returned by Experiment.run_records(...). It holds the list of AIRR record dicts, the underlying engine Outcome -objects (for trace / replay / validation), and the parent -Outcomes when the pipeline produced clonal families. +objects (for trace / replay / validation), legacy parent +Outcomes when expand_clones produced fixed-size +families, and lineage trees when clonal_lineage produced BCR +tree output. Treat it as a list-like view of records plus the typed validators and export helpers below.

@@ -23,6 +25,11 @@ The eight methods you'll reach for in real pipelines: | `.to_fasta(path, *, prefix="seq")` | Write assembled sequences as FASTA | | `.to_fastq(...)` / `.to_paired_fastq(...)` | Write FASTQ; paired-end requires `read_layout="paired_end"` | +`clonal_repertoire` returns ordinary `SimulationResult` records with +`clone_id` and `duplicate_count`. `clonal_lineage` returns +`SimulationResultWithLineages`, a subclass that adds `.lineage_trees`. +Legacy `expand_clones` returns `SimulationResult` with `.parents`. + ### FASTQ exports (prose only) The two FASTQ-emitting methods are documented in diff --git a/site_docs/validation/index.md b/site_docs/validation/index.md index 15358b4..1332fbf 100644 --- a/site_docs/validation/index.md +++ b/site_docs/validation/index.md @@ -61,8 +61,9 @@ one-liner. ### Family validation -For workloads using `expand_clones(...)`, two sibling validators -check family-level invariants: +For workloads that stamp `clone_id` (`clonal_lineage(...)`, +`clonal_repertoire(...)`, or legacy `expand_clones(...)`), family +validation checks clone-level record consistency: ```python family_report = result.validate_families() @@ -73,16 +74,19 @@ full_report = result.validate_families_with_parents(refdata) assert full_report, full_report.summary() ``` -`validate_families` checks within-family invariants alone — every -descendant of a clone shares its V(D)J recombination, every -descendant's `clone_id` matches the parent's, no descendant has a -SHM count below the parent's, and the per-clone size matches what -`expand_clones` was asked for. No refdata required. +`validate_families` is records-only. It checks that a clonal batch is +not mixed with non-clonal records and, when `truth_v_call`, +`truth_d_call`, and `truth_j_call` are present, that those +recombination-time truth calls are invariant within each `clone_id` +group. No refdata required. -`validate_families_with_parents(refdata)` adds full per-record -validation on every parent before checking family invariants — -it's the strongest gate. Use it in release-tier CI when the -pipeline ships clonal output. +`validate_families_with_parents(refdata)` is the stronger +legacy-star diagnostic for `expand_clones(...)` outputs, where +`result.parents` exists. It compares descendant records against the +actual parent `Outcome`. Modern `clonal_repertoire` and +`clonal_lineage` do not expose `result.parents`; validate their +records with `validate_records`, use `validate_families` for +`clone_id` grouping, and validate lineage trees with `tree.validate()`. ### Runtime opt-in @@ -108,8 +112,8 @@ hot loops. | Allele calls (`v_call` / `d_call` / `j_call` matches an independent walker) | `validate_records` | | Junction + productive triad (junction content, `vj_in_frame`, `stop_codon`, `productive` predicate identification) | `validate_records` | | Paired-end geometry (R1/R2 windows, R2 reverse-complement, `insert_size`) | `validate_records` (when `paired_end()` is in the pipeline) | -| Family size, parent/descendant `clone_id` match, descendant `n_mutations ≥ parent's` | `validate_families` | -| Each family's *parent* validates as a standalone record | `validate_families_with_parents` | +| Clonal batch is consistently stamped with `clone_id`; truth V/D/J calls are invariant within clone when truth columns are present | `validate_families` | +| Legacy `expand_clones` descendants agree with their parent `Outcome` | `validate_families_with_parents` | The three layers are independent — `validate_records` doesn't require a clonal structure; `validate_families` doesn't require @@ -308,24 +312,25 @@ the `failures` list is JSON-serialisable for downstream tooling. ### Clonal output -Stack record + family validation when the pipeline ships clonal -families: +Stack record + family validation when the pipeline ships clonal records: ```python result = ( exp .recombine() - .expand_clones(n_clones=50, per_clone=20) - .mutate(model="s5f", rate=0.05) - .run_records(n=1000, seed=42) + .clonal_repertoire(n_clones=50, max_size=100) + .sequencing_errors(rate=0.001) + .run_records(seed=42, expose_provenance=True) ) assert result.validate_records(refdata), "AIRR record divergence" -assert result.validate_families_with_parents(refdata), "Family invariant divergence" +assert result.validate_families(), "Family invariant divergence" ``` -The two layers catch different things and don't substitute for -each other. +For legacy `expand_clones(...)`, add +`result.validate_families_with_parents(refdata)`. For +`clonal_lineage(...)`, also call `tree.validate()` on each +`result.lineage_trees` entry when you need topology checks. ## When validation is not enough diff --git a/site_docs/validation/validate-records.md b/site_docs/validation/validate-records.md index aadce33..09b1113 100644 --- a/site_docs/validation/validate-records.md +++ b/site_docs/validation/validate-records.md @@ -182,39 +182,40 @@ and the insert size matches. ## Family validation -For workloads using `expand_clones(...)` to generate clonal -families, two sibling validators check family-level consistency: +For workloads that stamp `clone_id` (`clonal_lineage(...)`, +`clonal_repertoire(...)`, or legacy `expand_clones(...)`), family +validation checks clone-level consistency: ```python result = ( ga.Experiment.on("human_igh") .recombine() - .expand_clones(n_clones=50, per_clone=20) - .mutate(rate=0.05) - .run_records(n=1000, seed=42) + .clonal_repertoire(n_clones=50, max_size=100) + .mutate(rate=0.01) + .run_records(seed=42, expose_provenance=True) ) -family_report = result.validate_families(refdata) +family_report = result.validate_families() assert family_report, family_report.summary() ``` -`validate_families` checks within-family invariants: every -descendant of a clone shares its V(D)J recombination, every -descendant's `clone_id` matches the parent, no descendant has a -SHM count below the parent's, and the per-clone size matches what -`expand_clones` was asked for. +`validate_families` is records-only. It checks that a clonal batch is +not mixed with non-clonal records and, when `truth_v_call`, +`truth_d_call`, and `truth_j_call` are present, that those +recombination-time truth calls are invariant within each `clone_id` +group. -When you also want to check that each family's *parent* record -itself validates against the cartridge (in addition to family -invariants), use the parent-aware variant: +For legacy `expand_clones(...)`, you can also compare each +descendant against its actual parent `Outcome`: ```python report = result.validate_families_with_parents(refdata) ``` -This is the most expensive of the three — it runs the per-record -validator on every parent before checking family invariants — but -it's the strongest gate. Use it in release-tier CI. +`clonal_repertoire` and `clonal_lineage` do not expose +`result.parents`, so `validate_families_with_parents` is not their +primary validator. For `clonal_lineage`, validate the tree objects +directly with `tree.validate()` when topology matters. ## What `validate_records` does NOT do diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index 11ab22c..bd7ee67 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -337,8 +337,8 @@ def _coerce(label_for_error: str, value: object) -> float: # collapses descendant diversity (every clone member shares an # identical effect because the pass ran once on the parent IR). # -# :meth:`Experiment.expand_clones` scans the already-appended step -# list against this table; the first match is rejected with a +# The clonal fork methods scan the already-appended step list against +# this table; the first match is rejected with a # message naming the offending DSL method and the canonical fix. # # Each entry is ``(predicate, dsl_method_name)`` where the predicate @@ -350,9 +350,9 @@ def _descendant_phase_step_classifier(step): """Return the DSL method name a descendant-phase ``step`` came from, or ``None`` if ``step`` is not a descendant-phase step. - Single source of truth for the unified guard in - :meth:`Experiment.expand_clones`. Adding a new descendant-phase - DSL method means appending a clause here (and adding the + Single source of truth for the unified guard in the flat clonal + fork methods. Adding a new descendant-phase DSL method means + appending a clause here (and adding the companion spec test in ``tests/test_clonal_descendant_phase_guards.py``). """ @@ -976,10 +976,11 @@ def expand_clones( ``n == n_clones * per_clone``. .. deprecated:: - Use :meth:`clonal_lineage` instead, which grows real - affinity-maturation lineage trees rather than a flat star - topology. ``expand_clones`` remains supported for flat - clonal expansion. + Use :meth:`clonal_lineage` for BCR affinity-maturation + trees, or :meth:`clonal_repertoire` for TCR / flat-BCR + abundance repertoires with clone-size distributions. + ``expand_clones`` remains supported for fixed-size flat + star expansion. Constraints: - Both ``n_clones`` and ``per_clone`` must be positive ints. @@ -994,17 +995,14 @@ def expand_clones( NP bases) and only diverges through the post-fork passes. """ warnings.warn( - "Experiment.expand_clones() is deprecated in favor of " - "Experiment.clonal_lineage(). Note this is NOT a drop-in " - "replacement: clonal_lineage() grows real affinity-maturation " - "lineage trees (internal SHM, no per_clone — the observed count " - "depends on n_sample / selection), returns a " - "SimulationResultWithLineages with per-clone .lineage_trees, and " - "takes different parameters. Library-prep / sequencing passes " - "(sequencing_errors, pcr_amplify, …) can now follow " - "clonal_lineage() too, applied independently per observed cell. " - "expand_clones() remains supported for flat star-topology " - "expansion.", + "Experiment.expand_clones() is deprecated. Use " + "Experiment.clonal_lineage() for BCR affinity-maturation trees " + "(internal SHM, lineage_trees, no per_clone) or " + "Experiment.clonal_repertoire() for TCR / flat-BCR abundance " + "repertoires with clone-size distributions and duplicate_count. " + "Neither is a drop-in replacement for fixed per_clone output; " + "expand_clones() remains supported for legacy fixed-size flat " + "star expansion.", DeprecationWarning, stacklevel=2, ) @@ -1575,17 +1573,20 @@ def _check_v_subregion_rates_satisfiable( ) def _has_clonal_fork(self) -> bool: - """Whether :meth:`expand_clones` has already been appended. + """Whether any clonal fork has already been appended. Used by the DSL ordering guards on :meth:`invert_d`, - :meth:`receptor_revision`, and :meth:`expand_clones` itself. - Each step lowers into either the pre-fork (per-clone) or - the post-fork (per-descendant) plan; misordered calls used - to silently lower into the wrong half and produce records - with empty / default fields. The guards reject those - configurations at the DSL boundary. + :meth:`receptor_revision`, and the clonal fork methods. + Each fork has a pre-fork parent/founder phase; recombination-time + mechanisms must be inherited by every descendant, emitted copy, + or lineage node. Misordered calls used to lower into the wrong + half and produce records with empty / default fields. The guards + reject those configurations at the DSL boundary. """ - return any(isinstance(s, _ClonalForkStep) for s in self._steps) + return any( + isinstance(s, (_ClonalForkStep, _RepertoireForkStep, _LineageForkStep)) + for s in self._steps + ) def _is_tcr_refdata(self) -> bool: """Detect whether the bound refdata is a TCR locus. @@ -2053,10 +2054,11 @@ def invert_d(self, *, prob: float = 0.05) -> "Experiment": # even at prob=1.0. Reject at the DSL boundary instead. if self._has_clonal_fork(): raise ValueError( - "invert_d must be called before expand_clones(); D " + "invert_d must be called before the clonal fork; D " "inversion is a recombination-time decision and must " "be inherited by all clone descendants. Move the " - "invert_d(...) call before expand_clones(...)." + "invert_d(...) call before clonal_lineage(...), " + "clonal_repertoire(...), or expand_clones(...)." ) if not isinstance(prob, (int, float)): raise ValueError( @@ -2151,10 +2153,11 @@ def receptor_revision(self, *, prob: float = 0.05) -> "Experiment": if self._has_clonal_fork(): raise ValueError( "receptor_revision must be called before " - "expand_clones(); receptor revision is a " + "the clonal fork; receptor revision is a " "recombination-time decision and must be inherited by " "all clone descendants. Move the " - "receptor_revision(...) call before expand_clones(...)." + "receptor_revision(...) call before clonal_lineage(...), " + "clonal_repertoire(...), or expand_clones(...)." ) if not isinstance(prob, (int, float)): raise ValueError( diff --git a/tests/test_clonal_dsl_ordering_guards.py b/tests/test_clonal_dsl_ordering_guards.py index a7a0452..b6680c8 100644 --- a/tests/test_clonal_dsl_ordering_guards.py +++ b/tests/test_clonal_dsl_ordering_guards.py @@ -70,7 +70,7 @@ def test_invert_d_after_expand_clones_raises() -> None: exp.invert_d(prob=1.0) msg = str(exc_info.value) # Must name BOTH the correct ordering and the biological reason. - assert "invert_d must be called before expand_clones" in msg, msg + assert "invert_d must be called before the clonal fork" in msg, msg assert "recombination" in msg.lower(), msg assert "inherited" in msg, msg @@ -93,7 +93,7 @@ def test_receptor_revision_after_expand_clones_raises() -> None: exp.receptor_revision(prob=1.0) msg = str(exc_info.value) assert ( - "receptor_revision must be called before expand_clones" in msg + "receptor_revision must be called before the clonal fork" in msg ), msg assert "recombination" in msg.lower(), msg assert "inherited" in msg, msg @@ -225,7 +225,7 @@ def test_error_message_invert_d_names_recombination_time() -> None: exp.invert_d(prob=0.5) msg = str(exc_info.value) # The correct ordering. - assert "before expand_clones" in msg + assert "before the clonal fork" in msg # The biological reason — pin the phrase that names the # mechanism. assert re.search(r"recombination[- ]?time", msg.lower()) @@ -242,7 +242,7 @@ def test_error_message_receptor_revision_names_recombination_time() -> None: with pytest.raises(ValueError) as exc_info: exp.receptor_revision(prob=0.5) msg = str(exc_info.value) - assert "before expand_clones" in msg + assert "before the clonal fork" in msg assert re.search(r"recombination[- ]?time", msg.lower()) assert "Move" in msg or "move" in msg @@ -273,7 +273,7 @@ def test_pin_guarded_invert_d_post_fork_drop_cannot_reach_runtime() -> None: is rejected at the DSL boundary so the silent runtime behaviour is structurally unreachable. Pin the rejection rather than the broken runtime output.""" - with pytest.raises(ValueError, match="before expand_clones"): + with pytest.raises(ValueError, match="before the clonal fork"): ( ga.Experiment.on("human_igh") .recombine() @@ -284,7 +284,7 @@ def test_pin_guarded_invert_d_post_fork_drop_cannot_reach_runtime() -> None: def test_pin_guarded_receptor_revision_post_fork_drop_cannot_reach_runtime() -> None: """Bug B's silent drop closed at the DSL boundary.""" - with pytest.raises(ValueError, match="before expand_clones"): + with pytest.raises(ValueError, match="before the clonal fork"): ( ga.Experiment.on("human_igh") .recombine() diff --git a/tests/test_clonal_repertoire.py b/tests/test_clonal_repertoire.py index 3532446..a774b3a 100644 --- a/tests/test_clonal_repertoire.py +++ b/tests/test_clonal_repertoire.py @@ -80,6 +80,28 @@ def test_clonal_repertoire_rejects_double_fork(): .clonal_repertoire(n_clones=3, max_size=5)) +@pytest.mark.parametrize("method", ["invert_d", "receptor_revision"]) +def test_recombination_time_edits_reject_after_clonal_repertoire(method): + exp = ( + ga.Experiment.on("human_igh") + .recombine() + .clonal_repertoire(n_clones=5, max_size=10) + ) + with pytest.raises(ValueError, match="before the clonal fork"): + getattr(exp, method)(prob=0.05) + + +@pytest.mark.parametrize("method", ["invert_d", "receptor_revision"]) +def test_recombination_time_edits_reject_after_clonal_lineage(method): + exp = ( + ga.Experiment.on("human_igh") + .recombine() + .clonal_lineage(n_clones=1, max_generations=2, n_sample=2) + ) + with pytest.raises(ValueError, match="before the clonal fork"): + getattr(exp, method)(prob=0.05) + + def test_clonal_repertoire_validates_args(): base = ga.Experiment.on("human_igh").recombine() with pytest.raises(ValueError): From 9af5485a077712dc9e28560bae7912a934b45b2f Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 13:23:47 +0300 Subject: [PATCH 59/59] fix(clonal): reject any second clonal fork (expand_clones/clonal_lineage/clonal_repertoire) at DSL time The duplicate-fork guards in expand_clones() and clonal_lineage() only checked a subset of the three fork step types, so stacking two different fork methods (e.g. clonal_repertoire().clonal_lineage()) slipped past the DSL guard and surfaced as a confusing 'unsupported pipeline step type' TypeError at compile(). All three guards now check all three fork types and raise the canonical 'once per pipeline' message. Adds a parametrized regression test covering every ordered pair. --- src/GenAIRR/experiment.py | 10 ++++--- tests/test_clonal_dsl_ordering_guards.py | 35 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index bd7ee67..88cee7a 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -1012,9 +1012,10 @@ def expand_clones( ) if not isinstance(per_clone, int) or isinstance(per_clone, bool) or per_clone < 1: raise ValueError(f"per_clone must be a positive int, got {per_clone!r}") - if any(isinstance(s, _ClonalForkStep) for s in self._steps): + if self._has_clonal_fork(): raise ValueError( - "expand_clones() can only be called once per pipeline" + "expand_clones() / clonal_lineage() / clonal_repertoire() " + "can only be called once per pipeline" ) # Unified descendant-phase ordering guard. Scan the appended # step list for any step that came from a descendant-phase @@ -1382,9 +1383,10 @@ def clonal_lineage( ) # --- reject duplicate fork steps --- for s in self._steps: - if isinstance(s, (_ClonalForkStep, _LineageForkStep)): + if isinstance(s, (_ClonalForkStep, _RepertoireForkStep, _LineageForkStep)): raise ValueError( - "clonal_lineage() / expand_clones() can only be called once per pipeline" + "clonal_lineage() / expand_clones() / clonal_repertoire() " + "can only be called once per pipeline" ) # --- descendant-phase guard (same as expand_clones) --- for step in self._steps: diff --git a/tests/test_clonal_dsl_ordering_guards.py b/tests/test_clonal_dsl_ordering_guards.py index b6680c8..44a8042 100644 --- a/tests/test_clonal_dsl_ordering_guards.py +++ b/tests/test_clonal_dsl_ordering_guards.py @@ -349,3 +349,38 @@ def test_has_clonal_fork_helper_reflects_pipeline_state() -> None: assert base._has_clonal_fork() is False forked = base.expand_clones(n_clones=1, per_clone=1) assert forked._has_clonal_fork() is True + + +# ────────────────────────────────────────────────────────────────── +# A pipeline may contain at most one clonal fork — stacking any two +# of expand_clones / clonal_lineage / clonal_repertoire raises a +# clear "once per pipeline" error at DSL time (not a confusing +# downstream TypeError at compile()). +# ────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "first, second", + [ + ("clonal_repertoire", "clonal_lineage"), + ("clonal_lineage", "clonal_repertoire"), + ("expand_clones", "clonal_lineage"), + ("expand_clones", "clonal_repertoire"), + ("clonal_lineage", "expand_clones"), + ("clonal_repertoire", "expand_clones"), + ], +) +def test_two_clonal_forks_rejected_once_per_pipeline(first: str, second: str) -> None: + """Any second clonal fork is rejected with the canonical + "once per pipeline" message, regardless of which fork came + first.""" + kwargs = { + "expand_clones": dict(n_clones=1, per_clone=2), + "clonal_lineage": dict(n_clones=1), + "clonal_repertoire": dict(n_clones=1), + } + exp = getattr( + ga.Experiment.on("human_igh").recombine(), first + )(**kwargs[first]) + with pytest.raises(ValueError, match="once per pipeline"): + getattr(exp, second)(**kwargs[second])