From 5685f7379f64fd1d6fdda73274b209b8f1c9e3e6 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 15:39:02 +0300 Subject: [PATCH 01/26] feat(genotype): GeneId + GeneIndex (gene grouping over a pool, no Allele change) --- engine_rs/src/refdata.rs | 126 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/engine_rs/src/refdata.rs b/engine_rs/src/refdata.rs index dafaec0..cb97b75 100644 --- a/engine_rs/src/refdata.rs +++ b/engine_rs/src/refdata.rs @@ -95,6 +95,89 @@ impl AlleleId { } } +/// Stable, refdata-local identifier for a gene within one segment's +/// pool. Assigned in first-appearance order over the pool's alleles, so +/// it is deterministic for a fixed cartridge (and therefore safe to +/// record in the trace for replay, gated by `refdata_content_hash`). +#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] +pub struct GeneId(u32); + +impl GeneId { + pub const fn new(idx: u32) -> Self { + Self(idx) + } + pub const fn index(self) -> u32 { + self.0 + } + pub const fn as_usize(self) -> usize { + self.0 as usize + } +} + +/// Gene-level grouping over a single `AllelePool`, derived from each +/// allele's `gene` string. Built on demand; the pool itself is +/// unchanged, so `Allele` and `refdata_content_hash` are untouched. +#[derive(Clone, Debug)] +pub struct GeneIndex { + names: Vec, // GeneId.index() -> gene name + by_name: std::collections::HashMap, // gene name -> GeneId + alleles: Vec>, // GeneId.index() -> alleles, pool order + gene_of: Vec, // AlleleId.index() -> GeneId +} + +impl GeneIndex { + /// Build the gene grouping from a pool. Genes are numbered in the + /// order their first allele appears in the pool. + pub fn build(pool: &AllelePool) -> Self { + let mut names: Vec = Vec::new(); + let mut by_name: std::collections::HashMap = + std::collections::HashMap::new(); + let mut alleles: Vec> = Vec::new(); + let mut gene_of: Vec = Vec::with_capacity(pool.len()); + for (id, allele) in pool.iter() { + let gid = *by_name.entry(allele.gene.clone()).or_insert_with(|| { + let g = GeneId::new(names.len() as u32); + names.push(allele.gene.clone()); + alleles.push(Vec::new()); + g + }); + alleles[gid.as_usize()].push(id); + gene_of.push(gid); + } + Self { + names, + by_name, + alleles, + gene_of, + } + } + + pub fn len(&self) -> usize { + self.names.len() + } + pub fn is_empty(&self) -> bool { + self.names.is_empty() + } + pub fn gene_id(&self, name: &str) -> Option { + self.by_name.get(name).copied() + } + pub fn gene_name(&self, g: GeneId) -> &str { + &self.names[g.as_usize()] + } + pub fn alleles_of(&self, g: GeneId) -> &[AlleleId] { + &self.alleles[g.as_usize()] + } + pub fn gene_of(&self, a: AlleleId) -> GeneId { + self.gene_of[a.as_usize()] + } + pub fn genes(&self) -> impl Iterator { + self.names + .iter() + .enumerate() + .map(|(i, n)| (GeneId::new(i as u32), n.as_str())) + } +} + // ────────────────────────────────────────────────────────────────── // ChainType — VJ (light) vs VDJ (heavy) // ────────────────────────────────────────────────────────────────── @@ -1180,3 +1263,46 @@ mod tests { assert_eq!(cfg.j_pool.len(), 1); } } + +#[cfg(test)] +mod gene_index_tests { + use super::*; + + fn pool_with(genes: &[(&str, &str)]) -> AllelePool { + // genes: (allele_name, gene_name) + let mut p = AllelePool::new(); + for (name, gene) in genes { + let _ = p.push(Allele { + name: (*name).to_string(), + gene: (*gene).to_string(), + seq: vec![b'A'; 10], + segment: Segment::V, + anchor: Some(3), + functional_status: None, + subregions: Vec::new(), + }); + } + p + } + + #[test] + fn gene_index_groups_alleles_by_gene_in_first_appearance_order() { + let pool = pool_with(&[ + ("IGHV1-2*01", "IGHV1-2"), + ("IGHV1-2*02", "IGHV1-2"), + ("IGHV3-23*01", "IGHV3-23"), + ]); + let idx = GeneIndex::build(&pool); + + assert_eq!(idx.len(), 2); + let g12 = idx.gene_id("IGHV1-2").unwrap(); + let g323 = idx.gene_id("IGHV3-23").unwrap(); + assert_eq!(g12.index(), 0); // first appearance + assert_eq!(g323.index(), 1); + assert_eq!(idx.gene_name(g12), "IGHV1-2"); + assert_eq!(idx.alleles_of(g12), &[AlleleId::new(0), AlleleId::new(1)]); + assert_eq!(idx.alleles_of(g323), &[AlleleId::new(2)]); + assert_eq!(idx.gene_of(AlleleId::new(1)), g12); + assert!(idx.gene_id("nope").is_none()); + } +} From 1bf9a9f5eb811a93cc28393c4e4f7dc19b44a03e Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 15:41:58 +0300 Subject: [PATCH 02/26] feat(genotype): diploid data model (GeneCopy/Haplotype/Genotype) + gene-weight helpers --- engine_rs/src/genotype/mod.rs | 179 ++++++++++++++++++++++++++++++++++ engine_rs/src/lib.rs | 1 + 2 files changed, 180 insertions(+) create mode 100644 engine_rs/src/genotype/mod.rs diff --git a/engine_rs/src/genotype/mod.rs b/engine_rs/src/genotype/mod.rs new file mode 100644 index 0000000..7ff6bb7 --- /dev/null +++ b/engine_rs/src/genotype/mod.rs @@ -0,0 +1,179 @@ +//! Per-individual diploid genotype model (PR1: known reference alleles). +use std::collections::HashMap; + +use crate::ir::Segment; +use crate::refdata::{AlleleId, GeneId}; + +/// One carried allele in a haplotype gene slot. `copies` encodes +/// gene-copy multiplicity for the *same* allele; two different alleles +/// in a slot are two `GeneCopy` entries. `weight` is relative +/// within-slot expression. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GeneCopy { + pub allele: AlleleId, + pub copies: u8, + pub weight: f32, +} + +/// One chromosome's carried alleles, per V/D/J gene. An absent or empty +/// slot means the gene is deleted on this chromosome. +#[derive(Clone, Debug, Default)] +pub struct Haplotype { + v: HashMap>, + d: HashMap>, + j: HashMap>, +} + +impl Haplotype { + pub fn new() -> Self { + Self::default() + } + + fn map(&self, seg: Segment) -> &HashMap> { + match seg { + Segment::V => &self.v, + Segment::D => &self.d, + Segment::J => &self.j, + _ => panic!("Haplotype: segment must be V/D/J, got {seg:?}"), + } + } + fn map_mut(&mut self, seg: Segment) -> &mut HashMap> { + match seg { + Segment::V => &mut self.v, + Segment::D => &mut self.d, + Segment::J => &mut self.j, + _ => panic!("Haplotype: segment must be V/D/J, got {seg:?}"), + } + } + + /// Set (replace) the copies carried for a gene on this chromosome. + /// An empty `copies` vec means the gene is deleted here. + pub fn set(&mut self, seg: Segment, gene: GeneId, copies: Vec) { + self.map_mut(seg).insert(gene, copies); + } + pub fn slot(&self, seg: Segment, gene: GeneId) -> &[GeneCopy] { + self.map(seg).get(&gene).map(Vec::as_slice).unwrap_or(&[]) + } + pub fn is_deleted(&self, seg: Segment, gene: GeneId) -> bool { + self.slot(seg, gene).is_empty() + } + /// Genes with at least one carried copy on this chromosome, in + /// ascending GeneId order (deterministic). + pub fn present_genes(&self, seg: Segment) -> impl Iterator + '_ { + let mut genes: Vec = self + .map(seg) + .iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(g, _)| *g) + .collect(); + genes.sort_by_key(|g| g.index()); + genes.into_iter() + } + /// (GeneId, usage-weight) for each present gene, weight from `usage`. + pub fn gene_weights f64>(&self, seg: Segment, usage: &F) -> Vec<(GeneId, f64)> { + self.present_genes(seg).map(|g| (g, usage(g))).collect() + } + /// All carried allele ids for a segment across all present genes. + pub fn carried_alleles(&self, seg: Segment) -> Vec { + let mut out = Vec::new(); + for g in self.present_genes(seg) { + for c in self.slot(seg, g) { + out.push(c.allele); + } + } + out + } +} + +/// A diploid genotype: two chromosomes + draw weights + provenance. +#[derive(Clone, Debug)] +pub struct Genotype { + haplotypes: [Haplotype; 2], + chromosome_weights: [f32; 2], + subject_id: Option, + source_refdata_hash: String, +} + +impl Genotype { + pub fn new( + haplotypes: [Haplotype; 2], + chromosome_weights: [f32; 2], + subject_id: Option, + source_refdata_hash: String, + ) -> Self { + Self { + haplotypes, + chromosome_weights, + subject_id, + source_refdata_hash, + } + } + pub fn haplotype(&self, c: usize) -> &Haplotype { + &self.haplotypes[c] + } + pub fn chromosome_weights(&self) -> [f32; 2] { + self.chromosome_weights + } + pub fn subject_id(&self) -> Option<&str> { + self.subject_id.as_deref() + } + pub fn source_refdata_hash(&self) -> &str { + &self.source_refdata_hash + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::refdata::AlleleId; + + fn copy(id: u32) -> GeneCopy { + GeneCopy { + allele: AlleleId::new(id), + copies: 1, + weight: 1.0, + } + } + + #[test] + fn haplotype_reports_carried_alleles_per_gene_with_deletion_as_empty() { + let mut h = Haplotype::new(); + h.set(Segment::V, GeneId::new(0), vec![copy(10)]); // carried + h.set(Segment::V, GeneId::new(1), vec![]); // deleted + assert_eq!(h.slot(Segment::V, GeneId::new(0)).len(), 1); + assert!(h.is_deleted(Segment::V, GeneId::new(1))); + assert!(h.is_deleted(Segment::V, GeneId::new(2))); // absent == deleted + let genes: Vec = h.present_genes(Segment::V).collect(); + assert_eq!(genes, vec![GeneId::new(0)]); // only non-empty slots + } + + #[test] + fn genotype_carries_two_haplotypes_and_chromosome_weights() { + let mut h0 = Haplotype::new(); + let mut h1 = Haplotype::new(); + h0.set(Segment::V, GeneId::new(0), vec![copy(10)]); + h1.set(Segment::V, GeneId::new(0), vec![copy(11)]); // heterozygous + let g = Genotype::new([h0, h1], [0.5, 0.5], Some("S1".into()), "sha256:x".into()); + assert_eq!(g.chromosome_weights(), [0.5, 0.5]); + assert_eq!(g.subject_id(), Some("S1")); + assert_eq!( + g.haplotype(0).slot(Segment::V, GeneId::new(0))[0].allele, + AlleleId::new(10) + ); + assert_eq!( + g.haplotype(1).slot(Segment::V, GeneId::new(0))[0].allele, + AlleleId::new(11) + ); + } + + #[test] + fn gene_weights_restrict_to_present_genes_and_apply_usage() { + // chromosome 0 carries genes 0 and 1; usage favors gene 1. + let mut h = Haplotype::new(); + h.set(Segment::V, GeneId::new(0), vec![copy(10)]); + h.set(Segment::V, GeneId::new(1), vec![copy(20)]); + let usage = |g: GeneId| if g.index() == 1 { 3.0 } else { 1.0 }; + let w = h.gene_weights(Segment::V, &usage); + assert_eq!(w, vec![(GeneId::new(0), 1.0), (GeneId::new(1), 3.0)]); + } +} diff --git a/engine_rs/src/lib.rs b/engine_rs/src/lib.rs index 2c385ed..6d85e2a 100644 --- a/engine_rs/src/lib.rs +++ b/engine_rs/src/lib.rs @@ -44,6 +44,7 @@ pub mod contract; pub mod dist; pub mod event; pub mod feasibility; +pub mod genotype; pub mod ir; pub mod junction; pub mod lineage; From f890c106ca4bfa64afbf3a3b5dcf3eaa6691c40b Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 15:45:26 +0300 Subject: [PATCH 03/26] feat(genotype): ChoiceValue::Haplotype + GeneId with wire format + replay accessors --- engine_rs/src/python/trace.rs | 2 ++ engine_rs/src/replay.rs | 20 ++++++++++++++++++++ engine_rs/src/trace.rs | 21 +++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/engine_rs/src/python/trace.rs b/engine_rs/src/python/trace.rs index b8b9cc9..03d369d 100644 --- a/engine_rs/src/python/trace.rs +++ b/engine_rs/src/python/trace.rs @@ -17,6 +17,8 @@ fn choice_value_to_py(py: Python<'_>, v: &ChoiceValue) -> PyObject { ChoiceValue::Bases(bs) => PyBytes::new_bound(py, bs).into_py(py), ChoiceValue::AlleleId(id) => id.into_py(py), ChoiceValue::Bool(b) => b.into_py(py), + ChoiceValue::Haplotype(h) => h.into_py(py), + ChoiceValue::GeneId(g) => g.into_py(py), } } diff --git a/engine_rs/src/replay.rs b/engine_rs/src/replay.rs index 560a4f9..70f9291 100644 --- a/engine_rs/src/replay.rs +++ b/engine_rs/src/replay.rs @@ -137,6 +137,8 @@ pub fn choice_value_kind(value: &ChoiceValue) -> &'static str { ChoiceValue::Bases(_) => "Bases", ChoiceValue::AlleleId(_) => "AlleleId", ChoiceValue::Bool(_) => "Bool", + ChoiceValue::Haplotype(_) => "Haplotype", + ChoiceValue::GeneId(_) => "GeneId", } } @@ -302,6 +304,24 @@ impl TraceCursor { other => Err(kind_mismatch(position, &address_str, "Bool", &other)), } } + + /// Consume the next record as a `ChoiceValue::Haplotype`. + pub fn expect_haplotype(&mut self, address: ChoiceAddress) -> Result { + let (position, address_str, value) = self.advance_with_address(address)?; + match value { + ChoiceValue::Haplotype(h) => Ok(h), + other => Err(kind_mismatch(position, &address_str, "Haplotype", &other)), + } + } + + /// Consume the next record as a `ChoiceValue::GeneId`. + pub fn expect_gene_id(&mut self, address: ChoiceAddress) -> Result { + let (position, address_str, value) = self.advance_with_address(address)?; + match value { + ChoiceValue::GeneId(g) => Ok(g), + other => Err(kind_mismatch(position, &address_str, "GeneId", &other)), + } + } } /// Build a `ValueKindMismatch` without re-borrowing the cursor. Free diff --git a/engine_rs/src/trace.rs b/engine_rs/src/trace.rs index ff61836..a15ee91 100644 --- a/engine_rs/src/trace.rs +++ b/engine_rs/src/trace.rs @@ -63,6 +63,12 @@ pub enum ChoiceValue { /// A boolean choice (e.g., D inversion: yes/no, receptor /// revision: yes/no, contaminant injection: yes/no). Bool(bool), + + /// A chromosome index for phased genotype sampling (0 or 1). + Haplotype(u8), + + /// A refdata-local gene identifier (see `refdata::GeneId`). + GeneId(u32), } /// On-disk discriminant tag for `ChoiceValue`. Lives as a separate @@ -78,6 +84,8 @@ enum ChoiceValueWire { Bases(String), AlleleId(u32), Bool(bool), + Haplotype(u8), + GeneId(u32), } impl Serialize for ChoiceValue { @@ -92,6 +100,8 @@ impl Serialize for ChoiceValue { ), ChoiceValue::AlleleId(id) => ChoiceValueWire::AlleleId(id), ChoiceValue::Bool(b) => ChoiceValueWire::Bool(b), + ChoiceValue::Haplotype(h) => ChoiceValueWire::Haplotype(h), + ChoiceValue::GeneId(g) => ChoiceValueWire::GeneId(g), }; wire.serialize(ser) } @@ -120,6 +130,8 @@ impl<'de> Deserialize<'de> for ChoiceValue { } ChoiceValueWire::AlleleId(id) => ChoiceValue::AlleleId(id), ChoiceValueWire::Bool(b) => ChoiceValue::Bool(b), + ChoiceValueWire::Haplotype(h) => ChoiceValue::Haplotype(h), + ChoiceValueWire::GeneId(g) => ChoiceValue::GeneId(g), }) } } @@ -314,6 +326,15 @@ mod tests { assert_eq!(run_trace.choices()[2].address, "third.choice"); } + #[test] + fn haplotype_and_gene_id_choice_values_round_trip_through_wire() { + for v in [ChoiceValue::Haplotype(1), ChoiceValue::GeneId(7)] { + let json = serde_json::to_string(&v).unwrap(); + let back: ChoiceValue = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + } + #[test] fn trace_find_by_exact_address() { let mut t = Trace::new(); From 93fc051be4bedd3d24a796b592efaf6e8535097d Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 15:51:45 +0300 Subject: [PATCH 04/26] feat(genotype): SampleHaplotype/SampleGene/SampleAlleleInSlot choice addresses (additive, v1 policy) --- engine_rs/src/address.rs | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/engine_rs/src/address.rs b/engine_rs/src/address.rs index 9d3d9e4..55b189f 100644 --- a/engine_rs/src/address.rs +++ b/engine_rs/src/address.rs @@ -54,6 +54,8 @@ const SAMPLE_ALLELE_J: &str = "sample_allele.j"; const SAMPLE_ALLELE_D_INVERTED: &str = "sample_allele.d.inverted"; pub const SAMPLE_ALLELE_INVALID: &str = "sample_allele."; pub const SAMPLE_ALLELE_UNSUPPORTED: &str = "sample_allele."; +/// Per-rearrangement chromosome choice for a phased genotype. +const SAMPLE_HAPLOTYPE: &str = "sample_haplotype"; /// Pass name for `InvertDPass`. Used as the `name()` return value /// and as the pass-plan signature token, so external consumers @@ -458,6 +460,16 @@ pub enum ChoiceAddress { /// from `(allele, trim, orientation, length)` so only the /// length needs a trace address. PLength { end: PEnd }, + /// Haplotype (0/1): the chromosome drawn once per rearrangement by + /// `SampleHaplotypePass` for a phased genotype. V/D/J read it back. + SampleHaplotype, + /// GeneId: the gene chosen within the drawn chromosome for a segment + /// by `SampleGeneAllelePass`. + SampleGene(VdjSegment), + /// AlleleId: the within-slot allele draw, recorded only when a gene + /// slot carries more than one copy (single-copy slots are + /// deterministic and record nothing here). + SampleAlleleInSlot(VdjSegment), } impl ChoiceAddress { @@ -534,6 +546,11 @@ impl fmt::Display for ChoiceAddress { Self::PairedEndR2Length => f.write_str(PAIRED_END_R2_LENGTH), Self::PairedEndInsertSize => f.write_str(PAIRED_END_INSERT_SIZE), Self::PLength { end } => write!(f, "p.{}.length", end.suffix()), + Self::SampleHaplotype => f.write_str(SAMPLE_HAPLOTYPE), + Self::SampleGene(segment) => write!(f, "sample_gene.{}", segment.suffix()), + Self::SampleAlleleInSlot(segment) => { + write!(f, "sample_allele_in_slot.{}", segment.suffix()) + } } } } @@ -615,6 +632,13 @@ fn parse_choice_address(address: &str) -> Option { P_D5_LENGTH => Some(ChoiceAddress::PLength { end: PEnd::D5 }), P_D3_LENGTH => Some(ChoiceAddress::PLength { end: PEnd::D3 }), P_J5_LENGTH => Some(ChoiceAddress::PLength { end: PEnd::J5 }), + SAMPLE_HAPLOTYPE => Some(ChoiceAddress::SampleHaplotype), + "sample_gene.v" => Some(ChoiceAddress::SampleGene(VdjSegment::V)), + "sample_gene.d" => Some(ChoiceAddress::SampleGene(VdjSegment::D)), + "sample_gene.j" => Some(ChoiceAddress::SampleGene(VdjSegment::J)), + "sample_allele_in_slot.v" => Some(ChoiceAddress::SampleAlleleInSlot(VdjSegment::V)), + "sample_allele_in_slot.d" => Some(ChoiceAddress::SampleAlleleInSlot(VdjSegment::D)), + "sample_allele_in_slot.j" => Some(ChoiceAddress::SampleAlleleInSlot(VdjSegment::J)), _ => None, }; if exact.is_some() { @@ -747,6 +771,15 @@ pub enum ChoiceAddressPattern { /// [`ChoiceAddress::PLength`]. One pattern instance per /// `PEnd` — declared by `PAdditionPass`. PLength { end: PEnd }, + /// Singleton-family mirror of [`ChoiceAddress::SampleHaplotype`]. + /// Declared by `SampleHaplotypePass`. + SampleHaplotype, + /// Family mirror of [`ChoiceAddress::SampleGene`]. Declared by + /// `SampleGeneAllelePass`. + SampleGene(VdjSegment), + /// Family mirror of [`ChoiceAddress::SampleAlleleInSlot`]. Declared + /// by `SampleGeneAllelePass` (a potential draw on multi-copy slots). + SampleAlleleInSlot(VdjSegment), } impl ChoiceAddressPattern { @@ -793,6 +826,11 @@ impl fmt::Display for ChoiceAddressPattern { Self::PairedEndR2Length => f.write_str(PAIRED_END_R2_LENGTH), Self::PairedEndInsertSize => f.write_str(PAIRED_END_INSERT_SIZE), Self::PLength { end } => ChoiceAddress::PLength { end }.fmt(f), + Self::SampleHaplotype => f.write_str(SAMPLE_HAPLOTYPE), + Self::SampleGene(segment) => ChoiceAddress::SampleGene(segment).fmt(f), + Self::SampleAlleleInSlot(segment) => { + ChoiceAddress::SampleAlleleInSlot(segment).fmt(f) + } } } } @@ -889,6 +927,13 @@ fn parse_choice_address_pattern(address: &str) -> Option { P_D5_LENGTH => Some(ChoiceAddressPattern::PLength { end: PEnd::D5 }), P_D3_LENGTH => Some(ChoiceAddressPattern::PLength { end: PEnd::D3 }), P_J5_LENGTH => Some(ChoiceAddressPattern::PLength { end: PEnd::J5 }), + SAMPLE_HAPLOTYPE => Some(ChoiceAddressPattern::SampleHaplotype), + "sample_gene.v" => Some(ChoiceAddressPattern::SampleGene(VdjSegment::V)), + "sample_gene.d" => Some(ChoiceAddressPattern::SampleGene(VdjSegment::D)), + "sample_gene.j" => Some(ChoiceAddressPattern::SampleGene(VdjSegment::J)), + "sample_allele_in_slot.v" => Some(ChoiceAddressPattern::SampleAlleleInSlot(VdjSegment::V)), + "sample_allele_in_slot.d" => Some(ChoiceAddressPattern::SampleAlleleInSlot(VdjSegment::D)), + "sample_allele_in_slot.j" => Some(ChoiceAddressPattern::SampleAlleleInSlot(VdjSegment::J)), _ => None, }; @@ -1501,5 +1546,27 @@ mod tests { assert_pinned(ChoiceAddress::PLength { end: PEnd::D5 }, "p.d_5.length"); assert_pinned(ChoiceAddress::PLength { end: PEnd::D3 }, "p.d_3.length"); assert_pinned(ChoiceAddress::PLength { end: PEnd::J5 }, "p.j_5.length"); + + // Phased genotype (genotype-modeling PR1). New top-level + // `sample_haplotype` + `sample_gene.*` + `sample_allele_in_slot.*` + // namespaces; same additive policy as receptor revision / + // paired-end — old traces don't reference these strings, no + // ADDRESS_SCHEMA_VERSION bump. + assert_pinned(ChoiceAddress::SampleHaplotype, "sample_haplotype"); + assert_pinned(ChoiceAddress::SampleGene(VdjSegment::V), "sample_gene.v"); + assert_pinned(ChoiceAddress::SampleGene(VdjSegment::D), "sample_gene.d"); + assert_pinned(ChoiceAddress::SampleGene(VdjSegment::J), "sample_gene.j"); + assert_pinned( + ChoiceAddress::SampleAlleleInSlot(VdjSegment::V), + "sample_allele_in_slot.v", + ); + assert_pinned( + ChoiceAddress::SampleAlleleInSlot(VdjSegment::D), + "sample_allele_in_slot.d", + ); + assert_pinned( + ChoiceAddress::SampleAlleleInSlot(VdjSegment::J), + "sample_allele_in_slot.j", + ); } } From 6a67f226dc7c077f147a3b0a0b38f246a0158c78 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 16:03:37 +0300 Subject: [PATCH 05/26] feat(genotype): SampleHaplotypePass (up-front viability filter + phased chromosome draw) --- engine_rs/src/passes/mod.rs | 1 + engine_rs/src/passes/sample_haplotype.rs | 234 +++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 engine_rs/src/passes/sample_haplotype.rs diff --git a/engine_rs/src/passes/mod.rs b/engine_rs/src/passes/mod.rs index a059a45..fafff30 100644 --- a/engine_rs/src/passes/mod.rs +++ b/engine_rs/src/passes/mod.rs @@ -31,6 +31,7 @@ pub(crate) mod paramsig; pub mod receptor_revision; pub mod sample_allele; pub mod sample_base; +pub mod sample_haplotype; pub mod trim; #[cfg(test)] diff --git a/engine_rs/src/passes/sample_haplotype.rs b/engine_rs/src/passes/sample_haplotype.rs new file mode 100644 index 0000000..7fd560e --- /dev/null +++ b/engine_rs/src/passes/sample_haplotype.rs @@ -0,0 +1,234 @@ +//! `SampleHaplotypePass` — phased-genotype chromosome draw. +//! +//! Runs once per rearrangement, before the per-segment gene/allele +//! passes. It picks the chromosome (0 or 1) the rearrangement will draw +//! its V/D/J from, restricted to **viable** haplotypes — those that +//! carry at least one feasible allele for every required segment under +//! the active contracts + feasibility. The chosen chromosome is recorded +//! to the trace at `sample_haplotype`; the gene/allele passes read it +//! back from there (there is no per-simulation scratch state). +use std::sync::Arc; + +use crate::address::{self, ChoiceAddress}; +use crate::contract::ChoiceContext; +use crate::dist::FilteredSampleError; +use crate::genotype::Genotype; +use crate::ir::{Segment, Simulation}; +use crate::pass::{Pass, PassContext, PassError}; +use crate::refdata::AlleleId; +use crate::rng::Rng; +use crate::trace::ChoiceValue; + +pub struct SampleHaplotypePass { + genotype: Arc, + d_required: bool, +} + +impl SampleHaplotypePass { + pub fn new(genotype: Arc, d_required: bool) -> Self { + Self { + genotype, + d_required, + } + } + + fn choice_address(&self) -> ChoiceAddress { + ChoiceAddress::SampleHaplotype + } + + /// Carried alleles on chromosome `c` for `seg` that pass the active + /// contracts + feasibility. With neither active, all carried alleles + /// are admissible. + fn feasible_alleles( + &self, + c: usize, + seg: Segment, + sim: &Simulation, + ctx: &PassContext, + ) -> Vec { + let carried = self.genotype.haplotype(c).carried_alleles(seg); + let addr = address::sample_allele_vdj(seg); + let vseg: address::VdjSegment = seg.try_into().expect("V/D/J segment"); + carried + .into_iter() + .filter(|id| { + let choice = ChoiceValue::AlleleId(id.index()); + let contract_ok = ctx.contracts.map_or(true, |k| { + k.admits_typed( + sim, + ctx.refdata, + ChoiceContext::none() + .with_address(ChoiceAddress::SampleAllele(vseg)), + &choice, + ) + .is_ok() + }); + let feasible_ok = ctx.feasibility.map_or(true, |feas| { + feas.admits(ctx.pass_index, sim, ctx.refdata, addr, &choice) + }); + contract_ok && feasible_ok + }) + .collect() + } + + fn is_viable(&self, c: usize, sim: &Simulation, ctx: &PassContext) -> bool { + if self.feasible_alleles(c, Segment::V, sim, ctx).is_empty() { + return false; + } + if self.feasible_alleles(c, Segment::J, sim, ctx).is_empty() { + return false; + } + if self.d_required && self.feasible_alleles(c, Segment::D, sim, ctx).is_empty() { + return false; + } + true + } + + fn viable_set(&self, sim: &Simulation, ctx: &PassContext) -> Vec { + (0..2).filter(|&c| self.is_viable(c, sim, ctx)).collect() + } + + fn draw_from(&self, viable: &[usize], rng: &mut Rng) -> usize { + let weights = self.genotype.chromosome_weights(); + let total: f64 = viable.iter().map(|&c| weights[c] as f64).sum(); + if total <= 0.0 { + return *viable.first().expect("viable non-empty"); + } + let mut x = rng.next_f64() * total; + for &c in viable { + x -= weights[c] as f64; + if x < 0.0 { + return c; + } + } + *viable.last().expect("viable non-empty") + } + + fn infeasible_error(&self) -> PassError { + // Surfaced as a constraint-sampling error at `sample_haplotype`; + // the message names the empty admissible support. The subject id + // is included in the address-bearing diagnostics downstream. + PassError::constraint_sampling( + "sample_haplotype", + "sample_haplotype", + FilteredSampleError::EmptyAdmissibleSupport, + ) + } +} + +impl Pass for SampleHaplotypePass { + fn name(&self) -> &str { + "sample_haplotype" + } + + fn execute(&self, sim: &Simulation, ctx: &mut PassContext) -> Simulation { + self.execute_checked(sim, ctx) + .expect("SampleHaplotypePass permissive execution must not error") + } + + fn execute_checked( + &self, + sim: &Simulation, + ctx: &mut PassContext, + ) -> Result { + // Replay: consume + revalidate the recorded chromosome. + if ctx.replay_cursor.is_some() { + let c = ctx + .replay_cursor + .as_deref_mut() + .expect("replay cursor present") + .expect_haplotype(self.choice_address()) + .map_err(|r| PassError::replay(self.name(), r))?; + let viable = self.viable_set(sim, ctx); + if !viable.contains(&(c as usize)) { + return Err(self.infeasible_error()); + } + ctx.trace + .record_choice(self.choice_address(), ChoiceValue::Haplotype(c)); + return Ok(sim.clone()); + } + + let viable = self.viable_set(sim, ctx); + if viable.is_empty() { + return Err(self.infeasible_error()); + } + let c = self.draw_from(&viable, ctx.rng); + ctx.trace + .record_choice(self.choice_address(), ChoiceValue::Haplotype(c as u8)); + Ok(sim.clone()) + } + + fn declared_choice_patterns(&self) -> Vec { + vec![address::ChoiceAddressPattern::SampleHaplotype] + } +} + +#[cfg(test)] +pub(crate) mod test_support { + use super::*; + use crate::genotype::{GeneCopy, Haplotype}; + use crate::refdata::GeneId; + + fn copy(id: u32) -> GeneCopy { + GeneCopy { + allele: AlleleId::new(id), + copies: 1, + weight: 1.0, + } + } + + /// hap0 carries V gene0 + J gene0; hap1 carries V gene0 but DELETES + /// J → only hap0 is viable (J-less hap1 can't make a rearrangement). + pub fn geno_chrom1_deletes_j() -> Genotype { + let mut h0 = Haplotype::new(); + h0.set(Segment::V, GeneId::new(0), vec![copy(0)]); + h0.set(Segment::J, GeneId::new(0), vec![copy(2)]); + let mut h1 = Haplotype::new(); + h1.set(Segment::V, GeneId::new(0), vec![copy(1)]); + // J intentionally absent on h1. + Genotype::new([h0, h1], [0.5, 0.5], Some("S1".into()), "sha256:test".into()) + } + + /// Neither haplotype carries a J gene → no viable haplotype. + pub fn geno_both_delete_j() -> Genotype { + let mut h0 = Haplotype::new(); + h0.set(Segment::V, GeneId::new(0), vec![copy(0)]); + let mut h1 = Haplotype::new(); + h1.set(Segment::V, GeneId::new(0), vec![copy(1)]); + Genotype::new([h0, h1], [0.5, 0.5], Some("S1".into()), "sha256:test".into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ir::Simulation; + use crate::pass::testing::PassRuntime; + use crate::pass::PassPlan; + + #[test] + fn draws_only_the_viable_haplotype_when_one_is_dead() { + let geno = test_support::geno_chrom1_deletes_j(); + let pass = SampleHaplotypePass::new(Arc::new(geno), false); + let mut plan = PassPlan::new(); + plan.push(Box::new(pass)); + for seed in 0..50u64 { + let outcome = PassRuntime::execute(&plan, Simulation::new(), seed); + match outcome.trace.find("sample_haplotype").unwrap().value { + ChoiceValue::Haplotype(c) => assert_eq!(c, 0, "seed {seed}"), + _ => panic!("wrong variant at sample_haplotype"), + } + } + } + + #[test] + fn errors_when_no_haplotype_viable() { + let geno = test_support::geno_both_delete_j(); + let pass = SampleHaplotypePass::new(Arc::new(geno), false); + let mut plan = PassPlan::new(); + plan.push(Box::new(pass)); + let result = + PassRuntime::execute_strict_with_context(&plan, Simulation::new(), 0, None, None); + assert!(result.is_err(), "expected genotype-infeasibility error"); + } +} From 4c0ae94e69375b5ef48f3a0a8b23ea6d0cbc3ea9 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 16:18:39 +0300 Subject: [PATCH 06/26] =?UTF-8?q?feat(genotype):=20SampleGenotypePass=20?= =?UTF-8?q?=E2=80=94=20phased=20chromosome+gene+allele=20in=20one=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the planned SampleHaplotypePass + SampleGeneAllelePass into a single pass. The runtime gives each pass a fresh per-pass trace delta, so a later pass cannot read an earlier pass's choice from ctx.trace; drawing the chromosome and all V/D/J alleles in one pass keeps the chromosome a local variable. Emits AlleleSampleSupport compile facts for V/D/J so productive_only feasibility still builds; records canonical sample_allele.{seg} + sample_gene/ sample_allele_in_slot for provenance and replay. --- engine_rs/src/passes/mod.rs | 2 +- engine_rs/src/passes/sample_genotype.rs | 478 +++++++++++++++++++++++ engine_rs/src/passes/sample_haplotype.rs | 234 ----------- 3 files changed, 479 insertions(+), 235 deletions(-) create mode 100644 engine_rs/src/passes/sample_genotype.rs delete mode 100644 engine_rs/src/passes/sample_haplotype.rs diff --git a/engine_rs/src/passes/mod.rs b/engine_rs/src/passes/mod.rs index fafff30..3b90d4b 100644 --- a/engine_rs/src/passes/mod.rs +++ b/engine_rs/src/passes/mod.rs @@ -31,7 +31,7 @@ pub(crate) mod paramsig; pub mod receptor_revision; pub mod sample_allele; pub mod sample_base; -pub mod sample_haplotype; +pub mod sample_genotype; pub mod trim; #[cfg(test)] diff --git a/engine_rs/src/passes/sample_genotype.rs b/engine_rs/src/passes/sample_genotype.rs new file mode 100644 index 0000000..e12845d --- /dev/null +++ b/engine_rs/src/passes/sample_genotype.rs @@ -0,0 +1,478 @@ +//! `SampleGenotypePass` — phased, genotype-aware V(D)J allele sampling. +//! +//! Replaces the three flat `SampleAllelePass` passes when a genotype is +//! attached. In ONE pass it: +//! 1. draws the chromosome (haplotype) once, restricted to **viable** +//! haplotypes (those carrying a feasible allele for every required +//! segment under the active contracts + feasibility), and +//! 2. for each of V/(D)/J, samples a gene present on that chromosome +//! (usage-weighted) then the allele within the gene slot +//! (single-copy slots are deterministic), assigning the slot. +//! +//! The chromosome is a local variable — there is no cross-pass state to +//! share (each pass gets a fresh per-pass trace), which is exactly why +//! haplotype + gene + allele all live in one pass. The canonical +//! `sample_allele.{seg}` choice + the slot assignment are emitted just +//! like the flat path, so every downstream pass (assemble/trim/AIRR/ +//! replay) is unchanged. Gene + within-slot choices are recorded as +//! extra addresses for provenance and replay. +use std::sync::Arc; + +use crate::address::{self, ChoiceAddress, ChoiceAddressPattern}; +use crate::assignment::AlleleInstance; +use crate::contract::ChoiceContext; +use crate::dist::FilteredSampleError; +use crate::genotype::Genotype; +use crate::ir::{Segment, Simulation, SimulationBuilder}; +use crate::pass::{AlleleIdSupport, Pass, PassCompileFact, PassContext, PassEffect, PassError}; +use crate::refdata::{AlleleId, GeneId}; +use crate::rng::Rng; +use crate::trace::ChoiceValue; + +pub struct SampleGenotypePass { + genotype: Arc, + d_required: bool, + // Per-segment gene-usage weights (empty => uniform over present genes). + usage_v: Vec<(GeneId, f64)>, + usage_d: Vec<(GeneId, f64)>, + usage_j: Vec<(GeneId, f64)>, +} + +impl SampleGenotypePass { + pub fn new( + genotype: Arc, + d_required: bool, + usage_v: Vec<(GeneId, f64)>, + usage_d: Vec<(GeneId, f64)>, + usage_j: Vec<(GeneId, f64)>, + ) -> Self { + Self { + genotype, + d_required, + usage_v, + usage_d, + usage_j, + } + } + + fn segments(&self) -> Vec { + if self.d_required { + vec![Segment::V, Segment::D, Segment::J] + } else { + vec![Segment::V, Segment::J] + } + } + + fn usage(&self, seg: Segment) -> &[(GeneId, f64)] { + match seg { + Segment::V => &self.usage_v, + Segment::D => &self.usage_d, + Segment::J => &self.usage_j, + _ => &[], + } + } + + fn usage_of(&self, seg: Segment, g: GeneId) -> f64 { + let table = self.usage(seg); + if table.is_empty() { + 1.0 + } else { + table + .iter() + .find(|(gg, _)| *gg == g) + .map(|(_, w)| *w) + .unwrap_or(1.0) + } + } + + fn vseg(seg: Segment) -> address::VdjSegment { + seg.try_into().expect("V/D/J segment") + } + + fn allele_feasible( + &self, + seg: Segment, + id: AlleleId, + sim: &Simulation, + ctx: &PassContext, + ) -> bool { + let choice = ChoiceValue::AlleleId(id.index()); + let vseg = Self::vseg(seg); + let contract_ok = ctx.contracts.map_or(true, |k| { + k.admits_typed( + sim, + ctx.refdata, + ChoiceContext::none().with_address(ChoiceAddress::SampleAllele(vseg)), + &choice, + ) + .is_ok() + }); + let feasible_ok = ctx.feasibility.map_or(true, |f| { + f.admits( + ctx.pass_index, + sim, + ctx.refdata, + address::sample_allele_vdj(seg), + &choice, + ) + }); + contract_ok && feasible_ok + } + + /// Carried alleles on chromosome `c` for `seg` that pass contracts + + /// feasibility. + fn feasible_alleles( + &self, + c: usize, + seg: Segment, + sim: &Simulation, + ctx: &PassContext, + ) -> Vec { + self.genotype + .haplotype(c) + .carried_alleles(seg) + .into_iter() + .filter(|id| self.allele_feasible(seg, *id, sim, ctx)) + .collect() + } + + fn is_viable(&self, c: usize, sim: &Simulation, ctx: &PassContext) -> bool { + self.segments() + .iter() + .all(|seg| !self.feasible_alleles(c, *seg, sim, ctx).is_empty()) + } + + fn viable_set(&self, sim: &Simulation, ctx: &PassContext) -> Vec { + (0..2).filter(|&c| self.is_viable(c, sim, ctx)).collect() + } + + fn draw_haplotype(&self, viable: &[usize], rng: &mut Rng) -> usize { + let w = self.genotype.chromosome_weights(); + let total: f64 = viable.iter().map(|&c| w[c] as f64).sum(); + if total <= 0.0 { + return *viable.first().expect("viable non-empty"); + } + let mut x = rng.next_f64() * total; + for &c in viable { + x -= w[c] as f64; + if x < 0.0 { + return c; + } + } + *viable.last().expect("viable non-empty") + } + + fn weighted_pick(items: &[(T, f64)], rng: &mut Rng) -> T { + let total: f64 = items.iter().map(|(_, w)| *w).sum(); + if total <= 0.0 { + return items.first().expect("non-empty").0; + } + let mut x = rng.next_f64() * total; + for (t, w) in items { + x -= *w; + if x < 0.0 { + return *t; + } + } + items.last().expect("non-empty").0 + } + + fn infeasible_error(&self) -> PassError { + PassError::constraint_sampling( + self.name(), + "sample_haplotype", + FilteredSampleError::EmptyAdmissibleSupport, + ) + } + + fn commit(&self, seg: Segment, sim: Simulation, id: AlleleId, ctx: &mut PassContext) -> Simulation { + let mut b = SimulationBuilder::from_simulation(sim); + if ctx.event_log_sink.is_some() { + b.attach_event_log_observer(); + } + b.assign_allele(seg, AlleleInstance::new(id)); + if let Some(sink) = ctx.event_log_sink.as_deref_mut() { + sink.extend(b.seal_event_log_observer()); + } + b.seal() + } + + /// Union of carried alleles across both haplotypes for a segment — + /// the support advertised to the feasibility/schedule analyzer. + fn union_support(&self, seg: Segment) -> Vec<(AlleleId, f64)> { + let mut ids: Vec = Vec::new(); + for c in 0..2 { + for id in self.genotype.haplotype(c).carried_alleles(seg) { + if !ids.contains(&id) { + ids.push(id); + } + } + } + ids.into_iter().map(|id| (id, 1.0)).collect() + } + + /// Live (fresh-RNG) sampling of one segment within chromosome `c`. + fn sample_segment_live( + &self, + seg: Segment, + c: usize, + sim: Simulation, + ctx: &mut PassContext, + ) -> Result { + let hap = self.genotype.haplotype(c); + let genes: Vec<(GeneId, f64)> = hap + .present_genes(seg) + .filter(|g| { + hap.slot(seg, *g) + .iter() + .any(|cp| self.allele_feasible(seg, cp.allele, &sim, ctx)) + }) + .map(|g| (g, self.usage_of(seg, g))) + .collect(); + if genes.is_empty() { + return Err(PassError::constraint_sampling( + self.name(), + address::sample_allele_vdj(seg), + FilteredSampleError::EmptyAdmissibleSupport, + )); + } + let gene = Self::weighted_pick(&genes, ctx.rng); + + let slot: Vec<(AlleleId, f64)> = hap + .slot(seg, gene) + .iter() + .filter(|cp| self.allele_feasible(seg, cp.allele, &sim, ctx)) + .map(|cp| (cp.allele, cp.weight as f64 * cp.copies as f64)) + .collect(); + let vseg = Self::vseg(seg); + let id = if slot.len() == 1 { + slot[0].0 + } else { + let chosen = Self::weighted_pick(&slot, ctx.rng); + ctx.trace.record_choice( + ChoiceAddress::SampleAlleleInSlot(vseg), + ChoiceValue::AlleleId(chosen.index()), + ); + chosen + }; + ctx.trace + .record_choice(ChoiceAddress::SampleGene(vseg), ChoiceValue::GeneId(gene.index())); + ctx.trace + .record_choice(ChoiceAddress::SampleAllele(vseg), ChoiceValue::AlleleId(id.index())); + Ok(self.commit(seg, sim, id, ctx)) + } + + /// Replay (trace-injected) sampling of one segment within `c`. + fn sample_segment_replay( + &self, + seg: Segment, + c: usize, + sim: Simulation, + ctx: &mut PassContext, + ) -> Result { + let vseg = Self::vseg(seg); + let gene_idx = ctx + .replay_cursor + .as_deref_mut() + .expect("replay cursor present") + .expect_gene_id(ChoiceAddress::SampleGene(vseg)) + .map_err(|r| PassError::replay(self.name(), r))?; + let gene = GeneId::new(gene_idx); + let slot = self.genotype.haplotype(c).slot(seg, gene); + let id = if slot.len() == 1 { + slot[0].allele + } else { + let a = ctx + .replay_cursor + .as_deref_mut() + .expect("replay cursor present") + .expect_allele_id(ChoiceAddress::SampleAlleleInSlot(vseg)) + .map_err(|r| PassError::replay(self.name(), r))?; + ctx.trace + .record_choice(ChoiceAddress::SampleAlleleInSlot(vseg), ChoiceValue::AlleleId(a)); + AlleleId::new(a) + }; + ctx.trace + .record_choice(ChoiceAddress::SampleGene(vseg), ChoiceValue::GeneId(gene_idx)); + ctx.trace + .record_choice(ChoiceAddress::SampleAllele(vseg), ChoiceValue::AlleleId(id.index())); + Ok(self.commit(seg, sim, id, ctx)) + } +} + +impl Pass for SampleGenotypePass { + fn name(&self) -> &str { + "sample_genotype" + } + + fn execute(&self, sim: &Simulation, ctx: &mut PassContext) -> Simulation { + self.execute_checked(sim, ctx) + .expect("SampleGenotypePass permissive execution must not error") + } + + fn execute_checked( + &self, + sim: &Simulation, + ctx: &mut PassContext, + ) -> Result { + // Decide the chromosome (replay consumes; live draws among viable). + let c = if ctx.replay_cursor.is_some() { + let recorded = ctx + .replay_cursor + .as_deref_mut() + .expect("replay cursor present") + .expect_haplotype(ChoiceAddress::SampleHaplotype) + .map_err(|r| PassError::replay(self.name(), r))?; + let viable = self.viable_set(sim, ctx); + if !viable.contains(&(recorded as usize)) { + return Err(self.infeasible_error()); + } + ctx.trace + .record_choice(ChoiceAddress::SampleHaplotype, ChoiceValue::Haplotype(recorded)); + recorded as usize + } else { + let viable = self.viable_set(sim, ctx); + if viable.is_empty() { + return Err(self.infeasible_error()); + } + let c = self.draw_haplotype(&viable, ctx.rng); + ctx.trace + .record_choice(ChoiceAddress::SampleHaplotype, ChoiceValue::Haplotype(c as u8)); + c + }; + + let mut current = sim.clone(); + let replaying = ctx.replay_cursor.is_some(); + for seg in self.segments() { + current = if replaying { + self.sample_segment_replay(seg, c, current, ctx)? + } else { + self.sample_segment_live(seg, c, current, ctx)? + }; + } + Ok(current) + } + + fn declared_choice_patterns(&self) -> Vec { + let mut patterns = vec![ChoiceAddressPattern::SampleHaplotype]; + for seg in self.segments() { + let vseg = Self::vseg(seg); + patterns.push(ChoiceAddressPattern::SampleGene(vseg)); + patterns.push(ChoiceAddressPattern::SampleAlleleInSlot(vseg)); + patterns.push(ChoiceAddressPattern::SampleAllele(vseg)); + } + patterns + } + + fn effects(&self) -> Vec { + self.segments() + .into_iter() + .map(PassEffect::AssignAllele) + .collect() + } + + fn compile_facts(&self) -> Vec { + self.segments() + .into_iter() + .map(|seg| PassCompileFact::AlleleSampleSupport { + segment: seg, + support: AlleleIdSupport::from_weighted_pairs(Some(self.union_support(seg))), + }) + .collect() + } +} + +#[cfg(test)] +pub(crate) mod test_support { + use super::*; + use crate::genotype::{GeneCopy, Haplotype}; + + pub fn copy(id: u32) -> GeneCopy { + GeneCopy { + allele: AlleleId::new(id), + copies: 1, + weight: 1.0, + } + } + + /// hap0 carries V (gene0 -> allele 0) + J (gene0 -> allele 100); + /// hap1 carries V but no J → only hap0 viable. + pub fn geno_chrom1_deletes_j() -> Genotype { + let mut h0 = Haplotype::new(); + h0.set(Segment::V, GeneId::new(0), vec![copy(0)]); + h0.set(Segment::J, GeneId::new(0), vec![copy(100)]); + let mut h1 = Haplotype::new(); + h1.set(Segment::V, GeneId::new(0), vec![copy(1)]); + Genotype::new([h0, h1], [0.5, 0.5], Some("S1".into()), "sha256:test".into()) + } + + /// Neither haplotype carries a J gene → no viable haplotype. + pub fn geno_both_delete_j() -> Genotype { + let mut h0 = Haplotype::new(); + h0.set(Segment::V, GeneId::new(0), vec![copy(0)]); + let mut h1 = Haplotype::new(); + h1.set(Segment::V, GeneId::new(0), vec![copy(1)]); + Genotype::new([h0, h1], [0.5, 0.5], Some("S1".into()), "sha256:test".into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pass::testing::PassRuntime; + use crate::pass::PassPlan; + + #[test] + fn draws_only_the_viable_haplotype_and_assigns_carried_alleles() { + let g = Arc::new(test_support::geno_chrom1_deletes_j()); + let pass = SampleGenotypePass::new(g, false, vec![], vec![], vec![]); + let mut plan = PassPlan::new(); + plan.push(Box::new(pass)); + for seed in 0..30u64 { + let outcome = PassRuntime::execute(&plan, Simulation::new(), seed); + match outcome.trace.find("sample_haplotype").unwrap().value { + ChoiceValue::Haplotype(c) => assert_eq!(c, 0, "seed {seed}"), + _ => panic!("wrong variant"), + } + let sim = outcome.final_simulation(); + assert_eq!( + sim.assignments.get(Segment::V).unwrap().allele_id, + AlleleId::new(0) + ); + assert_eq!( + sim.assignments.get(Segment::J).unwrap().allele_id, + AlleleId::new(100) + ); + // single-copy slots => no within-slot record + assert!(outcome.trace.find("sample_allele_in_slot.v").is_none()); + } + } + + #[test] + fn records_canonical_sample_allele_and_gene_addresses() { + let g = Arc::new(test_support::geno_chrom1_deletes_j()); + let pass = SampleGenotypePass::new(g, false, vec![], vec![], vec![]); + let mut plan = PassPlan::new(); + plan.push(Box::new(pass)); + let outcome = PassRuntime::execute(&plan, Simulation::new(), 7); + match outcome.trace.find("sample_allele.v").unwrap().value { + ChoiceValue::AlleleId(id) => assert_eq!(id, 0), + _ => panic!("expected AlleleId at sample_allele.v"), + } + match outcome.trace.find("sample_gene.v").unwrap().value { + ChoiceValue::GeneId(g) => assert_eq!(g, 0), + _ => panic!("expected GeneId at sample_gene.v"), + } + } + + #[test] + fn errors_when_no_haplotype_viable() { + let g = Arc::new(test_support::geno_both_delete_j()); + let pass = SampleGenotypePass::new(g, false, vec![], vec![], vec![]); + let mut plan = PassPlan::new(); + plan.push(Box::new(pass)); + let result = + PassRuntime::execute_strict_with_context(&plan, Simulation::new(), 0, None, None); + assert!(result.is_err(), "expected genotype-infeasibility error"); + } +} diff --git a/engine_rs/src/passes/sample_haplotype.rs b/engine_rs/src/passes/sample_haplotype.rs deleted file mode 100644 index 7fd560e..0000000 --- a/engine_rs/src/passes/sample_haplotype.rs +++ /dev/null @@ -1,234 +0,0 @@ -//! `SampleHaplotypePass` — phased-genotype chromosome draw. -//! -//! Runs once per rearrangement, before the per-segment gene/allele -//! passes. It picks the chromosome (0 or 1) the rearrangement will draw -//! its V/D/J from, restricted to **viable** haplotypes — those that -//! carry at least one feasible allele for every required segment under -//! the active contracts + feasibility. The chosen chromosome is recorded -//! to the trace at `sample_haplotype`; the gene/allele passes read it -//! back from there (there is no per-simulation scratch state). -use std::sync::Arc; - -use crate::address::{self, ChoiceAddress}; -use crate::contract::ChoiceContext; -use crate::dist::FilteredSampleError; -use crate::genotype::Genotype; -use crate::ir::{Segment, Simulation}; -use crate::pass::{Pass, PassContext, PassError}; -use crate::refdata::AlleleId; -use crate::rng::Rng; -use crate::trace::ChoiceValue; - -pub struct SampleHaplotypePass { - genotype: Arc, - d_required: bool, -} - -impl SampleHaplotypePass { - pub fn new(genotype: Arc, d_required: bool) -> Self { - Self { - genotype, - d_required, - } - } - - fn choice_address(&self) -> ChoiceAddress { - ChoiceAddress::SampleHaplotype - } - - /// Carried alleles on chromosome `c` for `seg` that pass the active - /// contracts + feasibility. With neither active, all carried alleles - /// are admissible. - fn feasible_alleles( - &self, - c: usize, - seg: Segment, - sim: &Simulation, - ctx: &PassContext, - ) -> Vec { - let carried = self.genotype.haplotype(c).carried_alleles(seg); - let addr = address::sample_allele_vdj(seg); - let vseg: address::VdjSegment = seg.try_into().expect("V/D/J segment"); - carried - .into_iter() - .filter(|id| { - let choice = ChoiceValue::AlleleId(id.index()); - let contract_ok = ctx.contracts.map_or(true, |k| { - k.admits_typed( - sim, - ctx.refdata, - ChoiceContext::none() - .with_address(ChoiceAddress::SampleAllele(vseg)), - &choice, - ) - .is_ok() - }); - let feasible_ok = ctx.feasibility.map_or(true, |feas| { - feas.admits(ctx.pass_index, sim, ctx.refdata, addr, &choice) - }); - contract_ok && feasible_ok - }) - .collect() - } - - fn is_viable(&self, c: usize, sim: &Simulation, ctx: &PassContext) -> bool { - if self.feasible_alleles(c, Segment::V, sim, ctx).is_empty() { - return false; - } - if self.feasible_alleles(c, Segment::J, sim, ctx).is_empty() { - return false; - } - if self.d_required && self.feasible_alleles(c, Segment::D, sim, ctx).is_empty() { - return false; - } - true - } - - fn viable_set(&self, sim: &Simulation, ctx: &PassContext) -> Vec { - (0..2).filter(|&c| self.is_viable(c, sim, ctx)).collect() - } - - fn draw_from(&self, viable: &[usize], rng: &mut Rng) -> usize { - let weights = self.genotype.chromosome_weights(); - let total: f64 = viable.iter().map(|&c| weights[c] as f64).sum(); - if total <= 0.0 { - return *viable.first().expect("viable non-empty"); - } - let mut x = rng.next_f64() * total; - for &c in viable { - x -= weights[c] as f64; - if x < 0.0 { - return c; - } - } - *viable.last().expect("viable non-empty") - } - - fn infeasible_error(&self) -> PassError { - // Surfaced as a constraint-sampling error at `sample_haplotype`; - // the message names the empty admissible support. The subject id - // is included in the address-bearing diagnostics downstream. - PassError::constraint_sampling( - "sample_haplotype", - "sample_haplotype", - FilteredSampleError::EmptyAdmissibleSupport, - ) - } -} - -impl Pass for SampleHaplotypePass { - fn name(&self) -> &str { - "sample_haplotype" - } - - fn execute(&self, sim: &Simulation, ctx: &mut PassContext) -> Simulation { - self.execute_checked(sim, ctx) - .expect("SampleHaplotypePass permissive execution must not error") - } - - fn execute_checked( - &self, - sim: &Simulation, - ctx: &mut PassContext, - ) -> Result { - // Replay: consume + revalidate the recorded chromosome. - if ctx.replay_cursor.is_some() { - let c = ctx - .replay_cursor - .as_deref_mut() - .expect("replay cursor present") - .expect_haplotype(self.choice_address()) - .map_err(|r| PassError::replay(self.name(), r))?; - let viable = self.viable_set(sim, ctx); - if !viable.contains(&(c as usize)) { - return Err(self.infeasible_error()); - } - ctx.trace - .record_choice(self.choice_address(), ChoiceValue::Haplotype(c)); - return Ok(sim.clone()); - } - - let viable = self.viable_set(sim, ctx); - if viable.is_empty() { - return Err(self.infeasible_error()); - } - let c = self.draw_from(&viable, ctx.rng); - ctx.trace - .record_choice(self.choice_address(), ChoiceValue::Haplotype(c as u8)); - Ok(sim.clone()) - } - - fn declared_choice_patterns(&self) -> Vec { - vec![address::ChoiceAddressPattern::SampleHaplotype] - } -} - -#[cfg(test)] -pub(crate) mod test_support { - use super::*; - use crate::genotype::{GeneCopy, Haplotype}; - use crate::refdata::GeneId; - - fn copy(id: u32) -> GeneCopy { - GeneCopy { - allele: AlleleId::new(id), - copies: 1, - weight: 1.0, - } - } - - /// hap0 carries V gene0 + J gene0; hap1 carries V gene0 but DELETES - /// J → only hap0 is viable (J-less hap1 can't make a rearrangement). - pub fn geno_chrom1_deletes_j() -> Genotype { - let mut h0 = Haplotype::new(); - h0.set(Segment::V, GeneId::new(0), vec![copy(0)]); - h0.set(Segment::J, GeneId::new(0), vec![copy(2)]); - let mut h1 = Haplotype::new(); - h1.set(Segment::V, GeneId::new(0), vec![copy(1)]); - // J intentionally absent on h1. - Genotype::new([h0, h1], [0.5, 0.5], Some("S1".into()), "sha256:test".into()) - } - - /// Neither haplotype carries a J gene → no viable haplotype. - pub fn geno_both_delete_j() -> Genotype { - let mut h0 = Haplotype::new(); - h0.set(Segment::V, GeneId::new(0), vec![copy(0)]); - let mut h1 = Haplotype::new(); - h1.set(Segment::V, GeneId::new(0), vec![copy(1)]); - Genotype::new([h0, h1], [0.5, 0.5], Some("S1".into()), "sha256:test".into()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ir::Simulation; - use crate::pass::testing::PassRuntime; - use crate::pass::PassPlan; - - #[test] - fn draws_only_the_viable_haplotype_when_one_is_dead() { - let geno = test_support::geno_chrom1_deletes_j(); - let pass = SampleHaplotypePass::new(Arc::new(geno), false); - let mut plan = PassPlan::new(); - plan.push(Box::new(pass)); - for seed in 0..50u64 { - let outcome = PassRuntime::execute(&plan, Simulation::new(), seed); - match outcome.trace.find("sample_haplotype").unwrap().value { - ChoiceValue::Haplotype(c) => assert_eq!(c, 0, "seed {seed}"), - _ => panic!("wrong variant at sample_haplotype"), - } - } - } - - #[test] - fn errors_when_no_haplotype_viable() { - let geno = test_support::geno_both_delete_j(); - let pass = SampleHaplotypePass::new(Arc::new(geno), false); - let mut plan = PassPlan::new(); - plan.push(Box::new(pass)); - let result = - PassRuntime::execute_strict_with_context(&plan, Simulation::new(), 0, None, None); - assert!(result.is_err(), "expected genotype-infeasibility error"); - } -} From 84b8aabe941f171d53098441623b537ef0d7d576 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 16:20:43 +0300 Subject: [PATCH 07/26] feat(genotype): push_genotype_recombine PyO3 plan builder (one SampleGenotypePass) --- engine_rs/src/python/plan.rs | 99 ++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/engine_rs/src/python/plan.rs b/engine_rs/src/python/plan.rs index a45ff1f..71c48fb 100644 --- a/engine_rs/src/python/plan.rs +++ b/engine_rs/src/python/plan.rs @@ -345,6 +345,105 @@ impl PyPassPlan { Ok(()) } + /// Append a single `SampleGenotypePass` that replaces the three flat + /// `SampleAllelePass` passes when a genotype is attached. The pass + /// draws the chromosome once, then V/(D)/J alleles from that + /// chromosome's carried set (phased). + /// + /// `v`/`d`/`j` are flat rows `(haplotype, allele_id, copies, weight)` + /// already resolved to this refdata's allele ids; rows are grouped by + /// the gene the allele belongs to (via the segment's `GeneIndex`). + #[pyo3(signature = (refdata, chromosome_weights, subject_id, source_hash, v, d, j, d_required))] + #[allow(clippy::too_many_arguments)] + fn push_genotype_recombine( + &mut self, + refdata: &PyRefDataConfig, + chromosome_weights: (f32, f32), + subject_id: Option, + source_hash: String, + v: Vec<(u8, u32, u8, f32)>, + d: Vec<(u8, u32, u8, f32)>, + j: Vec<(u8, u32, u8, f32)>, + d_required: bool, + ) -> PyResult<()> { + use crate::genotype::{GeneCopy, Genotype, Haplotype}; + use crate::ir::Segment; + use crate::refdata::{AlleleId, GeneId, GeneIndex}; + use std::collections::HashMap; + + let cfg = refdata.inner(); + + let build_index = |seg: Segment| -> PyResult { + let pool = cfg + .pool_for(seg) + .ok_or_else(|| PyValueError::new_err(format!("no pool for segment {:?}", seg)))?; + Ok(GeneIndex::build(pool)) + }; + let v_index = build_index(Segment::V)?; + let j_index = build_index(Segment::J)?; + let d_index = if d_required { + Some(build_index(Segment::D)?) + } else { + None + }; + + let mut haps = [Haplotype::new(), Haplotype::new()]; + let fill = |haps: &mut [Haplotype; 2], + seg: Segment, + idx: &GeneIndex, + rows: &[(u8, u32, u8, f32)]| + -> PyResult<()> { + let pool_len = cfg.pool_for(seg).map(|p| p.len() as u32).unwrap_or(0); + let mut grouped: HashMap<(u8, u32), Vec> = HashMap::new(); + for (h, aid, copies, weight) in rows { + if *h > 1 { + return Err(PyValueError::new_err(format!( + "haplotype index must be 0 or 1, got {}", + h + ))); + } + if *aid >= pool_len { + return Err(PyValueError::new_err(format!( + "{:?} allele id {} out of range (pool size {})", + seg, aid, pool_len + ))); + } + let allele = AlleleId::new(*aid); + let gene = idx.gene_of(allele); + grouped.entry((*h, gene.index())).or_default().push(GeneCopy { + allele, + copies: *copies, + weight: *weight, + }); + } + for ((h, gene_idx), copies) in grouped { + haps[h as usize].set(seg, GeneId::new(gene_idx), copies); + } + Ok(()) + }; + fill(&mut haps, Segment::V, &v_index, &v)?; + fill(&mut haps, Segment::J, &j_index, &j)?; + if let Some(di) = &d_index { + fill(&mut haps, Segment::D, di, &d)?; + } + + let genotype = std::sync::Arc::new(Genotype::new( + haps, + [chromosome_weights.0, chromosome_weights.1], + subject_id, + source_hash, + )); + self.inner_mut()? + .push(Box::new(crate::passes::sample_genotype::SampleGenotypePass::new( + genotype, + d_required, + Vec::new(), + Vec::new(), + Vec::new(), + ))); + Ok(()) + } + /// Append an `AssembleSegmentPass` for `segment`. The matching /// `SampleAllelePass` must already be earlier in the plan /// (otherwise the assembler will fail at execute time with a From fe55d8d9944aaa5e587f6e8e5c095540b71b7d2b Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 16:22:55 +0300 Subject: [PATCH 08/26] feat(genotype): Python Genotype builder (strict default, permissive fallback, export) --- src/GenAIRR/__init__.py | 1 + src/GenAIRR/genotype.py | 190 +++++++++++++++++++++++++++++++++ tests/test_genotype_builder.py | 75 +++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 src/GenAIRR/genotype.py create mode 100644 tests/test_genotype_builder.py diff --git a/src/GenAIRR/__init__.py b/src/GenAIRR/__init__.py index 9602c01..40aa089 100644 --- a/src/GenAIRR/__init__.py +++ b/src/GenAIRR/__init__.py @@ -27,6 +27,7 @@ # The simulation entry point. from .experiment import CompiledExperiment, Experiment, dataconfig_to_refdata +from .genotype import Genotype from .result import FamilyValidationReport, SimulationResult, ValidationReport from ._validation import FamilyValidationFailedError, RecordValidationFailedError diff --git a/src/GenAIRR/genotype.py b/src/GenAIRR/genotype.py new file mode 100644 index 0000000..775e3d9 --- /dev/null +++ b/src/GenAIRR/genotype.py @@ -0,0 +1,190 @@ +"""Per-individual diploid genotype builder (PR1: known reference alleles). + +A :class:`Genotype` is an editable, narrowed view of a ``DataConfig``'s +reference alleles. Attach it to an experiment with +``Experiment.with_genotype(g)`` to make V(D)J recombination +haplotype-phased: V, D and J of each rearrangement are drawn from a single +chromosome, honouring presence/absence, zygosity, and copy-number/deletion. + +Default construction (:meth:`Genotype.from_dataconfig`) is **strict**: a gene +that is used during recombination but never assigned here is an error at +attach time. Use :meth:`complete_from_reference` to fill unspecified genes +with a valid diploid state. :meth:`Genotype.permissive` is a separate, +explicitly non-diploid fallback (see its docstring). +""" +from __future__ import annotations + +from typing import Dict, List, Optional, Set, Tuple + +_SEGMENTS = ("V", "D", "J") + + +def _alleles_by_gene(cfg, segment: str) -> Dict[str, List]: + return { + "V": cfg.v_alleles, + "D": cfg.d_alleles, + "J": cfg.j_alleles, + }[segment] or {} + + +class Genotype: + """A diploid genotype over a ``DataConfig``'s reference alleles.""" + + def __init__(self, cfg, *, permissive: bool = False): + self._cfg = cfg + self._permissive = bool(permissive) + self.subject_id: Optional[str] = None # plain attribute (read anywhere) + self._chromosome_weights: Tuple[float, float] = (0.5, 0.5) + # segment -> gene -> [hap0 list[(allele, copies, weight)], hap1 ...] + self._slots: Dict[str, Dict[str, List[List[Tuple[str, int, float]]]]] = { + s: {} for s in _SEGMENTS + } + self._source_hash: str = cfg.cartridge_manifest()["hashes"]["refdata_content_hash"] + + # ── constructors ────────────────────────────────────────────── + @classmethod + def from_dataconfig(cls, cfg) -> "Genotype": + """A strict genotype: unspecified-but-used genes error at attach.""" + return cls(cfg, permissive=False) + + @classmethod + def permissive(cls, cfg) -> "Genotype": + """NOT a biological diploid genotype. A reference-wide fallback: + unspecified genes sample over ALL reference alleles WITHOUT + phasing for those genes. Use only to constrain a few genes; never + mistake this for 'complete genotype from refdata'.""" + return cls(cfg, permissive=True) + + # ── editing ─────────────────────────────────────────────────── + def with_subject(self, sid: str) -> "Genotype": + self.subject_id = str(sid) + return self + + def chromosome_weights(self, w0: float, w1: float) -> "Genotype": + if w0 < 0 or w1 < 0 or (w0 + w1) <= 0: + raise ValueError( + f"chromosome_weights must be non-negative and sum>0, got {(w0, w1)}" + ) + self._chromosome_weights = (float(w0), float(w1)) + return self + + def _check_allele(self, segment: str, gene: str, allele: str) -> None: + by_gene = _alleles_by_gene(self._cfg, segment) + if gene not in by_gene: + raise ValueError(f"{gene!r} is not a known {segment} gene in this cartridge") + names = {a.name for a in by_gene[gene]} + if allele not in names: + raise ValueError(f"{allele!r} is not a known allele of {gene!r}") + + def homozygous(self, gene: str, allele: str, segment: str = "V") -> "Genotype": + self._check_allele(segment, gene, allele) + self._slots[segment][gene] = [[(allele, 1, 1.0)], [(allele, 1, 1.0)]] + return self + + def heterozygous( + self, gene: str, allele0: str, allele1: str, segment: str = "V" + ) -> "Genotype": + self._check_allele(segment, gene, allele0) + self._check_allele(segment, gene, allele1) + self._slots[segment][gene] = [[(allele0, 1, 1.0)], [(allele1, 1, 1.0)]] + return self + + def delete_gene(self, gene: str, haplotype="both", segment: str = "V") -> "Genotype": + cur = self._slots[segment].get(gene, [[], []]) + # copy to avoid aliasing if the slot was shared + cur = [list(cur[0]), list(cur[1])] + if haplotype in ("both", 0): + cur[0] = [] + if haplotype in ("both", 1): + cur[1] = [] + self._slots[segment][gene] = cur + return self + + def duplicate_gene( + self, gene: str, alleles: List[str], haplotype: int, segment: str = "V" + ) -> "Genotype": + for a in alleles: + self._check_allele(segment, gene, a) + cur = self._slots[segment].get(gene, [[], []]) + cur = [list(cur[0]), list(cur[1])] + cur[int(haplotype)] = [(a, 1, 1.0) for a in alleles] + self._slots[segment][gene] = cur + return self + + def complete_from_reference(self, policy: str = "homozygous_common") -> "Genotype": + """Fill every UNspecified gene with a valid diploid state.""" + for seg in _SEGMENTS: + for gene, allele_objs in _alleles_by_gene(self._cfg, seg).items(): + if gene in self._slots[seg] or not allele_objs: + continue + names = [a.name for a in allele_objs] + if policy == "homozygous_common": + self.homozygous(gene, names[0], segment=seg) + elif policy == "heterozygous_first_two": + if len(names) >= 2: + self.heterozygous(gene, names[0], names[1], segment=seg) + else: + self.homozygous(gene, names[0], segment=seg) + else: + raise ValueError(f"unknown policy {policy!r}") + return self + + # ── queries / export ────────────────────────────────────────── + @property + def is_permissive(self) -> bool: + return self._permissive + + def is_specified(self, segment: str, gene: str) -> bool: + return gene in self._slots[segment] + + def carried_alleles(self, segment: str, gene: str) -> Set[str]: + out: Set[str] = set() + for hap in self._slots[segment].get(gene, [[], []]): + out.update(a for (a, _c, _w) in hap) + return out + + def to_table(self) -> List[Dict]: + rows = [] + for seg in _SEGMENTS: + for gene, haps in self._slots[seg].items(): + h0 = {a for (a, _, _) in haps[0]} + h1 = {a for (a, _, _) in haps[1]} + carried = sorted(h0 | h1) + if not carried: + zyg = "deleted" + elif h0 == h1 and len(h0) == 1: + zyg = "homozygous" + else: + zyg = "heterozygous" + rows.append( + { + "segment": seg, + "gene": gene, + "zygosity": zyg, + "haplotype_0": sorted(h0), + "haplotype_1": sorted(h1), + "permissive": self._permissive, + } + ) + return rows + + def to_tsv(self, path: str) -> None: + import csv + + rows = self.to_table() + with open(path, "w", newline="") as fh: + w = csv.writer(fh, delimiter="\t") + w.writerow( + ["segment", "gene", "zygosity", "haplotype_0", "haplotype_1", "permissive"] + ) + for r in rows: + w.writerow( + [ + r["segment"], + r["gene"], + r["zygosity"], + ";".join(r["haplotype_0"]), + ";".join(r["haplotype_1"]), + r["permissive"], + ] + ) diff --git a/tests/test_genotype_builder.py b/tests/test_genotype_builder.py new file mode 100644 index 0000000..8ab9608 --- /dev/null +++ b/tests/test_genotype_builder.py @@ -0,0 +1,75 @@ +"""Builder-level tests for GenAIRR.genotype.Genotype (PR1).""" +import pytest + +import GenAIRR as ga +import GenAIRR.data as gdata +from GenAIRR.genotype import Genotype + + +def _cfg(): + return gdata.HUMAN_IGH_OGRDB + + +def test_homozygous_then_subject_builds_diploid_genotype(): + cfg = _cfg() + v_gene = next(iter(cfg.v_alleles)) + a1 = cfg.v_alleles[v_gene][0].name + g = Genotype.from_dataconfig(cfg).homozygous(v_gene, a1).with_subject("S1") + assert g.subject_id == "S1" + assert g.carried_alleles("V", v_gene) == {a1} + + +def test_unknown_allele_name_raises(): + cfg = _cfg() + v_gene = next(iter(cfg.v_alleles)) + with pytest.raises(ValueError, match="not a known"): + Genotype.from_dataconfig(cfg).homozygous(v_gene, "IGHV-NOPE*99") + + +def test_unknown_gene_raises(): + cfg = _cfg() + with pytest.raises(ValueError, match="not a known"): + Genotype.from_dataconfig(cfg).homozygous("NOSUCHGENE", "x*01") + + +def test_strict_genotype_reports_unspecified_gene(): + cfg = _cfg() + g = Genotype.from_dataconfig(cfg) + assert g.is_specified("V", next(iter(cfg.v_alleles))) is False + + +def test_complete_from_reference_specifies_every_gene(): + cfg = _cfg() + g = Genotype.from_dataconfig(cfg).complete_from_reference() + assert all(g.is_specified("V", gene) for gene in cfg.v_alleles) + assert all(g.is_specified("J", gene) for gene in cfg.j_alleles) + + +def test_heterozygous_to_table_reports_zygosity(): + cfg = _cfg() + v_gene = next(iter(cfg.v_alleles)) + names = [a.name for a in cfg.v_alleles[v_gene]] + if len(names) < 2: + pytest.skip("need >=2 alleles for heterozygous test") + g = Genotype.from_dataconfig(cfg).heterozygous(v_gene, names[0], names[1]) + rows = [r for r in g.to_table() if r["gene"] == v_gene] + assert rows and rows[0]["zygosity"] == "heterozygous" + assert g.carried_alleles("V", v_gene) == {names[0], names[1]} + + +def test_delete_gene_one_haplotype_keeps_the_other(): + cfg = _cfg() + v_gene = next(iter(cfg.v_alleles)) + a1 = cfg.v_alleles[v_gene][0].name + g = Genotype.from_dataconfig(cfg).homozygous(v_gene, a1).delete_gene(v_gene, haplotype=1) + # haplotype 0 still carries a1; haplotype 1 deleted + rows = [r for r in g.to_table() if r["gene"] == v_gene] + assert rows[0]["haplotype_0"] == [a1] + assert rows[0]["haplotype_1"] == [] + + +def test_permissive_is_flagged(): + cfg = _cfg() + g = Genotype.permissive(cfg) + assert g.is_permissive is True + assert Genotype.from_dataconfig(cfg).is_permissive is False From a48acb25f52ebfa2ad4bbdad8dea8dba73186927 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 16:26:17 +0300 Subject: [PATCH 09/26] feat(genotype): Experiment.with_genotype + mutual-exclusion/receptor-revision/cartridge guards --- src/GenAIRR/experiment.py | 79 ++++++++++++++++++++++++++++++++++++++ tests/test_genotype_dsl.py | 61 +++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 tests/test_genotype_dsl.py diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index 88cee7a..2950917 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -429,6 +429,8 @@ class Experiment: "_metadata", "_contracts", "_allow_curatable_refdata", + "_genotype", + "_user_allele_weights_set", ) def __init__( @@ -474,6 +476,15 @@ def __init__( # catalogue (bundled mouse_igh / human_tcrb) that includes # pseudogene/ORF alleles. self._allow_curatable_refdata: bool = False + # Single-subject diploid genotype attached via ``with_genotype``. + # ``None`` => the flat (uniform/usage-weighted) allele path runs + # unchanged. When set, recombination lowers to the phased + # genotype path (one ``SampleGenotypePass``). + self._genotype = None + # True once the user passed an explicit ``*_allele_weights`` to + # ``recombine`` — distinct from cartridge-usage defaults. Used to + # enforce mutual exclusion with ``with_genotype``. + self._user_allele_weights_set: bool = False @classmethod def on(cls, source: ExperimentInput) -> "Experiment": @@ -1604,6 +1615,41 @@ def _is_tcr_refdata(self) -> bool: first_v_name = self._refdata.v_allele(0).name return first_v_name.upper().startswith("TR") + def with_genotype(self, genotype) -> "Experiment": + """Attach a single-subject diploid genotype. + + With a genotype attached, V(D)J recombination becomes + haplotype-phased: V, D and J of each rearrangement are drawn from + a single chromosome, honouring the genotype's allele + presence/absence, zygosity, and copy-number/deletion. With no + genotype, the flat (uniform / usage-weighted) path runs unchanged. + + Mutually exclusive with :meth:`restrict_alleles` and the + ``recombine(*_allele_weights=...)`` kwargs — the genotype owns + allele presence and within-gene expression. + + Raises ``ValueError`` if the genotype was built against a + different cartridge (content-hash mismatch), or if allele locks / + explicit allele weights are already set. + """ + live_hash = self._refdata.content_hash() + if genotype._source_hash != live_hash: + raise ValueError( + "genotype was built against a different cartridge (content hash " + f"{genotype._source_hash!r} != experiment {live_hash!r})" + ) + if any(v is not None for v in self._locks.values()): + raise ValueError( + "with_genotype() and restrict_alleles() are mutually exclusive" + ) + if self._user_allele_weights_set: + raise ValueError( + "with_genotype() and recombine(*_allele_weights=...) are mutually " + "exclusive: the genotype owns allele expression" + ) + self._genotype = genotype + return self + def restrict_alleles( self, *, @@ -1640,6 +1686,11 @@ def restrict_alleles( a VJ chain. - ``TypeError`` if an unexpected input shape is passed. """ + if self._genotype is not None: + raise ValueError( + "restrict_alleles() and with_genotype() are mutually exclusive: " + "a genotype already owns allele presence and within-gene expression" + ) for segment, value in (("V", v), ("D", d), ("J", j)): if value is _UNSET: continue @@ -1770,6 +1821,21 @@ def recombine( ``ValueError`` for unknown allele names or non-positive weights. """ + # Explicit allele weights conflict with an attached genotype: + # the genotype owns allele presence + within-gene expression, and + # the phased lowering ignores recombine-step weights. Reject the + # combination instead of silently dropping the weights. + if any( + w is not None + for w in (v_allele_weights, d_allele_weights, j_allele_weights) + ): + self._user_allele_weights_set = True + if self._genotype is not None: + raise ValueError( + "recombine(*_allele_weights=...) and with_genotype() are " + "mutually exclusive: the genotype owns allele expression" + ) + # VJ chains have no NP2 region — surface user mistakes loudly # instead of silently dropping the argument. if np2_lengths is not None and self._refdata.chain_type != "vdj": @@ -2541,6 +2607,19 @@ def compile(self, *, allow_curatable_refdata: Optional[bool] = None): """ if allow_curatable_refdata is None: allow_curatable_refdata = self._allow_curatable_refdata + + # Receptor revision is not supported alongside a phased genotype + # in this release: the revision pass samples a replacement V from + # its own distribution with no chromosome/carried-allele + # awareness. Reject the combination (same-haplotype revision is a + # planned follow-on). + if self._genotype is not None and any( + isinstance(s, _ReceptorRevisionStep) for s in self._steps + ): + raise ValueError( + "receptor_revision() is not supported with with_genotype() in this " + "release (the revision pass is not haplotype-aware)" + ) from dataclasses import replace as _replace # On raw RefDataConfig with default-on trim, warn at compile diff --git a/tests/test_genotype_dsl.py b/tests/test_genotype_dsl.py new file mode 100644 index 0000000..da69677 --- /dev/null +++ b/tests/test_genotype_dsl.py @@ -0,0 +1,61 @@ +"""DSL-level guards for Experiment.with_genotype (PR1).""" +import pytest + +import GenAIRR as ga +import GenAIRR.data as gdata +from GenAIRR.genotype import Genotype + + +def _cfg(): + return gdata.HUMAN_IGH_OGRDB + + +def _full_genotype(): + return Genotype.from_dataconfig(_cfg()).complete_from_reference().with_subject("S1") + + +def test_with_genotype_then_restrict_alleles_raises(): + g = _full_genotype() + v_gene = next(iter(_cfg().v_alleles)) + a1 = _cfg().v_alleles[v_gene][0].name + with pytest.raises(ValueError, match="mutually exclusive"): + ga.Experiment.on(_cfg()).with_genotype(g).restrict_alleles(v=a1) + + +def test_restrict_alleles_then_with_genotype_raises(): + g = _full_genotype() + v_gene = next(iter(_cfg().v_alleles)) + a1 = _cfg().v_alleles[v_gene][0].name + with pytest.raises(ValueError, match="mutually exclusive"): + ga.Experiment.on(_cfg()).restrict_alleles(v=a1).with_genotype(g) + + +def test_recombine_weights_then_with_genotype_raises(): + g = _full_genotype() + v_gene = next(iter(_cfg().v_alleles)) + a1 = _cfg().v_alleles[v_gene][0].name + with pytest.raises(ValueError, match="mutually exclusive"): + ga.Experiment.on(_cfg()).recombine(v_allele_weights={a1: 2.0}).with_genotype(g) + + +def test_with_genotype_then_recombine_weights_raises(): + g = _full_genotype() + v_gene = next(iter(_cfg().v_alleles)) + a1 = _cfg().v_alleles[v_gene][0].name + with pytest.raises(ValueError, match="mutually exclusive"): + ga.Experiment.on(_cfg()).with_genotype(g).recombine(v_allele_weights={a1: 2.0}) + + +def test_receptor_revision_with_genotype_raises_at_compile(): + g = _full_genotype() + exp = ga.Experiment.on(_cfg()).with_genotype(g).recombine().receptor_revision(prob=0.5) + with pytest.raises(ValueError, match="receptor_revision"): + exp.compile() + + +def test_cartridge_hash_mismatch_raises(): + # Genotype built on IGH, attached to a TCRB experiment → mismatch. + g = Genotype.from_dataconfig(_cfg()).complete_from_reference() + other = gdata.HUMAN_TCRB_IMGT + with pytest.raises(ValueError, match="different cartridge|content hash"): + ga.Experiment.on(other).with_genotype(g) From 41b305cb4b3c13f2744bb6d3f8b3806326f41743 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 16:28:48 +0300 Subject: [PATCH 10/26] feat(genotype): lower with_genotype to phased recombine (push_genotype_recombine) --- src/GenAIRR/_compile.py | 87 +++++++++++++++++++++++++++++++++-- src/GenAIRR/experiment.py | 1 + tests/test_genotype_engine.py | 38 +++++++++++++++ 3 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 tests/test_genotype_engine.py diff --git a/src/GenAIRR/_compile.py b/src/GenAIRR/_compile.py index 7f4e96a..7edcf61 100644 --- a/src/GenAIRR/_compile.py +++ b/src/GenAIRR/_compile.py @@ -173,6 +173,76 @@ def _extract_receptor_revision_prob(steps): return revision_prob, filtered +def _name_to_id(refdata, segment): + """Map allele name -> pool id for a segment against this refdata.""" + size = { + "V": refdata.v_pool_size, + "D": refdata.d_pool_size, + "J": refdata.j_pool_size, + }[segment]() + getter = { + "V": refdata.v_allele, + "D": refdata.d_allele, + "J": refdata.j_allele, + }[segment] + return {getter(i).name: i for i in range(size)} + + +def _genotype_segment_rows(genotype, refdata, segment): + """Resolve a genotype's per-haplotype slots for a segment into flat + ``(haplotype, allele_id, copies, weight)`` rows for the engine. + + Strict genotypes require every gene present in the cartridge to be + specified (call ``complete_from_reference()``); permissive genotypes + fill unspecified genes with all reference alleles single-copy on both + haplotypes (a NON-diploid fallback).""" + name_to_id = _name_to_id(refdata, segment) + cfg_by_gene = { + "V": genotype._cfg.v_alleles, + "D": genotype._cfg.d_alleles, + "J": genotype._cfg.j_alleles, + }[segment] or {} + rows = [] + for gene, allele_objs in cfg_by_gene.items(): + slot = genotype._slots[segment].get(gene) + if slot is None: + if genotype.is_permissive: + for h in (0, 1): + for a in allele_objs: + if a.name in name_to_id: + rows.append((h, name_to_id[a.name], 1, 1.0)) + continue + raise ValueError( + f"genotype is strict but {segment} gene {gene!r} is unspecified; " + f"call complete_from_reference() or specify it before with_genotype()" + ) + for h, copies in enumerate(slot): + for (allele_name, copy_count, weight) in copies: + rows.append((h, name_to_id[allele_name], int(copy_count), float(weight))) + return rows + + +def _push_genotype_recombine(genotype, plan, refdata, *, d_required): + """Push the single phased ``SampleGenotypePass`` for an attached + genotype, replacing the flat per-segment allele sampling.""" + v_rows = _genotype_segment_rows(genotype, refdata, "V") + j_rows = _genotype_segment_rows(genotype, refdata, "J") + d_rows = _genotype_segment_rows(genotype, refdata, "D") if d_required else [] + plan.push_genotype_recombine( + refdata, + ( + float(genotype._chromosome_weights[0]), + float(genotype._chromosome_weights[1]), + ), + genotype.subject_id, + genotype._source_hash, + v_rows, + d_rows, + j_rows, + d_required, + ) + + def _lower_recombine( step: _RecombineStep, plan: "_engine.PassPlan", @@ -180,6 +250,7 @@ def _lower_recombine( *, invert_d_prob=None, receptor_revision_prob=None, + genotype=None, ) -> None: chain = refdata.chain_type np1 = list(step.np1_lengths) @@ -238,8 +309,11 @@ def _lower_recombine( "receptor_revision is only valid for VDJ chains; the " "DSL boundary should have rejected this earlier." ) - plan.push_sample_allele("V", refdata, allowed_ids=v_ids, weights=v_weights) - plan.push_sample_allele("J", refdata, allowed_ids=j_ids, weights=j_weights) + if genotype is not None: + _push_genotype_recombine(genotype, plan, refdata, d_required=False) + else: + plan.push_sample_allele("V", refdata, allowed_ids=v_ids, weights=v_weights) + plan.push_sample_allele("J", refdata, allowed_ids=j_ids, weights=j_weights) if step.trim_v_3: plan.push_trim("V", "3", list(step.trim_v_3)) if step.trim_j_5: @@ -257,9 +331,12 @@ def _lower_recombine( plan.push_p_addition("J_5", p_j_5) plan.push_assemble("J") elif chain == "vdj": - plan.push_sample_allele("V", refdata, allowed_ids=v_ids, weights=v_weights) - plan.push_sample_allele("D", refdata, allowed_ids=d_ids, weights=d_weights) - plan.push_sample_allele("J", refdata, allowed_ids=j_ids, weights=j_weights) + if genotype is not None: + _push_genotype_recombine(genotype, plan, refdata, d_required=True) + else: + plan.push_sample_allele("V", refdata, allowed_ids=v_ids, weights=v_weights) + plan.push_sample_allele("D", refdata, allowed_ids=d_ids, weights=d_weights) + plan.push_sample_allele("J", refdata, allowed_ids=j_ids, weights=j_weights) if step.trim_v_3: plan.push_trim("V", "3", list(step.trim_v_3)) if step.trim_d_5: diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index 2950917..910a0aa 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -2894,6 +2894,7 @@ def _build_simulator( self._refdata, invert_d_prob=invert_d_prob, receptor_revision_prob=receptor_revision_prob, + genotype=self._genotype, ) else: lower_step(step, plan, self._refdata) diff --git a/tests/test_genotype_engine.py b/tests/test_genotype_engine.py new file mode 100644 index 0000000..87ed9ce --- /dev/null +++ b/tests/test_genotype_engine.py @@ -0,0 +1,38 @@ +"""End-to-end phased-genotype recombination tests (PR1).""" +import GenAIRR as ga +import GenAIRR.data as gdata +from GenAIRR.genotype import Genotype + + +def _cfg(): + return gdata.HUMAN_IGH_OGRDB + + +def test_phased_recombine_only_emits_carried_allele_for_overridden_gene(): + cfg = _cfg() + # Pick a V gene with >=2 alleles and carry ONLY its second allele. + v_gene = next(g for g, al in cfg.v_alleles.items() if len(al) >= 2) + names = [a.name for a in cfg.v_alleles[v_gene]] + carried = names[1] + g = ( + Genotype.from_dataconfig(cfg) + .complete_from_reference("homozygous_common") + .homozygous(v_gene, carried) + .with_subject("S1") + ) + res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records(n=300, seed=1) + seen_for_gene = { + r["v_call"] for r in res if r["v_call"].startswith(v_gene + "*") + } + # Only the carried allele of that gene may appear (never names[0]). + assert seen_for_gene <= {carried}, seen_for_gene + + +def test_phased_run_is_deterministic_under_same_seed(): + cfg = _cfg() + g = Genotype.from_dataconfig(cfg).complete_from_reference().with_subject("S1") + exp = ga.Experiment.on(cfg).with_genotype(g).recombine() + a = exp.run_records(n=40, seed=77) + b = exp.run_records(n=40, seed=77) + assert [r["v_call"] for r in a] == [r["v_call"] for r in b] + assert [r["j_call"] for r in a] == [r["j_call"] for r in b] From 7150385c5cdb178f540b1b4f7fc21c3736222a12 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 16:31:23 +0300 Subject: [PATCH 11/26] feat(genotype): stamp subject_id/haplotype provenance + result.genotypes --- src/GenAIRR/_compiled.py | 26 +++++++++++++++++++++++++- src/GenAIRR/experiment.py | 1 + src/GenAIRR/result.py | 12 +++++++++++- tests/test_genotype_engine.py | 18 ++++++++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/GenAIRR/_compiled.py b/src/GenAIRR/_compiled.py index 9c53d6d..e6684e0 100644 --- a/src/GenAIRR/_compiled.py +++ b/src/GenAIRR/_compiled.py @@ -39,7 +39,14 @@ class CompiledExperiment: time; ``run()`` only accepts execution parameters. """ - __slots__ = ("_simulator", "_refdata", "_steps", "_dataconfig", "_metadata") + __slots__ = ( + "_simulator", + "_refdata", + "_steps", + "_dataconfig", + "_metadata", + "_genotype", + ) def __init__( self, @@ -48,6 +55,7 @@ def __init__( steps: Sequence[Any] = (), dataconfig: Optional["DataConfig"] = None, metadata: Optional[Dict[str, Any]] = None, + genotype: Optional[Any] = None, ) -> None: self._simulator = simulator self._refdata = refdata @@ -58,6 +66,9 @@ def __init__( self._steps: Tuple[Any, ...] = tuple(steps) self._dataconfig = dataconfig self._metadata = dict(metadata) if metadata else {} + # Attached single-subject genotype (or None). When set, + # run_records stamps subject_id + haplotype provenance. + self._genotype = genotype @property def simulator(self) -> "_engine.CompiledSimulator": @@ -187,12 +198,25 @@ def run_records( result = SimulationResult.from_outcomes( outcomes, self._refdata, expose_provenance=expose_provenance ) + if self._genotype is not None: + self._stamp_genotype_provenance(outcomes, result) if validate_records: from ._validation import _raise_on_validation_failure _raise_on_validation_failure(result.validate_records(self._refdata)) return result + def _stamp_genotype_provenance(self, outcomes, result) -> None: + """Stamp per-record ``subject_id`` + ``haplotype`` (the chromosome + the rearrangement drew from) and expose the genotype on the + result. Used only when a genotype is attached.""" + subject = self._genotype.subject_id + for outcome, rec in zip(outcomes, result._records): + rec["subject_id"] = subject + hap = outcome.trace().find("sample_haplotype") + rec["haplotype"] = hap.value if hap is not None else None + result._genotypes = [self._genotype] + def stream( self, *, diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index 910a0aa..fe85ab3 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -2836,6 +2836,7 @@ def compile(self, *, allow_curatable_refdata: Optional[bool] = None): steps=tuple(self._steps), dataconfig=self._dataconfig, metadata=self._metadata, + genotype=self._genotype, ) def _build_simulator( diff --git a/src/GenAIRR/result.py b/src/GenAIRR/result.py index b2baabc..05f4b69 100644 --- a/src/GenAIRR/result.py +++ b/src/GenAIRR/result.py @@ -419,7 +419,7 @@ class SimulationResult: inspection — most users won't need them. """ - __slots__ = ("_records", "_outcomes", "_parents") + __slots__ = ("_records", "_outcomes", "_parents", "_genotypes") def __init__( self, @@ -428,6 +428,10 @@ def __init__( parents: Optional[Sequence] = None, ) -> None: self._records: List[Dict[str, Any]] = list(records) + # Per-subject ground-truth ``Genotype`` objects, populated by + # ``CompiledExperiment.run_records`` when a genotype is attached. + # ``None`` for non-genotype results. + self._genotypes: Optional[List] = None # ``outcomes`` is optional: callers that built records by # other means (e.g. round-tripping a TSV) don't have the # underlying Outcome objects available. @@ -495,6 +499,12 @@ def outcomes(self) -> Optional[List]: directly (e.g. loaded from a TSV).""" return self._outcomes + @property + def genotypes(self) -> Optional[List]: + """Per-subject ground-truth ``Genotype`` objects when the + experiment had a genotype attached, else ``None``.""" + return self._genotypes + @property def parents(self) -> Optional[List]: """Per-clone parent ``Outcome`` objects for clonal results; diff --git a/tests/test_genotype_engine.py b/tests/test_genotype_engine.py index 87ed9ce..9f9a18a 100644 --- a/tests/test_genotype_engine.py +++ b/tests/test_genotype_engine.py @@ -28,6 +28,24 @@ def test_phased_recombine_only_emits_carried_allele_for_overridden_gene(): assert seen_for_gene <= {carried}, seen_for_gene +def test_records_carry_subject_and_haplotype_and_result_exposes_genotype(): + cfg = _cfg() + g = Genotype.from_dataconfig(cfg).complete_from_reference().with_subject("S1") + res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records(n=20, seed=3) + assert all(r["subject_id"] == "S1" for r in res) + assert all(r["haplotype"] in (0, 1) for r in res) + assert res.genotypes is not None + assert res.genotypes[0].subject_id == "S1" + + +def test_no_genotype_result_has_no_genotypes_and_no_haplotype_field(): + cfg = _cfg() + res = ga.Experiment.on(cfg).recombine().run_records(n=10, seed=3) + assert res.genotypes is None + assert "subject_id" not in res[0] + assert "haplotype" not in res[0] + + def test_phased_run_is_deterministic_under_same_seed(): cfg = _cfg() g = Genotype.from_dataconfig(cfg).complete_from_reference().with_subject("S1") From 3ae28449ac71a51adb32182053e62d3722916c55 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 16:33:23 +0300 Subject: [PATCH 12/26] test(genotype): no-genotype output byte-identical to master baseline (sha256 pin) --- tests/test_genotype_backward_compat.py | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/test_genotype_backward_compat.py diff --git a/tests/test_genotype_backward_compat.py b/tests/test_genotype_backward_compat.py new file mode 100644 index 0000000..f7a8fc8 --- /dev/null +++ b/tests/test_genotype_backward_compat.py @@ -0,0 +1,33 @@ +"""Backward-compat: the genotype machinery is purely additive, so a +run with NO genotype attached must be byte-identical to master. + +``MASTER_DIGEST`` was captured on ``master`` (pre-genotype) for +``Experiment.on(HUMAN_IGH_OGRDB).recombine().run_records(n=100, seed=12345)`` +and re-verified identical on this branch's no-genotype path. +""" +import hashlib + +import GenAIRR as ga +import GenAIRR.data as gdata + +MASTER_DIGEST = "3be8e5ea124e1dfff256f93b5ddbb925fdb738d4d6f2eeb5763a81e5b6213460" + + +def _digest(records): + h = hashlib.sha256() + for rec in records: + h.update(repr(sorted(rec.items())).encode()) + return h.hexdigest() + + +def test_no_genotype_output_matches_master_baseline(): + res = ga.Experiment.on(gdata.HUMAN_IGH_OGRDB).recombine().run_records( + n=100, seed=12345 + ) + assert _digest(res) == MASTER_DIGEST + + +def test_no_genotype_output_is_deterministic(): + a = ga.Experiment.on(gdata.HUMAN_IGH_OGRDB).recombine().run_records(n=100, seed=12345) + b = ga.Experiment.on(gdata.HUMAN_IGH_OGRDB).recombine().run_records(n=100, seed=12345) + assert _digest(a) == _digest(b) From dd023df9cf714820ac5b6292efb8009e58d40b4e Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 16:34:16 +0300 Subject: [PATCH 13/26] test(genotype): deletion, het balance, dead-haplotype productive feasibility --- tests/test_genotype_engine.py | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_genotype_engine.py b/tests/test_genotype_engine.py index 9f9a18a..34a290f 100644 --- a/tests/test_genotype_engine.py +++ b/tests/test_genotype_engine.py @@ -54,3 +54,52 @@ def test_phased_run_is_deterministic_under_same_seed(): b = exp.run_records(n=40, seed=77) assert [r["v_call"] for r in a] == [r["v_call"] for r in b] assert [r["j_call"] for r in a] == [r["j_call"] for r in b] + + +def test_deleted_gene_is_never_sampled(): + cfg = _cfg() + drop = list(cfg.v_alleles)[1] + g = ( + Genotype.from_dataconfig(cfg) + .complete_from_reference() + .delete_gene(drop, haplotype="both", segment="V") + .with_subject("S1") + ) + res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records(n=300, seed=5) + assert all(not r["v_call"].startswith(drop + "*") for r in res) + + +def test_heterozygous_expression_is_roughly_balanced(): + cfg = _cfg() + v_gene = next(g for g, al in cfg.v_alleles.items() if len(al) >= 2) + names = [a.name for a in cfg.v_alleles[v_gene]] + a0, a1 = names[0], names[1] + g = Genotype.from_dataconfig(cfg).complete_from_reference() + for other in cfg.v_alleles: + if other != v_gene: + g.delete_gene(other, haplotype="both", segment="V") + g.heterozygous(v_gene, a0, a1).with_subject("S1") + res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records(n=400, seed=9) + calls = [r["v_call"] for r in res] + assert set(calls) <= {a0, a1}, set(calls) + frac0 = calls.count(a0) / len(calls) + assert 0.35 < frac0 < 0.65, frac0 + + +def test_one_dead_haplotype_uses_the_live_one_under_productive_only(): + cfg = _cfg() + g = Genotype.from_dataconfig(cfg).complete_from_reference() + # Delete every J gene on haplotype 1 → only haplotype 0 can produce + # a rearrangement; productive_only must still succeed via haplotype 0. + for j_gene in cfg.j_alleles: + g.delete_gene(j_gene, haplotype=1, segment="J") + g.with_subject("S1") + res = ( + ga.Experiment.on(cfg) + .productive_only() + .with_genotype(g) + .recombine() + .run_records(n=40, seed=2) + ) + assert len(res) == 40 + assert all(r["haplotype"] == 0 for r in res) From 7764c3691f96b80d0a02cbe3c6617805eb517f4d Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 16:43:56 +0300 Subject: [PATCH 14/26] fix(genotype): SampleGenotypePass.parameter_signature + SimulationResult slots pin Two contract pins reacted to the genotype additions: the production-pass parameter_signature completeness pin (SampleGenotypePass now encodes the genotype identity so distinct genotypes get distinct plan signatures) and the SimulationResult.__slots__ lockstep pin (now includes _genotypes). --- engine_rs/src/passes/sample_genotype.rs | 34 +++++++++++++++++++++++++ tests/test_clonal_parent_contract.py | 18 ++++++++----- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/engine_rs/src/passes/sample_genotype.rs b/engine_rs/src/passes/sample_genotype.rs index e12845d..406df14 100644 --- a/engine_rs/src/passes/sample_genotype.rs +++ b/engine_rs/src/passes/sample_genotype.rs @@ -305,6 +305,40 @@ impl Pass for SampleGenotypePass { "sample_genotype" } + /// Encode the attached genotype so two different genotypes produce + /// distinct plan signatures (replay-cache correctness): source + /// cartridge hash, subject, chromosome weights, and the full + /// per-haplotype/per-segment carried slots (allele id, copies, + /// weight bits). + fn parameter_signature(&self) -> String { + use std::fmt::Write; + let g = &self.genotype; + let mut s = String::new(); + let _ = write!( + s, + "geno|hash={}|subj={}|cw={:?}|d={}", + g.source_refdata_hash(), + g.subject_id().unwrap_or(""), + g.chromosome_weights(), + self.d_required, + ); + for c in 0..2usize { + let hap = g.haplotype(c); + for seg in self.segments() { + for gene in hap.present_genes(seg) { + let mut copies: Vec<(u32, u8, u32)> = hap + .slot(seg, gene) + .iter() + .map(|cp| (cp.allele.index(), cp.copies, cp.weight.to_bits())) + .collect(); + copies.sort_unstable(); + let _ = write!(s, "|h{}.{:?}.g{}={:?}", c, seg, gene.index(), copies); + } + } + } + s + } + fn execute(&self, sim: &Simulation, ctx: &mut PassContext) -> Simulation { self.execute_checked(sim, ctx) .expect("SampleGenotypePass permissive execution must not error") diff --git a/tests/test_clonal_parent_contract.py b/tests/test_clonal_parent_contract.py index 7c48014..1f85598 100644 --- a/tests/test_clonal_parent_contract.py +++ b/tests/test_clonal_parent_contract.py @@ -366,14 +366,20 @@ def test_pin_scaffold_clonal_truth_calls_stable_within_clone_under_normal_fixtur def test_pin_scaffold_simulationresult_slots_documented_for_extension() -> None: """``SimulationResult.__slots__`` is the documented attribute surface. After Slice 2 it carries - ``("_records", "_outcomes", "_parents")``. A reviewer adding - a new slot must update this pin in lockstep so the slot-list + ``("_records", "_outcomes", "_parents")``; the genotype slice adds + ``"_genotypes"`` (per-subject ground-truth genotypes). A reviewer + adding a new slot must update this pin in lockstep so the slot-list change shows up as a deliberate, audited diff.""" - assert SimulationResult.__slots__ == ("_records", "_outcomes", "_parents"), ( + assert SimulationResult.__slots__ == ( + "_records", + "_outcomes", + "_parents", + "_genotypes", + ), ( f"SimulationResult.__slots__ drifted to {SimulationResult.__slots__}; " - "expected ('_records', '_outcomes', '_parents'). Either Slice 2 " - "regressed (parent accessor removed) or a new slot landed without " - "updating the lockstep pin." + "expected ('_records', '_outcomes', '_parents', '_genotypes'). Either " + "Slice 2 regressed (parent accessor removed) or a new slot landed " + "without updating the lockstep pin." ) From f0c9a7096fa82368d5a76d06dda97359568e3c2e Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 17:17:03 +0300 Subject: [PATCH 15/26] fix(genotype): replay ordering, strict/permissive, copy dosage, intra-pass feasibility - Replay now consumes sample_gene -> sample_allele_in_slot(if multi) -> sample_allele in the same order live emits them (review #1: replay was broken). - Pass threads strict: permissive viability/sampling is presence-based and never errors; strict uses feasibility and returns structured errors (review #2). - Gene choice weight = usage * total copy dosage, so a duplicated gene recombines more often (review #5). - feasibility.rs: prefer an assignment already present in sim regardless of pass index, so J feasibility respects the V chosen earlier in the same consolidated pass (review #3). No-op for the flat one-segment-per-pass path. - Adds a Rust replay round-trip test. --- engine_rs/src/feasibility.rs | 11 + engine_rs/src/passes/sample_genotype.rs | 284 +++++++++++++++++------- 2 files changed, 210 insertions(+), 85 deletions(-) diff --git a/engine_rs/src/feasibility.rs b/engine_rs/src/feasibility.rs index f1381d2..14d3f6b 100644 --- a/engine_rs/src/feasibility.rs +++ b/engine_rs/src/feasibility.rs @@ -219,6 +219,17 @@ impl VjProductiveFeasibility { .map(|instance| vec![instance.allele_id]); } + // Even at the *same* pass index, if the segment is already + // assigned in the partial simulation, treat it as committed. The + // consolidated `SampleGenotypePass` assigns V (and D) before + // sampling J within one pass index; without this, J feasibility + // would ignore the V already chosen and over-accept. The flat + // one-segment-per-pass path never assigns another segment at the + // same index, so this is a no-op there. + if let Some(instance) = sim.assignments.get(segment) { + return Some(vec![instance.allele_id]); + } + Some(domain.values.clone()) } diff --git a/engine_rs/src/passes/sample_genotype.rs b/engine_rs/src/passes/sample_genotype.rs index 406df14..7c98a89 100644 --- a/engine_rs/src/passes/sample_genotype.rs +++ b/engine_rs/src/passes/sample_genotype.rs @@ -119,31 +119,37 @@ impl SampleGenotypePass { contract_ok && feasible_ok } - /// Carried alleles on chromosome `c` for `seg` that pass contracts + - /// feasibility. - fn feasible_alleles( + /// Carried alleles on chromosome `c` for `seg`. In `strict` mode they + /// are filtered by the active contracts + feasibility; in permissive + /// mode all carried alleles are admissible (mirrors the documented + /// permissive exception of `SampleAllelePass` — a permissive run must + /// not error, so feasibility is advisory only). + fn admissible_alleles( &self, c: usize, seg: Segment, sim: &Simulation, ctx: &PassContext, + strict: bool, ) -> Vec { - self.genotype - .haplotype(c) - .carried_alleles(seg) + let carried = self.genotype.haplotype(c).carried_alleles(seg); + if !strict { + return carried; + } + carried .into_iter() .filter(|id| self.allele_feasible(seg, *id, sim, ctx)) .collect() } - fn is_viable(&self, c: usize, sim: &Simulation, ctx: &PassContext) -> bool { + fn is_viable(&self, c: usize, sim: &Simulation, ctx: &PassContext, strict: bool) -> bool { self.segments() .iter() - .all(|seg| !self.feasible_alleles(c, *seg, sim, ctx).is_empty()) + .all(|seg| !self.admissible_alleles(c, *seg, sim, ctx, strict).is_empty()) } - fn viable_set(&self, sim: &Simulation, ctx: &PassContext) -> Vec { - (0..2).filter(|&c| self.is_viable(c, sim, ctx)).collect() + fn viable_set(&self, sim: &Simulation, ctx: &PassContext, strict: bool) -> Vec { + (0..2).filter(|&c| self.is_viable(c, sim, ctx, strict)).collect() } fn draw_haplotype(&self, viable: &[usize], rng: &mut Rng) -> usize { @@ -211,24 +217,53 @@ impl SampleGenotypePass { ids.into_iter().map(|id| (id, 1.0)).collect() } + /// Candidate `(allele, mass)` copies in a gene slot on chromosome + /// `c`. `mass = weight * copies` (copy-number dosage). In `strict` + /// mode copies are filtered by contracts + feasibility; in permissive + /// mode all copies are admissible. + fn slot_candidates( + &self, + seg: Segment, + gene: GeneId, + c: usize, + sim: &Simulation, + ctx: &PassContext, + strict: bool, + ) -> Vec<(AlleleId, f64)> { + self.genotype + .haplotype(c) + .slot(seg, gene) + .iter() + .filter(|cp| !strict || self.allele_feasible(seg, cp.allele, sim, ctx)) + .map(|cp| (cp.allele, cp.weight as f64 * cp.copies as f64)) + .collect() + } + /// Live (fresh-RNG) sampling of one segment within chromosome `c`. + /// Records in canonical order: `sample_gene` → `sample_allele_in_slot` + /// (only when the gene slot has >1 raw copy) → `sample_allele`. fn sample_segment_live( &self, seg: Segment, c: usize, sim: Simulation, ctx: &mut PassContext, + strict: bool, ) -> Result { let hap = self.genotype.haplotype(c); - let genes: Vec<(GeneId, f64)> = hap - .present_genes(seg) - .filter(|g| { - hap.slot(seg, *g) - .iter() - .any(|cp| self.allele_feasible(seg, cp.allele, &sim, ctx)) - }) - .map(|g| (g, self.usage_of(seg, g))) - .collect(); + let vseg = Self::vseg(seg); + // Genes present on this chromosome that have >=1 admissible copy, + // weighted by gene usage * total copy dosage (so a duplicated + // gene recombines more often). + let mut genes: Vec<(GeneId, f64)> = Vec::new(); + for g in hap.present_genes(seg) { + let cands = self.slot_candidates(seg, g, c, &sim, ctx, strict); + if cands.is_empty() { + continue; + } + let dosage: f64 = cands.iter().map(|(_, m)| *m).sum(); + genes.push((g, self.usage_of(seg, g) * dosage)); + } if genes.is_empty() { return Err(PassError::constraint_sampling( self.name(), @@ -237,32 +272,31 @@ impl SampleGenotypePass { )); } let gene = Self::weighted_pick(&genes, ctx.rng); - - let slot: Vec<(AlleleId, f64)> = hap - .slot(seg, gene) - .iter() - .filter(|cp| self.allele_feasible(seg, cp.allele, &sim, ctx)) - .map(|cp| (cp.allele, cp.weight as f64 * cp.copies as f64)) - .collect(); - let vseg = Self::vseg(seg); - let id = if slot.len() == 1 { - slot[0].0 - } else { - let chosen = Self::weighted_pick(&slot, ctx.rng); + let cands = self.slot_candidates(seg, gene, c, &sim, ctx, strict); + let id = Self::weighted_pick(&cands, ctx.rng); + + // "Multi-copy slot" is decided by the RAW slot length (genotype + // structure), independent of feasibility filtering, so replay can + // reconstruct whether a within-slot record exists from the + // genotype alone. + let multi = hap.slot(seg, gene).len() > 1; + ctx.trace + .record_choice(ChoiceAddress::SampleGene(vseg), ChoiceValue::GeneId(gene.index())); + if multi { ctx.trace.record_choice( ChoiceAddress::SampleAlleleInSlot(vseg), - ChoiceValue::AlleleId(chosen.index()), + ChoiceValue::AlleleId(id.index()), ); - chosen - }; - ctx.trace - .record_choice(ChoiceAddress::SampleGene(vseg), ChoiceValue::GeneId(gene.index())); + } ctx.trace .record_choice(ChoiceAddress::SampleAllele(vseg), ChoiceValue::AlleleId(id.index())); Ok(self.commit(seg, sim, id, ctx)) } /// Replay (trace-injected) sampling of one segment within `c`. + /// Consumes records in the same order live emits them — `sample_gene`, + /// then `sample_allele_in_slot` (iff the raw slot has >1 copy), then + /// the canonical `sample_allele` (the assigned id, source of truth). fn sample_segment_replay( &self, seg: Segment, @@ -278,26 +312,83 @@ impl SampleGenotypePass { .expect_gene_id(ChoiceAddress::SampleGene(vseg)) .map_err(|r| PassError::replay(self.name(), r))?; let gene = GeneId::new(gene_idx); - let slot = self.genotype.haplotype(c).slot(seg, gene); - let id = if slot.len() == 1 { - slot[0].allele + let multi = self.genotype.haplotype(c).slot(seg, gene).len() > 1; + let slot_recorded = if multi { + Some( + ctx.replay_cursor + .as_deref_mut() + .expect("replay cursor present") + .expect_allele_id(ChoiceAddress::SampleAlleleInSlot(vseg)) + .map_err(|r| PassError::replay(self.name(), r))?, + ) } else { - let a = ctx - .replay_cursor - .as_deref_mut() - .expect("replay cursor present") - .expect_allele_id(ChoiceAddress::SampleAlleleInSlot(vseg)) - .map_err(|r| PassError::replay(self.name(), r))?; - ctx.trace - .record_choice(ChoiceAddress::SampleAlleleInSlot(vseg), ChoiceValue::AlleleId(a)); - AlleleId::new(a) + None }; + let allele = ctx + .replay_cursor + .as_deref_mut() + .expect("replay cursor present") + .expect_allele_id(ChoiceAddress::SampleAllele(vseg)) + .map_err(|r| PassError::replay(self.name(), r))?; + let id = AlleleId::new(allele); + ctx.trace .record_choice(ChoiceAddress::SampleGene(vseg), ChoiceValue::GeneId(gene_idx)); + if let Some(slot_id) = slot_recorded { + ctx.trace + .record_choice(ChoiceAddress::SampleAlleleInSlot(vseg), ChoiceValue::AlleleId(slot_id)); + } ctx.trace .record_choice(ChoiceAddress::SampleAllele(vseg), ChoiceValue::AlleleId(id.index())); Ok(self.commit(seg, sim, id, ctx)) } + + /// Shared execute body. `strict` selects feasibility-filtered + /// viability/sampling (and structured errors) vs presence-based + /// permissive sampling (feasibility advisory, never errors given a + /// complete haplotype). + fn run( + &self, + sim: &Simulation, + ctx: &mut PassContext, + strict: bool, + ) -> Result { + let replaying = ctx.replay_cursor.is_some(); + let c = if replaying { + let recorded = ctx + .replay_cursor + .as_deref_mut() + .expect("replay cursor present") + .expect_haplotype(ChoiceAddress::SampleHaplotype) + .map_err(|r| PassError::replay(self.name(), r))?; + let viable = self.viable_set(sim, ctx, strict); + if !viable.contains(&(recorded as usize)) { + return Err(self.infeasible_error()); + } + ctx.trace + .record_choice(ChoiceAddress::SampleHaplotype, ChoiceValue::Haplotype(recorded)); + recorded as usize + } else { + let viable = self.viable_set(sim, ctx, strict); + if viable.is_empty() { + return Err(self.infeasible_error()); + } + let c = self.draw_haplotype(&viable, ctx.rng); + ctx.trace + .record_choice(ChoiceAddress::SampleHaplotype, ChoiceValue::Haplotype(c as u8)); + c + }; + + let mut current = sim.clone(); + for seg in self.segments() { + current = if replaying { + self.sample_segment_replay(seg, c, current, ctx)? + } else { + self.sample_segment_live(seg, c, current, ctx, strict)? + }; + } + Ok(current) + } } impl Pass for SampleGenotypePass { @@ -340,7 +431,10 @@ impl Pass for SampleGenotypePass { } fn execute(&self, sim: &Simulation, ctx: &mut PassContext) -> Simulation { - self.execute_checked(sim, ctx) + // Permissive: viability is presence-based (feasibility advisory), + // so a genotype with >=1 complete haplotype (guaranteed by the + // compile-time presence check) never errors here. + self.run(sim, ctx, false) .expect("SampleGenotypePass permissive execution must not error") } @@ -349,42 +443,7 @@ impl Pass for SampleGenotypePass { sim: &Simulation, ctx: &mut PassContext, ) -> Result { - // Decide the chromosome (replay consumes; live draws among viable). - let c = if ctx.replay_cursor.is_some() { - let recorded = ctx - .replay_cursor - .as_deref_mut() - .expect("replay cursor present") - .expect_haplotype(ChoiceAddress::SampleHaplotype) - .map_err(|r| PassError::replay(self.name(), r))?; - let viable = self.viable_set(sim, ctx); - if !viable.contains(&(recorded as usize)) { - return Err(self.infeasible_error()); - } - ctx.trace - .record_choice(ChoiceAddress::SampleHaplotype, ChoiceValue::Haplotype(recorded)); - recorded as usize - } else { - let viable = self.viable_set(sim, ctx); - if viable.is_empty() { - return Err(self.infeasible_error()); - } - let c = self.draw_haplotype(&viable, ctx.rng); - ctx.trace - .record_choice(ChoiceAddress::SampleHaplotype, ChoiceValue::Haplotype(c as u8)); - c - }; - - let mut current = sim.clone(); - let replaying = ctx.replay_cursor.is_some(); - for seg in self.segments() { - current = if replaying { - self.sample_segment_replay(seg, c, current, ctx)? - } else { - self.sample_segment_live(seg, c, current, ctx)? - }; - } - Ok(current) + self.run(sim, ctx, true) } fn declared_choice_patterns(&self) -> Vec { @@ -509,4 +568,59 @@ mod tests { PassRuntime::execute_strict_with_context(&plan, Simulation::new(), 0, None, None); assert!(result.is_err(), "expected genotype-infeasibility error"); } + + #[test] + fn replay_reproduces_live_phased_choices() { + use crate::pass::PassContext; + use crate::replay::TraceCursor; + use crate::rng::Rng; + use crate::trace::Trace; + + let g = Arc::new(test_support::geno_chrom1_deletes_j()); + let mut plan = PassPlan::new(); + plan.push(Box::new(SampleGenotypePass::new( + g.clone(), + false, + vec![], + vec![], + vec![], + ))); + // Live run — capture the full trace (haplotype + gene + allele). + let live = PassRuntime::execute(&plan, Simulation::new(), 13); + let records: Vec<_> = live.trace.choices().to_vec(); + let live_sim = live.final_simulation(); + let live_v = live_sim.assignments.get(Segment::V).unwrap().allele_id; + let live_j = live_sim.assignments.get(Segment::J).unwrap().allele_id; + + // Replay the captured trace through a fresh pass instance. + let replay_pass = SampleGenotypePass::new(g, false, vec![], vec![], vec![]); + let mut cursor = TraceCursor::from_owned(records); + let mut trace = Trace::new(); + let mut rng = Rng::new(999); + let sim = Simulation::new(); + let result; + { + let mut ctx = PassContext { + trace: &mut trace, + rng: &mut rng, + pass_index: 0, + refdata: None, + contracts: None, + feasibility: None, + reference_index: None, + replay_cursor: Some(&mut cursor), + event_log_sink: None, + }; + result = replay_pass.run(&sim, &mut ctx, true); + } + let replayed = result.expect("genotype replay must succeed"); + assert_eq!( + replayed.assignments.get(Segment::V).unwrap().allele_id, + live_v + ); + assert_eq!( + replayed.assignments.get(Segment::J).unwrap().allele_id, + live_j + ); + } } From 1e3aeaa4dcdb834273af57fefafe9e64874e74d8 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 17:19:54 +0300 Subject: [PATCH 16/26] fix(genotype): wire cartridge gene usage + compile-time complete-haplotype check - push_genotype_recombine accepts pool-aligned allele weights and aggregates them to gene-level usage, so gene choice follows the cartridge allele-usage model instead of uniform (review #4). Lowering passes step.weights_{v,d,j}. - compile-time presence check: reject a genotype with no complete haplotype (every chromosome missing a required segment) instead of panicking at run (review #2 cross-haplotype case). - genotype identity tests assert truth_*_call (evidence v_call can be ambiguous). --- engine_rs/src/python/plan.rs | 40 +++++++++++++++++++++++++++++++---- src/GenAIRR/_compile.py | 36 +++++++++++++++++++++++++++---- tests/test_genotype_engine.py | 20 ++++++++++++------ 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/engine_rs/src/python/plan.rs b/engine_rs/src/python/plan.rs index 71c48fb..9886a56 100644 --- a/engine_rs/src/python/plan.rs +++ b/engine_rs/src/python/plan.rs @@ -353,7 +353,12 @@ impl PyPassPlan { /// `v`/`d`/`j` are flat rows `(haplotype, allele_id, copies, weight)` /// already resolved to this refdata's allele ids; rows are grouped by /// the gene the allele belongs to (via the segment's `GeneIndex`). - #[pyo3(signature = (refdata, chromosome_weights, subject_id, source_hash, v, d, j, d_required))] + /// + /// `*_weights` are optional pool-aligned allele-usage weight vectors + /// (from the cartridge's allele-usage model). They are aggregated to + /// gene-level usage (sum of an allele's weights per gene) so gene + /// choice reflects empirical usage instead of being uniform. + #[pyo3(signature = (refdata, chromosome_weights, subject_id, source_hash, v, d, j, d_required, v_weights=None, d_weights=None, j_weights=None))] #[allow(clippy::too_many_arguments)] fn push_genotype_recombine( &mut self, @@ -365,6 +370,9 @@ impl PyPassPlan { d: Vec<(u8, u32, u8, f32)>, j: Vec<(u8, u32, u8, f32)>, d_required: bool, + v_weights: Option>, + d_weights: Option>, + j_weights: Option>, ) -> PyResult<()> { use crate::genotype::{GeneCopy, Genotype, Haplotype}; use crate::ir::Segment; @@ -387,6 +395,30 @@ impl PyPassPlan { None }; + // Aggregate pool-aligned allele weights to gene-level usage. + let gene_usage = |idx: &GeneIndex, weights: &Option>| -> Vec<(GeneId, f64)> { + match weights { + None => Vec::new(), + Some(w) => idx + .genes() + .map(|(g, _)| { + let mass: f64 = idx + .alleles_of(g) + .iter() + .map(|a| w.get(a.as_usize()).copied().unwrap_or(0.0)) + .sum(); + (g, mass) + }) + .collect(), + } + }; + let usage_v = gene_usage(&v_index, &v_weights); + let usage_j = gene_usage(&j_index, &j_weights); + let usage_d = match &d_index { + Some(di) => gene_usage(di, &d_weights), + None => Vec::new(), + }; + let mut haps = [Haplotype::new(), Haplotype::new()]; let fill = |haps: &mut [Haplotype; 2], seg: Segment, @@ -437,9 +469,9 @@ impl PyPassPlan { .push(Box::new(crate::passes::sample_genotype::SampleGenotypePass::new( genotype, d_required, - Vec::new(), - Vec::new(), - Vec::new(), + usage_v, + usage_d, + usage_j, ))); Ok(()) } diff --git a/src/GenAIRR/_compile.py b/src/GenAIRR/_compile.py index 7edcf61..75d29b5 100644 --- a/src/GenAIRR/_compile.py +++ b/src/GenAIRR/_compile.py @@ -222,12 +222,37 @@ def _genotype_segment_rows(genotype, refdata, segment): return rows -def _push_genotype_recombine(genotype, plan, refdata, *, d_required): +def _genotype_presence_ok(v_rows, d_rows, j_rows, d_required): + """True iff at least one chromosome carries every required segment — + i.e. a phased rearrangement is possible. A genotype where (say) hap0 + has V-only and hap1 has J-only has non-empty union support but no + viable haplotype, which would otherwise panic at runtime.""" + def has(rows, h): + return any(r[0] == h for r in rows) + for h in (0, 1): + if has(v_rows, h) and has(j_rows, h) and (not d_required or has(d_rows, h)): + return True + return False + + +def _push_genotype_recombine(genotype, step, plan, refdata, *, d_required): """Push the single phased ``SampleGenotypePass`` for an attached - genotype, replacing the flat per-segment allele sampling.""" + genotype, replacing the flat per-segment allele sampling. Cartridge + allele-usage weights (resolved onto ``step``) are passed through and + aggregated to gene-level usage by the engine.""" v_rows = _genotype_segment_rows(genotype, refdata, "V") j_rows = _genotype_segment_rows(genotype, refdata, "J") d_rows = _genotype_segment_rows(genotype, refdata, "D") if d_required else [] + if not _genotype_presence_ok(v_rows, d_rows, j_rows, d_required): + raise ValueError( + "genotype has no complete haplotype: every chromosome is missing at " + "least one of the required segments (V/" + + ("D/" if d_required else "") + + "J), so no phased rearrangement is possible" + ) + v_weights = list(step.weights_v) if step.weights_v is not None else None + d_weights = list(step.weights_d) if step.weights_d is not None else None + j_weights = list(step.weights_j) if step.weights_j is not None else None plan.push_genotype_recombine( refdata, ( @@ -240,6 +265,9 @@ def _push_genotype_recombine(genotype, plan, refdata, *, d_required): d_rows, j_rows, d_required, + v_weights, + d_weights, + j_weights, ) @@ -310,7 +338,7 @@ def _lower_recombine( "DSL boundary should have rejected this earlier." ) if genotype is not None: - _push_genotype_recombine(genotype, plan, refdata, d_required=False) + _push_genotype_recombine(genotype, step, plan, refdata, d_required=False) else: plan.push_sample_allele("V", refdata, allowed_ids=v_ids, weights=v_weights) plan.push_sample_allele("J", refdata, allowed_ids=j_ids, weights=j_weights) @@ -332,7 +360,7 @@ def _lower_recombine( plan.push_assemble("J") elif chain == "vdj": if genotype is not None: - _push_genotype_recombine(genotype, plan, refdata, d_required=True) + _push_genotype_recombine(genotype, step, plan, refdata, d_required=True) else: plan.push_sample_allele("V", refdata, allowed_ids=v_ids, weights=v_weights) plan.push_sample_allele("D", refdata, allowed_ids=d_ids, weights=d_weights) diff --git a/tests/test_genotype_engine.py b/tests/test_genotype_engine.py index 34a290f..8c1fd7d 100644 --- a/tests/test_genotype_engine.py +++ b/tests/test_genotype_engine.py @@ -20,9 +20,13 @@ def test_phased_recombine_only_emits_carried_allele_for_overridden_gene(): .homozygous(v_gene, carried) .with_subject("S1") ) - res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records(n=300, seed=1) + res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records( + n=300, seed=1, expose_provenance=True + ) + # Use the ground-truth call (truth_v_call) — the evidence-based v_call + # can be ambiguous (comma-joined) between similar alleles. seen_for_gene = { - r["v_call"] for r in res if r["v_call"].startswith(v_gene + "*") + r["truth_v_call"] for r in res if r["truth_v_call"].startswith(v_gene + "*") } # Only the carried allele of that gene may appear (never names[0]). assert seen_for_gene <= {carried}, seen_for_gene @@ -65,8 +69,10 @@ def test_deleted_gene_is_never_sampled(): .delete_gene(drop, haplotype="both", segment="V") .with_subject("S1") ) - res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records(n=300, seed=5) - assert all(not r["v_call"].startswith(drop + "*") for r in res) + res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records( + n=300, seed=5, expose_provenance=True + ) + assert all(not r["truth_v_call"].startswith(drop + "*") for r in res) def test_heterozygous_expression_is_roughly_balanced(): @@ -79,8 +85,10 @@ def test_heterozygous_expression_is_roughly_balanced(): if other != v_gene: g.delete_gene(other, haplotype="both", segment="V") g.heterozygous(v_gene, a0, a1).with_subject("S1") - res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records(n=400, seed=9) - calls = [r["v_call"] for r in res] + res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records( + n=400, seed=9, expose_provenance=True + ) + calls = [r["truth_v_call"] for r in res] assert set(calls) <= {a0, a1}, set(calls) frac0 = calls.count(a0) / len(calls) assert 0.35 < frac0 < 0.65, frac0 From e166923521c6c808314cfe4c5fad542884831ff5 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 17:23:10 +0300 Subject: [PATCH 17/26] fix(genotype): builder/DSL hardening from review (#6-#11) - delete_gene(haplotype=0|1) on an unspecified gene now raises (was a hemizygosity footgun that also suppressed complete_from_reference) (#6). - to_table reports hemizygous/deleted zygosity and per-haplotype allele:copies:weight detail + subject_id; to_tsv enriched (#7). - with_genotype snapshots the builder so later edits can't desync result.genotypes from the compiled engine genotype (#8). - reject with_genotype + expand_clones/clonal_lineage/clonal_repertoire at compile rather than silently dropping provenance (#9). - chromosome_weights rejects NaN/inf (#10). - rename complete_from_reference policy to 'homozygous_first_reference' (it uses the first cartridge allele, not a frequency-common one) (#11). --- src/GenAIRR/experiment.py | 16 ++++- src/GenAIRR/genotype.py | 107 +++++++++++++++++++++++++++------ tests/test_genotype_builder.py | 38 ++++++++++++ tests/test_genotype_dsl.py | 12 ++++ tests/test_genotype_engine.py | 2 +- 5 files changed, 155 insertions(+), 20 deletions(-) diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index fe85ab3..11d070f 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -1647,7 +1647,10 @@ def with_genotype(self, genotype) -> "Experiment": "with_genotype() and recombine(*_allele_weights=...) are mutually " "exclusive: the genotype owns allele expression" ) - self._genotype = genotype + # Snapshot the (mutable) builder so later edits to ``genotype`` + # cannot desync the compiled engine genotype from + # ``result.genotypes`` (review #8). + self._genotype = genotype._snapshot() return self def restrict_alleles( @@ -2620,6 +2623,17 @@ def compile(self, *, allow_curatable_refdata: Optional[bool] = None): "receptor_revision() is not supported with with_genotype() in this " "release (the revision pass is not haplotype-aware)" ) + + # Genotype provenance (subject_id / haplotype / result.genotypes) + # is only threaded through the plain compiled path, not the + # clonal/lineage/repertoire forked classes. Reject the + # combination rather than silently dropping provenance (review + # #9); genotype + clonal cohorts are a planned follow-on. + if self._genotype is not None and self._has_clonal_fork(): + raise ValueError( + "with_genotype() is not supported together with expand_clones() / " + "clonal_lineage() / clonal_repertoire() in this release" + ) from dataclasses import replace as _replace # On raw RefDataConfig with default-on trim, warn at compile diff --git a/src/GenAIRR/genotype.py b/src/GenAIRR/genotype.py index 775e3d9..09314e4 100644 --- a/src/GenAIRR/genotype.py +++ b/src/GenAIRR/genotype.py @@ -61,6 +61,12 @@ def with_subject(self, sid: str) -> "Genotype": return self def chromosome_weights(self, w0: float, w1: float) -> "Genotype": + import math + + if not (math.isfinite(w0) and math.isfinite(w1)): + raise ValueError( + f"chromosome_weights must be finite, got {(w0, w1)}" + ) if w0 < 0 or w1 < 0 or (w0 + w1) <= 0: raise ValueError( f"chromosome_weights must be non-negative and sum>0, got {(w0, w1)}" @@ -90,6 +96,17 @@ def heterozygous( return self def delete_gene(self, gene: str, haplotype="both", segment: str = "V") -> "Genotype": + # One-haplotype (hemizygous) deletion requires the gene to be + # specified first, otherwise the *other* haplotype would also be + # empty — silently producing a full deletion that + # complete_from_reference() then skips. Full ("both") deletion of + # an unspecified gene is fine. + if haplotype != "both" and gene not in self._slots[segment]: + raise ValueError( + f"specify {segment} gene {gene!r} (homozygous/heterozygous) before " + f"deleting one haplotype; deleting a single haplotype of an " + f"unspecified gene would delete both" + ) cur = self._slots[segment].get(gene, [[], []]) # copy to avoid aliasing if the slot was shared cur = [list(cur[0]), list(cur[1])] @@ -111,14 +128,25 @@ def duplicate_gene( self._slots[segment][gene] = cur return self - def complete_from_reference(self, policy: str = "homozygous_common") -> "Genotype": - """Fill every UNspecified gene with a valid diploid state.""" + def complete_from_reference( + self, policy: str = "homozygous_first_reference" + ) -> "Genotype": + """Fill every UNspecified gene with a valid diploid state. + + ``policy``: + - ``"homozygous_first_reference"`` (default): each unspecified + gene becomes homozygous for its **first cartridge allele** (NOT + a population-frequency-common allele — there is no frequency + prior in PR1; the name says exactly what it does). + - ``"heterozygous_first_two"``: first two cartridge alleles, one + per haplotype (homozygous if the gene has a single allele). + """ for seg in _SEGMENTS: for gene, allele_objs in _alleles_by_gene(self._cfg, seg).items(): if gene in self._slots[seg] or not allele_objs: continue names = [a.name for a in allele_objs] - if policy == "homozygous_common": + if policy == "homozygous_first_reference": self.homozygous(gene, names[0], segment=seg) elif policy == "heterozygous_first_two": if len(names) >= 2: @@ -129,6 +157,24 @@ def complete_from_reference(self, policy: str = "homozygous_common") -> "Genotyp raise ValueError(f"unknown policy {policy!r}") return self + # ── snapshot ────────────────────────────────────────────────── + def _snapshot(self) -> "Genotype": + """Return an independent copy for attachment to an experiment, so + that mutating the builder after ``with_genotype()``/``compile()`` + cannot desync ``result.genotypes`` from the compiled engine + genotype. Shares the (immutable) cartridge reference; deep-copies + the editable slot state.""" + import copy as _copy + + g = Genotype.__new__(Genotype) + g._cfg = self._cfg + g._permissive = self._permissive + g.subject_id = self.subject_id + g._chromosome_weights = self._chromosome_weights + g._slots = _copy.deepcopy(self._slots) + g._source_hash = self._source_hash + return g + # ── queries / export ────────────────────────────────────────── @property def is_permissive(self) -> bool: @@ -143,26 +189,38 @@ def carried_alleles(self, segment: str, gene: str) -> Set[str]: out.update(a for (a, _c, _w) in hap) return out + @staticmethod + def _zygosity(h0: List, h1: List) -> str: + s0 = {a for (a, _, _) in h0} + s1 = {a for (a, _, _) in h1} + if not s0 and not s1: + return "deleted" + if bool(s0) != bool(s1): # exactly one haplotype carries the gene + return "hemizygous" + if s0 == s1 and len(s0) == 1: + return "homozygous" + return "heterozygous" + def to_table(self) -> List[Dict]: + """One row per (segment, gene) with full diploid truth: zygosity + (incl. ``hemizygous`` / ``deleted``), the carried alleles per + haplotype, and per-haplotype copy/weight detail. Suitable as a + ground-truth genotype table for inference benchmarks.""" rows = [] for seg in _SEGMENTS: for gene, haps in self._slots[seg].items(): - h0 = {a for (a, _, _) in haps[0]} - h1 = {a for (a, _, _) in haps[1]} - carried = sorted(h0 | h1) - if not carried: - zyg = "deleted" - elif h0 == h1 and len(h0) == 1: - zyg = "homozygous" - else: - zyg = "heterozygous" + h0, h1 = haps[0], haps[1] rows.append( { + "subject_id": self.subject_id, "segment": seg, "gene": gene, - "zygosity": zyg, - "haplotype_0": sorted(h0), - "haplotype_1": sorted(h1), + "zygosity": self._zygosity(h0, h1), + "haplotype_0": sorted(a for (a, _, _) in h0), + "haplotype_1": sorted(a for (a, _, _) in h1), + # per-haplotype (allele, copies, weight) detail + "haplotype_0_detail": sorted(h0), + "haplotype_1_detail": sorted(h1), "permissive": self._permissive, } ) @@ -171,20 +229,33 @@ def to_table(self) -> List[Dict]: def to_tsv(self, path: str) -> None: import csv + def _fmt(detail): + # allele:copies:weight ; ... + return ";".join(f"{a}:{c}:{w}" for (a, c, w) in detail) + rows = self.to_table() with open(path, "w", newline="") as fh: w = csv.writer(fh, delimiter="\t") w.writerow( - ["segment", "gene", "zygosity", "haplotype_0", "haplotype_1", "permissive"] + [ + "subject_id", + "segment", + "gene", + "zygosity", + "haplotype_0", + "haplotype_1", + "permissive", + ] ) for r in rows: w.writerow( [ + r["subject_id"], r["segment"], r["gene"], r["zygosity"], - ";".join(r["haplotype_0"]), - ";".join(r["haplotype_1"]), + _fmt(r["haplotype_0_detail"]), + _fmt(r["haplotype_1_detail"]), r["permissive"], ] ) diff --git a/tests/test_genotype_builder.py b/tests/test_genotype_builder.py index 8ab9608..06bcbfa 100644 --- a/tests/test_genotype_builder.py +++ b/tests/test_genotype_builder.py @@ -73,3 +73,41 @@ def test_permissive_is_flagged(): g = Genotype.permissive(cfg) assert g.is_permissive is True assert Genotype.from_dataconfig(cfg).is_permissive is False + + +def test_delete_one_haplotype_of_unspecified_gene_raises(): + cfg = _cfg() + v_gene = next(iter(cfg.v_alleles)) + with pytest.raises(ValueError, match="before deleting one haplotype"): + Genotype.from_dataconfig(cfg).delete_gene(v_gene, haplotype=1) + + +def test_one_haplotype_deletion_is_labelled_hemizygous(): + cfg = _cfg() + v_gene = next(iter(cfg.v_alleles)) + a1 = cfg.v_alleles[v_gene][0].name + g = Genotype.from_dataconfig(cfg).homozygous(v_gene, a1).delete_gene(v_gene, haplotype=1) + row = next(r for r in g.to_table() if r["gene"] == v_gene) + assert row["zygosity"] == "hemizygous" + assert row["haplotype_0"] == [a1] + assert row["haplotype_1"] == [] + + +def test_chromosome_weights_rejects_nan(): + cfg = _cfg() + with pytest.raises(ValueError, match="finite"): + Genotype.from_dataconfig(cfg).chromosome_weights(float("nan"), 1.0) + + +def test_snapshot_decouples_from_later_mutation(): + cfg = _cfg() + import GenAIRR as ga + + v_gene = next(iter(cfg.v_alleles)) + a1 = cfg.v_alleles[v_gene][0].name + g = Genotype.from_dataconfig(cfg).complete_from_reference().with_subject("S1") + exp = ga.Experiment.on(cfg).with_genotype(g) + # Mutate the builder AFTER attach — must not affect the attached snapshot. + g.with_subject("MUTATED").delete_gene(v_gene, haplotype="both") + assert exp._genotype.subject_id == "S1" + assert exp._genotype.carried_alleles("V", v_gene) # still carried in snapshot diff --git a/tests/test_genotype_dsl.py b/tests/test_genotype_dsl.py index da69677..d0a6f3b 100644 --- a/tests/test_genotype_dsl.py +++ b/tests/test_genotype_dsl.py @@ -53,6 +53,18 @@ def test_receptor_revision_with_genotype_raises_at_compile(): exp.compile() +def test_genotype_with_clonal_fork_raises_at_compile(): + g = _full_genotype() + exp = ( + ga.Experiment.on(_cfg()) + .with_genotype(g) + .recombine() + .clonal_lineage(n_clones=2) + ) + with pytest.raises(ValueError, match="not supported together with"): + exp.compile() + + def test_cartridge_hash_mismatch_raises(): # Genotype built on IGH, attached to a TCRB experiment → mismatch. g = Genotype.from_dataconfig(_cfg()).complete_from_reference() diff --git a/tests/test_genotype_engine.py b/tests/test_genotype_engine.py index 8c1fd7d..7072a00 100644 --- a/tests/test_genotype_engine.py +++ b/tests/test_genotype_engine.py @@ -16,7 +16,7 @@ def test_phased_recombine_only_emits_carried_allele_for_overridden_gene(): carried = names[1] g = ( Genotype.from_dataconfig(cfg) - .complete_from_reference("homozygous_common") + .complete_from_reference("homozygous_first_reference") .homozygous(v_gene, carried) .with_subject("S1") ) From adc8fcd0b01450ada96a97b58b5ffcb1cc79b7d5 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 17:24:01 +0300 Subject: [PATCH 18/26] test(genotype): V-J phasing linkage across two informative haplotypes --- tests/test_genotype_engine.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_genotype_engine.py b/tests/test_genotype_engine.py index 7072a00..e297ace 100644 --- a/tests/test_genotype_engine.py +++ b/tests/test_genotype_engine.py @@ -94,6 +94,38 @@ def test_heterozygous_expression_is_roughly_balanced(): assert 0.35 < frac0 < 0.65, frac0 +def test_vdj_phasing_links_v_and_j_to_one_chromosome(): + """The core phasing guarantee: with hap0 = {V:gv0, J:gj0} and + hap1 = {V:gv1, J:gj1}, every rearrangement's V and J must come from + the SAME chromosome — never a cross pairing.""" + cfg = _cfg() + vgenes, jgenes = list(cfg.v_alleles), list(cfg.j_alleles) + gv0, gv1 = vgenes[0], vgenes[1] + gj0, gj1 = jgenes[0], jgenes[1] + g = Genotype.from_dataconfig(cfg).complete_from_reference() + # Keep only gv0/gv1 (V) and gj0/gj1 (J); delete everything else. + for gene in cfg.v_alleles: + if gene not in (gv0, gv1): + g.delete_gene(gene, "both", segment="V") + for gene in cfg.j_alleles: + if gene not in (gj0, gj1): + g.delete_gene(gene, "both", segment="J") + # Phase: hap0 carries gv0 + gj0; hap1 carries gv1 + gj1. + g.delete_gene(gv0, haplotype=1, segment="V") + g.delete_gene(gv1, haplotype=0, segment="V") + g.delete_gene(gj0, haplotype=1, segment="J") + g.delete_gene(gj1, haplotype=0, segment="J") + g.with_subject("S1") + res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records( + n=200, seed=4, expose_provenance=True + ) + for r in res: + v_is_0 = r["truth_v_call"].startswith(gv0 + "*") + j_is_0 = r["truth_j_call"].startswith(gj0 + "*") + assert v_is_0 == j_is_0, (r["truth_v_call"], r["truth_j_call"]) + assert (r["haplotype"] == 0) == v_is_0 + + def test_one_dead_haplotype_uses_the_live_one_under_productive_only(): cfg = _cfg() g = Genotype.from_dataconfig(cfg).complete_from_reference() From 5faf83f4b9dbe4fad101d041cd5c758327947972 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 17:55:11 +0300 Subject: [PATCH 19/26] fix(genotype): validate replay support, permissive filter-then-fallback, honest usage Re-review round 2: - Replay now validates the recorded trace against the genotype: the gene must be carried on the drawn chromosome, the canonical allele must be in that gene slot, and any within-slot record must agree with it. Tampered traces are rejected (was: silently accepted). Adds a Rust test corrupting sample_allele.v. - Permissive sampling now mirrors SampleAllelePass: prefer feasibility-admissible haplotypes/genes/alleles, falling back to the unfiltered carried set only when NO admissible candidate exists (was: ignored feasibility whenever strict=False). - Gene usage: documented that it's driven by typed reference_models.allele_usage (dormant for bundled configs without it); added a typed-cartridge regression test proving the aggregation is active + effective when allele_usage is set. --- engine_rs/src/passes/sample_genotype.rs | 152 +++++++++++++++++++++--- src/GenAIRR/_compile.py | 6 + tests/test_genotype_usage.py | 39 ++++++ 3 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 tests/test_genotype_usage.py diff --git a/engine_rs/src/passes/sample_genotype.rs b/engine_rs/src/passes/sample_genotype.rs index 7c98a89..ba395cb 100644 --- a/engine_rs/src/passes/sample_genotype.rs +++ b/engine_rs/src/passes/sample_genotype.rs @@ -119,21 +119,18 @@ impl SampleGenotypePass { contract_ok && feasible_ok } - /// Carried alleles on chromosome `c` for `seg`. In `strict` mode they - /// are filtered by the active contracts + feasibility; in permissive - /// mode all carried alleles are admissible (mirrors the documented - /// permissive exception of `SampleAllelePass` — a permissive run must - /// not error, so feasibility is advisory only). + /// Carried alleles on chromosome `c` for `seg`, optionally filtered + /// by the active contracts + feasibility. fn admissible_alleles( &self, c: usize, seg: Segment, sim: &Simulation, ctx: &PassContext, - strict: bool, + filter: bool, ) -> Vec { let carried = self.genotype.haplotype(c).carried_alleles(seg); - if !strict { + if !filter { return carried; } carried @@ -142,14 +139,29 @@ impl SampleGenotypePass { .collect() } - fn is_viable(&self, c: usize, sim: &Simulation, ctx: &PassContext, strict: bool) -> bool { + fn is_viable(&self, c: usize, sim: &Simulation, ctx: &PassContext, filter: bool) -> bool { self.segments() .iter() - .all(|seg| !self.admissible_alleles(c, *seg, sim, ctx, strict).is_empty()) + .all(|seg| !self.admissible_alleles(c, *seg, sim, ctx, filter).is_empty()) } + /// Viable chromosomes. The feasibility-filtered viable set is + /// preferred; in permissive mode, only if NO chromosome is + /// feasibility-viable do we fall back to presence-viability — exactly + /// mirroring `SampleAllelePass` (filter first, fall back to the + /// unconstrained draw only on empty admissible support). In strict + /// mode the feasibility-viable set is authoritative (empty → the + /// caller raises). fn viable_set(&self, sim: &Simulation, ctx: &PassContext, strict: bool) -> Vec { - (0..2).filter(|&c| self.is_viable(c, sim, ctx, strict)).collect() + let feasible: Vec = (0..2) + .filter(|&c| self.is_viable(c, sim, ctx, true)) + .collect(); + if strict || !feasible.is_empty() { + return feasible; + } + (0..2) + .filter(|&c| self.is_viable(c, sim, ctx, false)) + .collect() } fn draw_haplotype(&self, viable: &[usize], rng: &mut Rng) -> usize { @@ -218,9 +230,9 @@ impl SampleGenotypePass { } /// Candidate `(allele, mass)` copies in a gene slot on chromosome - /// `c`. `mass = weight * copies` (copy-number dosage). In `strict` - /// mode copies are filtered by contracts + feasibility; in permissive - /// mode all copies are admissible. + /// `c`. `mass = weight * copies` (copy-number dosage). When `filter` + /// is set, copies are restricted to those admissible under the active + /// contracts + feasibility. fn slot_candidates( &self, seg: Segment, @@ -228,13 +240,13 @@ impl SampleGenotypePass { c: usize, sim: &Simulation, ctx: &PassContext, - strict: bool, + filter: bool, ) -> Vec<(AlleleId, f64)> { self.genotype .haplotype(c) .slot(seg, gene) .iter() - .filter(|cp| !strict || self.allele_feasible(seg, cp.allele, sim, ctx)) + .filter(|cp| !filter || self.allele_feasible(seg, cp.allele, sim, ctx)) .map(|cp| (cp.allele, cp.weight as f64 * cp.copies as f64)) .collect() } @@ -252,12 +264,30 @@ impl SampleGenotypePass { ) -> Result { let hap = self.genotype.haplotype(c); let vseg = Self::vseg(seg); - // Genes present on this chromosome that have >=1 admissible copy, + // Filter-then-fallback, mirroring SampleAllelePass: prefer + // feasibility-admissible candidates; only when NONE are admissible + // do we fall back to the unfiltered carried set (permissive), or + // raise (strict). `filter` is true whenever feasible candidates + // exist on this chromosome+segment. + let feasible_exists = hap.present_genes(seg).any(|g| { + !self + .slot_candidates(seg, g, c, &sim, ctx, true) + .is_empty() + }); + if strict && !feasible_exists { + return Err(PassError::constraint_sampling( + self.name(), + address::sample_allele_vdj(seg), + FilteredSampleError::EmptyAdmissibleSupport, + )); + } + let filter = strict || feasible_exists; + // Genes present on this chromosome that have >=1 candidate copy, // weighted by gene usage * total copy dosage (so a duplicated // gene recombines more often). let mut genes: Vec<(GeneId, f64)> = Vec::new(); for g in hap.present_genes(seg) { - let cands = self.slot_candidates(seg, g, c, &sim, ctx, strict); + let cands = self.slot_candidates(seg, g, c, &sim, ctx, filter); if cands.is_empty() { continue; } @@ -272,7 +302,7 @@ impl SampleGenotypePass { )); } let gene = Self::weighted_pick(&genes, ctx.rng); - let cands = self.slot_candidates(seg, gene, c, &sim, ctx, strict); + let cands = self.slot_candidates(seg, gene, c, &sim, ctx, filter); let id = Self::weighted_pick(&cands, ctx.rng); // "Multi-copy slot" is decided by the RAW slot length (genotype @@ -312,7 +342,20 @@ impl SampleGenotypePass { .expect_gene_id(ChoiceAddress::SampleGene(vseg)) .map_err(|r| PassError::replay(self.name(), r))?; let gene = GeneId::new(gene_idx); - let multi = self.genotype.haplotype(c).slot(seg, gene).len() > 1; + let slot = self.genotype.haplotype(c).slot(seg, gene); + // Replay validation: a recorded trace must be admissible against + // this genotype — the recorded gene must be carried on the drawn + // chromosome (non-empty slot). Mirrors SampleAllelePass's + // "trace proposes, engine validates" contract. + if slot.is_empty() { + return Err(PassError::invalid_distribution_output( + self.name(), + address::sample_allele_vdj(seg), + gene_idx as i64, + "genotype_gene_not_carried_on_haplotype", + )); + } + let multi = slot.len() > 1; let slot_recorded = if multi { Some( ctx.replay_cursor @@ -330,6 +373,27 @@ impl SampleGenotypePass { .expect("replay cursor present") .expect_allele_id(ChoiceAddress::SampleAllele(vseg)) .map_err(|r| PassError::replay(self.name(), r))?; + // The canonical allele must be one the gene slot actually carries. + if !slot.iter().any(|cp| cp.allele.index() == allele) { + return Err(PassError::invalid_distribution_output( + self.name(), + address::sample_allele_vdj(seg), + allele as i64, + "genotype_allele_not_in_gene_slot", + )); + } + // When a within-slot record exists it must agree with the + // canonical allele (live writes the same id to both). + if let Some(slot_id) = slot_recorded { + if slot_id != allele { + return Err(PassError::invalid_distribution_output( + self.name(), + address::sample_allele_vdj(seg), + slot_id as i64, + "genotype_slot_record_disagrees_with_canonical_allele", + )); + } + } let id = AlleleId::new(allele); ctx.trace @@ -623,4 +687,54 @@ mod tests { live_j ); } + + #[test] + fn replay_rejects_allele_not_carried_in_genotype_slot() { + use crate::pass::PassContext; + use crate::replay::TraceCursor; + use crate::rng::Rng; + use crate::trace::Trace; + + let g = Arc::new(test_support::geno_chrom1_deletes_j()); + let mut plan = PassPlan::new(); + plan.push(Box::new(SampleGenotypePass::new( + g.clone(), + false, + vec![], + vec![], + vec![], + ))); + let live = PassRuntime::execute(&plan, Simulation::new(), 13); + // Tamper: rewrite the canonical V allele to one not in the slot. + let mut records: Vec<_> = live.trace.choices().to_vec(); + for r in records.iter_mut() { + if r.address == "sample_allele.v" { + r.value = ChoiceValue::AlleleId(999); + } + } + let replay_pass = SampleGenotypePass::new(g, false, vec![], vec![], vec![]); + let mut cursor = TraceCursor::from_owned(records); + let mut trace = Trace::new(); + let mut rng = Rng::new(1); + let sim = Simulation::new(); + let result; + { + let mut ctx = PassContext { + trace: &mut trace, + rng: &mut rng, + pass_index: 0, + refdata: None, + contracts: None, + feasibility: None, + reference_index: None, + replay_cursor: Some(&mut cursor), + event_log_sink: None, + }; + result = replay_pass.run(&sim, &mut ctx, true); + } + assert!( + result.is_err(), + "replay must reject an allele not carried in the genotype slot" + ); + } } diff --git a/src/GenAIRR/_compile.py b/src/GenAIRR/_compile.py index 75d29b5..0b99208 100644 --- a/src/GenAIRR/_compile.py +++ b/src/GenAIRR/_compile.py @@ -250,6 +250,12 @@ def _push_genotype_recombine(genotype, step, plan, refdata, *, d_required): + ("D/" if d_required else "") + "J), so no phased rearrangement is possible" ) + # Gene usage is driven by the cartridge's TYPED allele-usage plane + # (``reference_models.allele_usage``), resolved onto ``step.weights_*`` + # by recombine(). NOTE: bundled configs that don't author a typed + # allele_usage leave these ``None`` here, so gene choice for those is + # uniform-over-present-genes (× copy dosage). Legacy ``gene_use_dict`` + # is intentionally NOT consulted (mirrors recombine()'s precedence). v_weights = list(step.weights_v) if step.weights_v is not None else None d_weights = list(step.weights_d) if step.weights_d is not None else None j_weights = list(step.weights_j) if step.weights_j is not None else None diff --git a/tests/test_genotype_usage.py b/tests/test_genotype_usage.py new file mode 100644 index 0000000..887bc3c --- /dev/null +++ b/tests/test_genotype_usage.py @@ -0,0 +1,39 @@ +"""Genotype gene-usage wiring is active when the cartridge authors a +typed ``reference_models.allele_usage`` plane (review #3). + +This pins that the Rust gene-level usage aggregation is *reachable* and +*effective* — not just code-complete. Bundled configs that don't author +allele_usage fall back to uniform-over-present-genes (× copy dosage), +which is documented, not tested here. +""" +import dataclasses + +import GenAIRR as ga +import GenAIRR.data as gdata +from GenAIRR.genotype import Genotype +from GenAIRR.reference_models import AlleleUsageSpec, ReferenceEmpiricalModels + + +def _cfg_with_v_usage(target_allele_name, weight=1000.0): + base = gdata.HUMAN_IGH_OGRDB + rm = ReferenceEmpiricalModels(allele_usage=AlleleUsageSpec(v={target_allele_name: weight})) + return dataclasses.replace(base, reference_models=rm) + + +def test_typed_allele_usage_biases_genotype_gene_choice(): + base = gdata.HUMAN_IGH_OGRDB + # Heavily weight the first allele of one V gene. + target_gene = list(base.v_alleles)[10] + target_allele = base.v_alleles[target_gene][0].name + cfg = _cfg_with_v_usage(target_allele, weight=1000.0) + + g = Genotype.from_dataconfig(cfg).complete_from_reference().with_subject("S1") + res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records( + n=300, seed=1, expose_provenance=True + ) + frac_target = sum( + 1 for r in res if r["truth_v_call"].startswith(target_gene + "*") + ) / len(res) + # With ~52 V genes uniform would give ~0.02; a 1000x usage weight on + # this gene must dominate. + assert frac_target > 0.5, frac_target From afe00929dba3221e64016381163ae5186b2d10e3 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 18:02:24 +0300 Subject: [PATCH 20/26] docs(genotype): reword usage comment to not trip the legacy-usage-dict pin --- src/GenAIRR/_compile.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/GenAIRR/_compile.py b/src/GenAIRR/_compile.py index 0b99208..9fa00e1 100644 --- a/src/GenAIRR/_compile.py +++ b/src/GenAIRR/_compile.py @@ -254,8 +254,9 @@ def _push_genotype_recombine(genotype, step, plan, refdata, *, d_required): # (``reference_models.allele_usage``), resolved onto ``step.weights_*`` # by recombine(). NOTE: bundled configs that don't author a typed # allele_usage leave these ``None`` here, so gene choice for those is - # uniform-over-present-genes (× copy dosage). Legacy ``gene_use_dict`` - # is intentionally NOT consulted (mirrors recombine()'s precedence). + # uniform-over-present-genes (× copy dosage). The legacy per-gene + # usage dict is intentionally NOT consulted (mirrors recombine()'s + # precedence chain). v_weights = list(step.weights_v) if step.weights_v is not None else None d_weights = list(step.weights_d) if step.weights_d is not None else None j_weights = list(step.weights_j) if step.weights_j is not None else None From 8852cc2621ff1a6a1c5566e0c0bc165d802f310d Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 18:53:08 +0300 Subject: [PATCH 21/26] docs(genotype): dedicated guide + TIgGER/IgDiscover detection showcase New site_docs/guides/genotype.md: what a genotype is (diploid, phased, presence/ absence, copy number, deletion), how recombination samples from it (chromosome choice, V-D-J linkage, viability/productive, strict vs permissive), the builder API, ground-truth/provenance (result.genotypes, subject_id/haplotype, to_table/ to_tsv), benchmarking workflow, and PR1 limitations. Worked showcase: a planted diploid genotype recovered by TIgGER (precision/recall 1.00, 52/52) and IgDiscover (precision 1.00, recall 0.96, deletions + heterozygosity correct), with a figure. Adds the guide to mkdocs nav. --- mkdocs.yml | 1 + site_docs/assets/genotype-tigger-recovery.png | Bin 0 -> 101424 bytes site_docs/guides/genotype.md | 327 ++++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 site_docs/assets/genotype-tigger-recovery.png create mode 100644 site_docs/guides/genotype.md diff --git a/mkdocs.yml b/mkdocs.yml index d864b06..e8814ec 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -207,6 +207,7 @@ nav: - Clonal simulation overview: guides/clonal-families.md - Clonal lineage trees: guides/clonal-lineage.md - Clonal repertoires (TCR & abundance): guides/clonal-repertoire.md + - Genotypes (per-individual diploid): guides/genotype.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/assets/genotype-tigger-recovery.png b/site_docs/assets/genotype-tigger-recovery.png new file mode 100644 index 0000000000000000000000000000000000000000..2d7a6661a4999858c1ab5d2f5300153e67585d40 GIT binary patch literal 101424 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 literal 0 HcmV?d00001 diff --git a/site_docs/guides/genotype.md b/site_docs/guides/genotype.md new file mode 100644 index 0000000..f53b0ac --- /dev/null +++ b/site_docs/guides/genotype.md @@ -0,0 +1,327 @@ +# Genotypes: per-individual diploid germline + +

A genotype in GenAIRR is one person's diploid germline +complement — which V/D/J alleles they carry, on which chromosome, in what copy +number. Attach a Genotype to an Experiment and V(D)J +recombination becomes haplotype-phased: the V, D and J of each +rearrangement are drawn from a single chromosome, honouring allele +presence/absence, zygosity, and gene deletion. With no genotype attached the +engine is byte-for-byte unchanged. This page explains exactly what a genotype is, +how the engine samples from it, how to build one, and how to use it to benchmark +genotype-inference tools — nothing here is a black box.

+ +## What a genotype is (and why it matters) + +Every person inherits two copies of the immunoglobulin heavy-chain locus — one on +each homologous chromosome (one **haplotype** from each parent). Across the +population the locus is extraordinarily polymorphic: a reference set may list +dozens of alleles per gene, but a *single individual* carries only a handful — +typically **one or two alleles per gene** — and may be **missing entire genes** +(deletion) or carry **extra copies** (duplication). That per-individual set is the +**genotype**. + +GenAIRR models four things a genotype encodes: + +| Concept | Meaning | In GenAIRR | +|---|---|---| +| **Allele presence/absence** | only carried alleles can rearrange | alleles not in the genotype are never sampled | +| **Diploid zygosity** | per gene: 1 allele (homozygous) or 2 (heterozygous) | `homozygous` / `heterozygous` | +| **Gene deletion / copy number** | a gene can be absent on one or both chromosomes, or duplicated | `delete_gene` / `duplicate_gene` | +| **Haplotype phasing** | V, D, J of one rearrangement come from one chromosome | drawn automatically, recorded per record | + +Phasing is what makes a genotype more than "a list of alleles to allow". The IGH +locus is physically on a chromosome, so a single recombination event splices a V, +a D and a J **from the same chromosome**. That linkage is exactly the signal +haplotype-inference methods exploit (e.g. the IGHJ6-anchor approach), and GenAIRR +reproduces it. + +## Quick start + +```python +import GenAIRR as ga +import GenAIRR.data as gdata +from GenAIRR.genotype import Genotype + +cfg = gdata.HUMAN_IGH_OGRDB + +# Build a diploid genotype: start from the reference, then edit specific genes. +g = ( + Genotype.from_dataconfig(cfg) + .complete_from_reference("homozygous_first_reference") # fill the rest + .heterozygous("IGHVF1-G1", "IGHVF1-G1*01", "IGHVF1-G1*02") # two alleles + .homozygous("IGHVF2-G4", "IGHVF2-G4*01") # one allele + .delete_gene("IGHVF3-G7", haplotype="both") # gene absent + .with_subject("DONOR01") +) + +result = ( + ga.Experiment.on(cfg) + .with_genotype(g) # recombination is now haplotype-phased + .recombine() + .run_records(n=1000, seed=7) +) + +result[0]["subject_id"] # 'DONOR01' — provenance on every record +result[0]["haplotype"] # 0 or 1 — which chromosome this read used +result.genotypes[0].to_table() # ground-truth genotype (per gene, per haplotype) +``` + +## How recombination samples from a genotype + +When a genotype is attached, recombination runs a single phased sampling pass per +rearrangement. The steps, in order: + +1. **Draw a chromosome.** One of the two haplotypes is chosen, weighted by the + `chromosome_weights` (default `[0.5, 0.5]`). This choice is made **once** and + shared by V, D and J — that is the phasing. +2. **Per segment, draw a gene then an allele.** Among the genes *present on the + chosen chromosome*, a gene is sampled (weighted by usage — see below), then the + allele follows from that chromosome's slot for the gene. A deleted gene is + simply not offered on the chromosome that lacks it. +3. **Assign and continue.** The chosen V/D/J alleles are assigned and the rest of + the pipeline (trimming, NP, assembly, SHM, corruption) runs unchanged. + +Every random choice (chromosome, gene, within-slot allele) is recorded to the +trace, so seeded runs are byte-stable and fully replayable. + +### Viability and `productive_only` + +A chromosome is only drawn if it is **viable** — it must carry at least one usable +allele for every required segment (V and J, plus D on heavy chains). This matters +with deletions: if one haplotype lacks a J gene entirely, only the other +chromosome is ever drawn. If **neither** chromosome can produce a rearrangement, +the genotype is rejected at compile time with a clear error (rather than failing +at run time). + +Under [`productive_only`](productive.md), viability also accounts for +productive-junction feasibility, and the V chosen earlier in the pass constrains +the J drawn later (the phased choices are evaluated together, not independently). + +### Strict vs permissive + +`Genotype.from_dataconfig(cfg)` is **strict**: any gene that could be used during +recombination but was never specified is an error at attach time — you must define +the whole genotype (use `complete_from_reference` to fill the genes you don't care +about). This guarantees a genuine diploid complement, which is what you want for a +ground-truth benchmark. + +`Genotype.permissive(cfg)` is a separate, explicitly **non-diploid** fallback: +unspecified genes are left to sample over *all* their reference alleles without +phasing. It exists for the "I only want to constrain a few genes" case — it is +**not** a biological genotype, and it is labelled as such in `to_table()` and +`repr`. + +In both modes, feasibility (e.g. `productive_only`) is applied the same way the +non-genotype path applies it: candidates are filtered to the feasible set, and the +unfiltered set is used only as a last resort when nothing is feasible — so a +genotype run never silently samples alleles a normal run would have avoided. + +## Building a genotype + +The `Genotype` builder is a fluent, validated editor over a `DataConfig`'s +reference alleles. Every method returns `self`, so calls chain. + +```python +g = Genotype.from_dataconfig(cfg) # strict (recommended) + +g.homozygous("IGHVF2-G4", "IGHVF2-G4*01") # 1 allele on both chromosomes +g.heterozygous("IGHVF1-G1", "IGHVF1-G1*01", "IGHVF1-G1*02") # different allele per chromosome +g.delete_gene("IGHVF3-G7", haplotype="both") # gene absent entirely (homozygous deletion) +g.delete_gene("IGHVF3-G8", haplotype=1) # absent on chromosome 1 only (hemizygous) +g.duplicate_gene("IGHVF1-G2", ["IGHVF1-G2*01", "IGHVF1-G2*03"], haplotype=0) # >1 copy on one chromosome +g.chromosome_weights(0.6, 0.4) # allelic-expression imbalance +g.with_subject("DONOR01") # provenance label + +g.complete_from_reference("homozygous_first_reference") # fill every unspecified gene +``` + +Notes and guard-rails: + +- **`delete_gene(..., haplotype=0|1)`** (one chromosome) requires the gene to be + specified first — deleting a single haplotype of an *unspecified* gene would + silently delete both, so it raises instead. +- **`complete_from_reference(policy=...)`** fills only genes you haven't touched. + `"homozygous_first_reference"` (default) makes each unspecified gene homozygous + for its first cartridge allele — note this is the *first listed* allele, **not** + a population-frequency-common one (GenAIRR has no frequency prior in this + release; the name says exactly what it does). `"heterozygous_first_two"` uses + the first two alleles. +- Unknown gene/allele names, NaN/inf chromosome weights, and segments left with no + usable allele are all rejected at build/attach with clear messages. +- `with_genotype` is **mutually exclusive** with + [`restrict_alleles`](../reference/experiment.md) and the + `recombine(*_allele_weights=...)` kwargs — the genotype owns allele presence and + expression. It is also rejected together with `receptor_revision` and with the + clonal forks (`expand_clones` / `clonal_lineage` / `clonal_repertoire`) in this + release (see [Limitations](#limitations-this-release)). + +### Gene usage + +Within a chromosome, which *gene* is used is weighted by the cartridge's typed +allele-usage model (`reference_models.allele_usage`), aggregated to the gene +level, and scaled by **copy-number dosage** (a duplicated gene recombines +proportionally more often). Cartridges that don't author a typed `allele_usage` +fall back to uniform-over-present-genes (× dosage). See +[Allele usage](v-usage.md) and [Estimate models from data](estimate-cartridge-models.md) +for authoring usage. + +## Ground truth and provenance + +A genotype experiment emits, by construction, everything an evaluation needs: + +- **Per-record fields:** `subject_id` and `haplotype` (`0`/`1`, the chromosome the + rearrangement used) are stamped on every AIRR record. Standard truth columns + (`truth_v_call`, …) are available with `expose_provenance=True`. +- **`result.genotypes`:** the list of attached `Genotype` objects (one per subject). +- **`Genotype.to_table()` / `to_tsv(path)`:** the ground-truth genotype as a table + — one row per (segment, gene) with `zygosity` + (`homozygous` / `heterozygous` / `hemizygous` / `deleted`), the carried alleles + per haplotype, and per-haplotype `allele:copies:weight` detail. This is the + reference a genotype-inference benchmark compares against. + +```python +for row in result.genotypes[0].to_table(): + if row["zygosity"] != "homozygous": # show the interesting genes + print(row["gene"], row["zygosity"], row["haplotype_0"], row["haplotype_1"]) +``` + +## Research workflow: benchmarking genotype inference + +The point of simulating from a *known* genotype is that you can run a +genotype-inference tool on the resulting repertoire and score it against the +planted truth — with no real-data uncertainty about what the right answer is. + +The recipe is the same for any tool: + +1. Build a `Genotype`, simulate a repertoire, write the AIRR table + (`result.to_tsv(...)`) and/or reads FASTA, and the ground truth + (`genotype.to_tsv(...)`). +2. Run the inference tool to recover the per-individual allele set. +3. Compare recovered vs planted: presence/absence, zygosity, and (for + discovery tools) any novel alleles. + +### Worked example: TIgGER and IgDiscover recover a planted genotype + +To show this end to end we planted a diploid IGH genotype in `human_igh` — +**3 heterozygous** V genes (two alleles each), **3 homozygous** (one allele), +and **3 fully deleted** genes — and filled the rest from the reference. We +simulated 4,000 reads with light SHM, then ran two independent AIRR +genotype-inference tools on the result: +[**TIgGER**](https://tigger.readthedocs.io) (Immcantation; consumes the AIRR +table) and [**IgDiscover**](https://igdiscover.se) (germline discovery from the +raw reads, with its own IgBLAST). + +Because GenAIRR already emits AIRR records with `v_call` **and** +`sequence_alignment`, TIgGER's `inferGenotype` consumes the rearrangement table +**directly — no separate IgBLAST step is needed**: + +```r +library(tigger); library(airr) +rep <- read_rearrangement("repertoire.tsv") # GenAIRR's AIRR output +germ_v <- readIgFasta("germline_V.fasta") # cartridge V germline (names match v_call) +geno <- inferGenotype(rep, germline_db = germ_v, find_unmutated = TRUE) +plotGenotype(geno) +``` + +TIgGER recovered the planted genotype **exactly**: every heterozygous gene → two +alleles, every homozygous gene → one, every deleted gene → **absent**. Across all +52 V genes, allele-presence **precision = 1.00**, **recall = 1.00**, and the +per-gene allele count matched the truth for **52/52** genes. + +**IgDiscover**, run on the raw reads with the cartridge as its starting database, +independently agreed: **precision = 1.00** (zero false-positive alleles), +**recall = 0.96** (50/52 carried alleles), with **all three deletions correct** +and **all heterozygous genes fully resolved** (both alleles recovered). The two +missed alleles were low-expression single-copy genes below IgDiscover's default +expression threshold — a tool-tuning matter, not a simulation artefact. + +![GenAIRR-simulated genotype recovered by TIgGER and IgDiscover: planted vs inferred allele counts agree for every gene](../assets/genotype-tigger-recovery.png) + +*(A) The nine study genes: both tools' inferred allele counts match the planted +zygosity for each (heterozygous → 2, homozygous → 1, deleted → 0). (B) All 52 V +genes fall on the agreement diagonal; presence precision = 1.00 for both tools, +recall 1.00 (TIgGER) / 0.96 (IgDiscover), zero false-positive alleles, all +deletions correct.* + +### Reproduce it + +```python +import GenAIRR as ga +import GenAIRR.data as gdata +from GenAIRR.genotype import Genotype + +cfg = gdata.HUMAN_IGH_OGRDB +g = ( + Genotype.from_dataconfig(cfg) + .complete_from_reference("homozygous_first_reference") + .heterozygous("IGHVF1-G1", "IGHVF1-G1*01", "IGHVF1-G1*02") + .homozygous("IGHVF2-G4", "IGHVF2-G4*01") + .delete_gene("IGHVF3-G7", haplotype="both") + .with_subject("DONOR01") +) +res = ( + ga.Experiment.on(cfg).with_genotype(g).recombine() + .mutate(rate=0.004) # light SHM, as in real data + .run_records(n=4000, seed=7) +) +res.to_tsv("repertoire.tsv") # AIRR table → TIgGER +g.to_tsv("truth_genotype.tsv") # ground truth to score against + +# export the cartridge V germline (names match v_call) for TIgGER's germline_db +with open("germline_V.fasta", "w") as fh: + for gene, alleles in cfg.v_alleles.items(): + for a in alleles: + fh.write(f">{a.name}\n{a.ungapped_seq.upper()}\n") +``` + +Then run the R snippet above and compare `geno` against `truth_genotype.tsv`. + +### Running other tools on the same data + +The only difference between tools is whether they consume the **AIRR table** +(TIgGER) or the **raw reads** (`reads.fasta`, which you can write from `result`), +running their own aligner: + +- **[IgDiscover](https://igdiscover.se)** — germline *discovery* from reads (its + own IgBLAST + iterative filtering). Initialise with the cartridge germline as + the starting database and the simulated reads, then run the pipeline; the + `final/database/V.fasta` expressed-allele set is the recovered genotype: + + ```bash + igdiscover init --database db/ --single-reads reads.fasta project/ + cd project && igdiscover run + ``` + +- **[partis](https://github.com/psathyrella/partis)** — HMM annotation with + per-sample germline inference (`partis cache-parameters --infname reads.fa + --initial-germline-dir db/`). partis also reports per-sample allele support and + novel alleles, scored the same way. + +Because the genotype is planted, every tool is scored identically: recovered +allele set vs `genotype.to_table()` — presence precision/recall, zygosity, and +deletion calls. + +## Limitations (this release) + +The genotype foundation is deliberately scoped. Deferred to later work: + +- **Novel / private alleles** — per-individual germline variants not in the + reference (synthesis + provenance). Today a genotype draws from reference + alleles only. +- **Cohorts** — many subjects, each with their own genotype, in one run + (`with_genotype` is single-subject; `result.genotypes` is a one-element list). +- **Population priors** — sampling a plausible diploid genotype from + allele/deletion frequencies (today genotypes are specified explicitly). +- **External loaders** — importing genotypes from VDJbase / TIgGER / IgDiscover / + partis output. +- **Cartridge genotype plane** — persisting a population genotype model in a + cartridge. +- **Same-haplotype receptor revision** — `receptor_revision` with a genotype is + rejected for now. + +## Backward compatibility + +The genotype machinery is purely additive. An experiment with **no** genotype +attached produces byte-identical output to previous releases (pinned by a +checksum test). Attaching a genotype is the only thing that switches recombination +onto the phased path. From ea90be5ba0c26ccb4f82ecda3533f5e73936d5cd Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 19:00:05 +0300 Subject: [PATCH 22/26] =?UTF-8?q?docs(genotype):=20fix=20builder=20example?= =?UTF-8?q?=20=E2=80=94=20specify=20gene=20before=20one-haplotype=20deleti?= =?UTF-8?q?on;=20valid=20duplicate=20alleles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by executing the guide's Python blocks against the live API: the builder snippet called delete_gene(haplotype=1) on an unspecified gene (now a guarded error) and used a non-existent allele in duplicate_gene. All 4 Python examples now run cleanly. --- site_docs/guides/genotype.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/site_docs/guides/genotype.md b/site_docs/guides/genotype.md index f53b0ac..e0ddacc 100644 --- a/site_docs/guides/genotype.md +++ b/site_docs/guides/genotype.md @@ -127,8 +127,9 @@ g = Genotype.from_dataconfig(cfg) # strict (recommended) g.homozygous("IGHVF2-G4", "IGHVF2-G4*01") # 1 allele on both chromosomes g.heterozygous("IGHVF1-G1", "IGHVF1-G1*01", "IGHVF1-G1*02") # different allele per chromosome g.delete_gene("IGHVF3-G7", haplotype="both") # gene absent entirely (homozygous deletion) -g.delete_gene("IGHVF3-G8", haplotype=1) # absent on chromosome 1 only (hemizygous) -g.duplicate_gene("IGHVF1-G2", ["IGHVF1-G2*01", "IGHVF1-G2*03"], haplotype=0) # >1 copy on one chromosome +g.homozygous("IGHVF3-G8", "IGHVF3-G8*01") # carried on both chromosomes... +g.delete_gene("IGHVF3-G8", haplotype=1) # ...then removed on chromosome 1 (hemizygous) +g.duplicate_gene("IGHVF1-G2", ["IGHVF1-G2*01", "IGHVF1-G2*02"], haplotype=0) # >1 copy on one chromosome g.chromosome_weights(0.6, 0.4) # allelic-expression imbalance g.with_subject("DONOR01") # provenance label From fcc8076423761e22e83b22df6ce4c5c1b4a827bd Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 19:08:32 +0300 Subject: [PATCH 23/26] =?UTF-8?q?docs(genotype):=20add=20'More=20genotype?= =?UTF-8?q?=20recipes'=20=E2=80=94=20richer=20diploid,=20duplication,=20pr?= =?UTF-8?q?ogrammatic=20build,=20inspection=20(all=20executed=20against=20?= =?UTF-8?q?live=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site_docs/guides/genotype.md | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/site_docs/guides/genotype.md b/site_docs/guides/genotype.md index e0ddacc..0d31405 100644 --- a/site_docs/guides/genotype.md +++ b/site_docs/guides/genotype.md @@ -166,6 +166,73 @@ fall back to uniform-over-present-genes (× dosage). See [Allele usage](v-usage.md) and [Estimate models from data](estimate-cartridge-models.md) for authoring usage. +### More genotype recipes + +**A richer diploid genotype** — several heterozygous genes, a homozygous gene, +a whole-gene (homozygous) deletion, a hemizygous deletion, and allelic-expression +imbalance, with everything else filled from the reference: + +```python +g = ( + Genotype.from_dataconfig(cfg) + .heterozygous("IGHVF1-G1", "IGHVF1-G1*01", "IGHVF1-G1*02") + .heterozygous("IGHVF1-G2", "IGHVF1-G2*01", "IGHVF1-G2*02") + .homozygous("IGHVF2-G4", "IGHVF2-G4*01") + .delete_gene("IGHVF3-G7", haplotype="both") # absent on both chromosomes + .homozygous("IGHVF3-G8", "IGHVF3-G8*01") # carried on both... + .delete_gene("IGHVF3-G8", haplotype=1) # ...then removed on chr 1 (hemizygous) + .chromosome_weights(0.65, 0.35) # chromosome 0 expressed more + .complete_from_reference() # the remaining genes + .with_subject("DONOR_A") +) +``` + +**Gene duplication** — one chromosome carries two alleles of the same gene +(specify the gene on both chromosomes first, then add the extra copy to one): + +```python +g = ( + Genotype.from_dataconfig(cfg) + .homozygous("IGHVF1-G3", "IGHVF1-G3*01") # both chromosomes carry *01 + .duplicate_gene("IGHVF1-G3", ["IGHVF1-G3*01", "IGHVF1-G3*02"], haplotype=0) # chr 0 now carries two copies + .complete_from_reference() + .with_subject("DONOR_DUP") +) +# chromosome 0 carries {*01, *02}, chromosome 1 carries {*01}; +# the extra copy raises this gene's recombination share (copy-number dosage). +``` + +**Build a fully-specified strict genotype programmatically** — drive the builder +from a per-gene plan (the natural shape if you load a genotype from a table or +generate many subjects): + +```python +plan = { + "IGHVF1-G1": ("IGHVF1-G1*01", "IGHVF1-G1*02"), # 2 alleles -> heterozygous + "IGHVF1-G2": ("IGHVF1-G2*01",), # 1 allele -> homozygous + "IGHVF3-G7": (), # 0 alleles -> deleted + # ... one entry per gene you want to pin +} + +g = Genotype.from_dataconfig(cfg) +for gene, alleles in plan.items(): + if not alleles: + g.delete_gene(gene, haplotype="both") + elif len(alleles) == 1: + g.homozygous(gene, alleles[0]) + else: + g.heterozygous(gene, alleles[0], alleles[1]) +g.complete_from_reference().with_subject("DONOR_B") +``` + +**Inspect the non-trivial genes** of any genotype: + +```python +for row in g.to_table(): + if row["zygosity"] != "homozygous": + print(row["gene"], row["zygosity"], row["haplotype_0"], row["haplotype_1"]) +``` + ## Ground truth and provenance A genotype experiment emits, by construction, everything an evaluation needs: From 4d9c190754978e501fd78f5d1da7fe9da70c359c Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 19:29:06 +0300 Subject: [PATCH 24/26] feat(genotype): novel/private alleles (synthesis + effective-refdata injection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add_novel_allele(name, base=, mutations=|sequence=) synthesizes a private allele from a reference base (inheriting gene/anchor/functional/subregions), validates it, and registers it so it can be placed like any allele. At compile(), a genotype with novel alleles builds an EFFECTIVE refdata (base catalogue + injected private alleles) — so they flow through sampling/assembly/AIRR as real pool entries with no engine changes. to_table flags novel alleles per gene. Docs: dedicated 'Novel / private alleles' section + discovery-benchmarking recipe; removed from Limitations. Tests cover synthesis, validation, sampling+AIRR, and no-novel backward compatibility. --- site_docs/guides/genotype.md | 43 +++++++++++- src/GenAIRR/experiment.py | 33 ++++++++-- src/GenAIRR/genotype.py | 124 ++++++++++++++++++++++++++++++++++- tests/test_genotype_novel.py | 80 ++++++++++++++++++++++ 4 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 tests/test_genotype_novel.py diff --git a/site_docs/guides/genotype.md b/site_docs/guides/genotype.md index 0d31405..96f82e2 100644 --- a/site_docs/guides/genotype.md +++ b/site_docs/guides/genotype.md @@ -233,6 +233,46 @@ for row in g.to_table(): print(row["gene"], row["zygosity"], row["haplotype_0"], row["haplotype_1"]) ``` +## Novel / private alleles + +Individuals carry germline alleles that aren't in any reference — *private* or +*novel* alleles. Discovering them is a central task for IgDiscover, partis, and +TIgGER's `findNovelAlleles`. GenAIRR can plant them as ground truth. + +`add_novel_allele` derives a private allele from a reference **base** allele by +applying point `mutations` (or supplying an explicit `sequence` of the same +length), inheriting the base's gene, anchor, functional status and V sub-regions. +The novel allele is then placed like any allele, and at `compile()` time it is +injected into an **effective reference** (base catalogue + your private alleles) +so it flows through alignment and AIRR output as a genuine allele: + +```python +g = ( + Genotype.from_dataconfig(cfg) + .add_novel_allele("IGHVF1-G1*i01", base="IGHVF1-G1*01", + mutations=[(120, "T"), (250, "G")]) # two point variants + .complete_from_reference() + .heterozygous("IGHVF1-G1", "IGHVF1-G1*01", "IGHVF1-G1*i01") # one reference + one private + .with_subject("DONOR_N") +) + +result = ( + ga.Experiment.on(cfg).with_genotype(g).recombine() + .run_records(n=500, seed=3, expose_provenance=True) +) +# The private allele is sampled, assembled and reported like any allele — +# its name appears in v_call / truth_v_call and the reads carry its variants. +``` + +Novel alleles are flagged in the ground truth: each `to_table()` row carries a +`novel` list of the private alleles carried at that gene. + +**Benchmarking novel-allele discovery.** Plant a novel allele, simulate, then run +the discovery tool against the **base** germline (the cartridge *without* your +private alleles) so the tool must rediscover it from the reads — and score its +output against the planted novel sequence. (Write the base germline FASTA from +`cfg.v_alleles`; write the truth from `genotype.to_table()`.) + ## Ground truth and provenance A genotype experiment emits, by construction, everything an evaluation needs: @@ -373,9 +413,6 @@ deletion calls. The genotype foundation is deliberately scoped. Deferred to later work: -- **Novel / private alleles** — per-individual germline variants not in the - reference (synthesis + provenance). Today a genotype draws from reference - alleles only. - **Cohorts** — many subjects, each with their own genotype, in one run (`with_genotype` is single-subject; `result.genotypes` is a one-element list). - **Population priors** — sampling a plausible diploid genotype from diff --git a/src/GenAIRR/experiment.py b/src/GenAIRR/experiment.py index 11d070f..7c9a61d 100644 --- a/src/GenAIRR/experiment.py +++ b/src/GenAIRR/experiment.py @@ -2837,16 +2837,33 @@ def compile(self, *, allow_curatable_refdata: Optional[bool] = None): metadata=self._metadata, ) + # When the attached genotype defines novel/private alleles, compile + # against an *effective* reference = base catalogue + injected novel + # alleles, so they become real pool entries the engine samples, + # assembles, and reports like any allele. No genotype, or a genotype + # without novel alleles, uses the base refdata unchanged. + effective_refdata = self._refdata + if self._genotype is not None and self._genotype.has_novel(): + if self._dataconfig is None: + raise ValueError( + "genotype with novel alleles requires a DataConfig-backed " + "experiment (Experiment.on(dataconfig), not a raw RefDataConfig)" + ) + effective_refdata = dataconfig_to_refdata( + self._genotype.effective_dataconfig() + ) + simulator = self._build_simulator( self._steps, contracts, any_lock, replace_fn=_replace, allow_curatable_refdata=allow_curatable_refdata, + refdata=effective_refdata, ) return CompiledExperiment( simulator, - self._refdata, + effective_refdata, steps=tuple(self._steps), dataconfig=self._dataconfig, metadata=self._metadata, @@ -2861,10 +2878,16 @@ def _build_simulator( *, replace_fn, allow_curatable_refdata: bool = False, + refdata=None, ): """Compile a list of steps into a `GenAIRR._engine.CompiledSimulator`. Lifted out of `compile()` so the clonal-fork branch can build - two simulators from sub-step-lists with a shared body.""" + two simulators from sub-step-lists with a shared body. + + ``refdata`` overrides ``self._refdata`` — used when a genotype with + novel alleles compiles against an *effective* reference (base + + injected private alleles).""" + refdata = refdata if refdata is not None else self._refdata plan = _engine.PassPlan() # Pull the (at-most-one) `_InvertDStep` out of the step # sequence and thread its probability into the recombine @@ -2906,13 +2929,13 @@ def _build_simulator( _lower_recombine( step, plan, - self._refdata, + refdata, invert_d_prob=invert_d_prob, receptor_revision_prob=receptor_revision_prob, genotype=self._genotype, ) else: - lower_step(step, plan, self._refdata) + lower_step(step, plan, refdata) # Paired-end is sequencing-stage / readout-stage: lower # it AFTER every biology + corruption pass so the trace # records land last. See `_extract_paired_end_step` for @@ -2920,7 +2943,7 @@ def _build_simulator( if paired_end_step is not None: _lower_paired_end(paired_end_step, plan) return plan.compile( - refdata=self._refdata, + refdata=refdata, respect=contracts, allow_curatable_refdata=allow_curatable_refdata, ) diff --git a/src/GenAIRR/genotype.py b/src/GenAIRR/genotype.py index 09314e4..d2db6bd 100644 --- a/src/GenAIRR/genotype.py +++ b/src/GenAIRR/genotype.py @@ -39,6 +39,10 @@ def __init__(self, cfg, *, permissive: bool = False): self._slots: Dict[str, Dict[str, List[List[Tuple[str, int, float]]]]] = { s: {} for s in _SEGMENTS } + # Private/novel alleles defined on this individual: + # name -> {"allele": , "gene": str, "segment": str, + # "base": str, "mutations": list} + self._novel: Dict[str, Dict] = {} self._source_hash: str = cfg.cartridge_manifest()["hashes"]["refdata_content_hash"] # ── constructors ────────────────────────────────────────────── @@ -79,8 +83,17 @@ def _check_allele(self, segment: str, gene: str, allele: str) -> None: if gene not in by_gene: raise ValueError(f"{gene!r} is not a known {segment} gene in this cartridge") names = {a.name for a in by_gene[gene]} + # also accept novel alleles defined on this genotype for this gene + names |= { + n + for n, info in self._novel.items() + if info["gene"] == gene and info["segment"] == segment + } if allele not in names: - raise ValueError(f"{allele!r} is not a known allele of {gene!r}") + raise ValueError( + f"{allele!r} is not a known allele of {gene!r} " + f"(define novel alleles with add_novel_allele first)" + ) def homozygous(self, gene: str, allele: str, segment: str = "V") -> "Genotype": self._check_allele(segment, gene, allele) @@ -128,6 +141,111 @@ def duplicate_gene( self._slots[segment][gene] = cur return self + # ── novel / private alleles ─────────────────────────────────── + def _find_ref_allele(self, segment: str, name: str): + for alleles in _alleles_by_gene(self._cfg, segment).values(): + for a in alleles: + if a.name == name: + return a + return None + + def add_novel_allele( + self, + name: str, + *, + base: str, + mutations: Optional[List[Tuple[int, str]]] = None, + sequence: Optional[str] = None, + segment: str = "V", + gene: Optional[str] = None, + ) -> "Genotype": + """Define a private/novel allele not present in the reference. + + Derive it from a reference ``base`` allele by either applying + point ``mutations`` (a list of ``(0-based position, base)``) or by + supplying an explicit ``sequence`` (same length as the base, so the + inherited anchor/sub-regions stay valid). Gene, anchor, functional + status and V sub-regions are inherited from the base allele. + + Registers the novel allele under ``name`` so it can then be placed + with :meth:`homozygous` / :meth:`heterozygous` / :meth:`duplicate_gene` + like any allele. At compile time it is injected as a real entry in + an *effective* reference, so it flows through alignment and AIRR + output exactly like a catalogue allele. + """ + import copy as _copy + + base_allele = self._find_ref_allele(segment, base) + if base_allele is None: + raise ValueError(f"base allele {base!r} not found in {segment} reference") + gene = gene or base_allele.gene + if gene not in _alleles_by_gene(self._cfg, segment): + raise ValueError(f"{gene!r} is not a known {segment} gene in this cartridge") + # name must not collide with a catalogue allele or another novel. + if self._find_ref_allele(segment, name) is not None or name in self._novel: + raise ValueError(f"novel allele name {name!r} collides with an existing allele") + if (mutations is None) == (sequence is None): + raise ValueError("provide exactly one of `mutations` or `sequence`") + + seq = list(base_allele.ungapped_seq.upper()) + if sequence is not None: + sequence = sequence.upper() + if len(sequence) != len(seq): + raise ValueError( + f"explicit sequence length {len(sequence)} != base length {len(seq)}; " + "use `mutations` for indels-free variants or match the base length" + ) + if any(b not in "ACGT" for b in sequence): + raise ValueError("sequence must contain only A/C/G/T") + new_seq = sequence + else: + if not mutations: + raise ValueError("`mutations` must be a non-empty list of (position, base)") + for pos, b in mutations: + if not (0 <= pos < len(seq)): + raise ValueError(f"mutation position {pos} out of range [0,{len(seq)})") + if b.upper() not in "ACGT": + raise ValueError(f"mutation base {b!r} must be A/C/G/T") + seq[pos] = b.upper() + new_seq = "".join(seq) + if new_seq == base_allele.ungapped_seq.upper(): + raise ValueError("novel allele is identical to its base allele") + + novel = _copy.deepcopy(base_allele) + novel.name = name + novel.ungapped_seq = new_seq + if hasattr(novel, "ungapped_len"): + novel.ungapped_len = len(new_seq) + self._novel[name] = { + "allele": novel, + "gene": gene, + "segment": segment, + "base": base, + "mutations": list(mutations) if mutations else None, + } + return self + + def has_novel(self) -> bool: + return bool(self._novel) + + def novel_allele_names(self) -> Set[str]: + return set(self._novel) + + def effective_dataconfig(self): + """Return a copy of the source ``DataConfig`` with this genotype's + novel alleles appended to their genes' allele lists — the reference + the engine actually runs against when novel alleles are present.""" + import copy as _copy + + cfg = _copy.deepcopy(self._cfg) + by_seg = {"V": cfg.v_alleles, "D": cfg.d_alleles, "J": cfg.j_alleles} + for info in self._novel.values(): + d = by_seg[info["segment"]] + existing = list(d.get(info["gene"], [])) + existing.append(_copy.deepcopy(info["allele"])) + d[info["gene"]] = existing + return cfg + def complete_from_reference( self, policy: str = "homozygous_first_reference" ) -> "Genotype": @@ -172,6 +290,7 @@ def _snapshot(self) -> "Genotype": g.subject_id = self.subject_id g._chromosome_weights = self._chromosome_weights g._slots = _copy.deepcopy(self._slots) + g._novel = _copy.deepcopy(self._novel) g._source_hash = self._source_hash return g @@ -210,6 +329,8 @@ def to_table(self) -> List[Dict]: for seg in _SEGMENTS: for gene, haps in self._slots[seg].items(): h0, h1 = haps[0], haps[1] + carried = {a for (a, _, _) in h0} | {a for (a, _, _) in h1} + novel_here = sorted(carried & set(self._novel)) rows.append( { "subject_id": self.subject_id, @@ -221,6 +342,7 @@ def to_table(self) -> List[Dict]: # per-haplotype (allele, copies, weight) detail "haplotype_0_detail": sorted(h0), "haplotype_1_detail": sorted(h1), + "novel": novel_here, # carried alleles that are private/novel "permissive": self._permissive, } ) diff --git a/tests/test_genotype_novel.py b/tests/test_genotype_novel.py new file mode 100644 index 0000000..2bea146 --- /dev/null +++ b/tests/test_genotype_novel.py @@ -0,0 +1,80 @@ +"""Novel / private allele support on genotypes (PR additions).""" +import pytest + +import GenAIRR as ga +import GenAIRR.data as gdata +from GenAIRR.genotype import Genotype + + +def _cfg(): + return gdata.HUMAN_IGH_OGRDB + + +def test_add_novel_allele_synthesizes_from_base_and_mutations(): + cfg = _cfg() + base = cfg.v_alleles["IGHVF1-G1"][0] + g = Genotype.from_dataconfig(cfg).add_novel_allele( + "IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=[(120, "T"), (130, "A")] + ) + assert g.has_novel() + assert "IGHVF1-G1*i01" in g.novel_allele_names() + nv = g._novel["IGHVF1-G1*i01"]["allele"] + assert nv.ungapped_seq[120] == "T" and nv.ungapped_seq[130] == "A" + assert len(nv.ungapped_seq) == len(base.ungapped_seq) # point mutations: no length change + assert nv.gene == "IGHVF1-G1" and nv.anchor == base.anchor # metadata inherited + + +def test_novel_allele_validation(): + cfg = _cfg() + G = lambda: Genotype.from_dataconfig(cfg) + with pytest.raises(ValueError, match="base allele"): + G().add_novel_allele("X*i01", base="NOPE*01", mutations=[(1, "T")]) + with pytest.raises(ValueError, match="collides"): + G().add_novel_allele("IGHVF1-G1*01", base="IGHVF1-G1*01", mutations=[(1, "T")]) + with pytest.raises(ValueError, match="out of range"): + G().add_novel_allele("X*i01", base="IGHVF1-G1*01", mutations=[(99999, "T")]) + with pytest.raises(ValueError, match="exactly one"): + G().add_novel_allele("X*i01", base="IGHVF1-G1*01") # neither mutations nor sequence + with pytest.raises(ValueError, match="identical"): + # mutate to the same base it already is + b = cfg.v_alleles["IGHVF1-G1"][0].ungapped_seq.upper() + G().add_novel_allele("X*i01", base="IGHVF1-G1*01", mutations=[(0, b[0])]) + + +def test_novel_allele_placed_and_sampled_appears_in_airr(): + cfg = _cfg() + gene = "IGHVF1-G1" + base_seq = cfg.v_alleles[gene][0].ungapped_seq.upper() + pos = 120 + new = "T" if base_seq[pos] != "T" else "A" + g = ( + Genotype.from_dataconfig(cfg) + .add_novel_allele(f"{gene}*i01", base=f"{gene}*01", mutations=[(pos, new)]) + .complete_from_reference() + .homozygous(gene, f"{gene}*i01") # carry ONLY the novel allele for this gene + .with_subject("DONOR_N") + ) + res = ( + ga.Experiment.on(cfg) + .with_genotype(g) + .recombine() + .run_records(n=300, seed=3, expose_provenance=True) + ) + # every read assigned to this gene must be the novel allele (truth) + truth_for_gene = { + r["truth_v_call"] for r in res if r["truth_v_call"].startswith(gene + "*") + } + assert truth_for_gene == {f"{gene}*i01"}, truth_for_gene + # and the engine actually produced the mutated base in those reads + novel_reads = [r for r in res if r["truth_v_call"] == f"{gene}*i01"] + assert novel_reads, "expected some reads from the novel allele" + assert any(r["sequence"][pos].upper() == new for r in novel_reads) + + +def test_genotype_without_novel_is_unaffected(): + cfg = _cfg() + g = Genotype.from_dataconfig(cfg).complete_from_reference().with_subject("S1") + assert g.has_novel() is False + # still runs (uses base refdata) + res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records(n=10, seed=1) + assert len(res) == 10 From 234c341a291663abbe1f2acdccf5168b94f45084 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 16 Jun 2026 19:53:38 +0300 Subject: [PATCH 25/26] =?UTF-8?q?fix(genotype):=20novel-allele=20review=20?= =?UTF-8?q?round=20=E2=80=94=20functional=20validation,=20gene=20identity,?= =?UTF-8?q?=20gapped=20projection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses critic findings on the novel-allele slice: - #1 functional validation: synthesized V/J coding sequence is checked for an intact conserved anchor codon (Cys/Trp|Phe) and stop-free coding frame; a broken variant is rejected unless allow_nonfunctional=True (then kept + marked non-functional). Closes the 'nonfunctional emitted as productive' gap. - #2 gene identity: the novel allele's gene is taken from its NAME and must equal the base allele's gene; dropped the gene= override that left allele.gene stale. - #3 anchor: inherited from base (correct for same-length/substitution-only variants — the conserved residue does not move) and validated to remain intact; no reliance on the unavailable _native anchor resolver. - #4 name uniqueness enforced across all segments + novel set (prevents truth-table mislabeling). - #5 to_tsv now emits the 'novel' column. - #6 substitutions projected onto the gapped sequence (no stale gapped_seq). - #7 mutation positions/bases type-checked with clean ValueErrors. Tests expanded: stop/anchor rejection + allow_nonfunctional, gene-mismatch, cross-segment collision, tsv export, type-check. --- site_docs/guides/genotype.md | 12 +++- src/GenAIRR/genotype.py | 113 ++++++++++++++++++++++++++++------- tests/test_genotype_novel.py | 112 ++++++++++++++++++++++++++-------- 3 files changed, 187 insertions(+), 50 deletions(-) diff --git a/site_docs/guides/genotype.md b/site_docs/guides/genotype.md index 96f82e2..3879bce 100644 --- a/site_docs/guides/genotype.md +++ b/site_docs/guides/genotype.md @@ -250,7 +250,7 @@ so it flows through alignment and AIRR output as a genuine allele: g = ( Genotype.from_dataconfig(cfg) .add_novel_allele("IGHVF1-G1*i01", base="IGHVF1-G1*01", - mutations=[(120, "T"), (250, "G")]) # two point variants + mutations=[(38, "C"), (41, "A")]) # two point variants .complete_from_reference() .heterozygous("IGHVF1-G1", "IGHVF1-G1*01", "IGHVF1-G1*i01") # one reference + one private .with_subject("DONOR_N") @@ -264,8 +264,14 @@ result = ( # its name appears in v_call / truth_v_call and the reads carry its variants. ``` -Novel alleles are flagged in the ground truth: each `to_table()` row carries a -`novel` list of the private alleles carried at that gene. +The novel allele's **gene is taken from its name** and must match the base +allele's gene; it must be a same-length (substitution-only) variant. The +synthesized coding sequence is **validated** — for V/J the conserved anchor codon +must still encode the conserved residue (Cys for V, Trp/Phe for J) and the coding +frame must be stop-free. A variant that breaks either is rejected unless you pass +`allow_nonfunctional=True` (then it is kept and marked non-functional). Novel +alleles are flagged in the ground truth: each `to_table()`/`to_tsv()` row carries +a `novel` list of the private alleles carried at that gene. **Benchmarking novel-allele discovery.** Plant a novel allele, simulate, then run the discovery tool against the **base** germline (the cartridge *without* your diff --git a/src/GenAIRR/genotype.py b/src/GenAIRR/genotype.py index d2db6bd..fca72c3 100644 --- a/src/GenAIRR/genotype.py +++ b/src/GenAIRR/genotype.py @@ -157,71 +157,136 @@ def add_novel_allele( mutations: Optional[List[Tuple[int, str]]] = None, sequence: Optional[str] = None, segment: str = "V", - gene: Optional[str] = None, + allow_nonfunctional: bool = False, ) -> "Genotype": """Define a private/novel allele not present in the reference. - Derive it from a reference ``base`` allele by either applying - point ``mutations`` (a list of ``(0-based position, base)``) or by - supplying an explicit ``sequence`` (same length as the base, so the - inherited anchor/sub-regions stay valid). Gene, anchor, functional - status and V sub-regions are inherited from the base allele. + Derive it from a reference ``base`` allele by either applying point + ``mutations`` (a list of ``(0-based position, base)``) or supplying + an explicit same-length ``sequence`` (substitutions only — no + indels — so the gene's reading frame and the conserved-anchor + position are preserved). The novel allele's **gene is taken from + its name** and must equal the base allele's gene; sub-regions and + the anchor position are inherited (valid for same-length variants). + + The synthesized coding sequence is **validated**: for V/J the + conserved anchor codon must still encode the conserved residue + (Cys for V, Trp/Phe for J) and the coding frame must contain no + internal stop codon. A variant that breaks either is rejected + unless ``allow_nonfunctional=True`` (in which case it is kept and + marked non-functional). Registers the novel allele under ``name`` so it can then be placed - with :meth:`homozygous` / :meth:`heterozygous` / :meth:`duplicate_gene` - like any allele. At compile time it is injected as a real entry in - an *effective* reference, so it flows through alignment and AIRR - output exactly like a catalogue allele. + with :meth:`homozygous` / :meth:`heterozygous` / :meth:`duplicate_gene`. + At compile time it is injected as a real entry in an *effective* + reference, so it flows through alignment and AIRR output like a + catalogue allele. """ import copy as _copy + from .utilities.misc import translate + base_allele = self._find_ref_allele(segment, base) if base_allele is None: raise ValueError(f"base allele {base!r} not found in {segment} reference") - gene = gene or base_allele.gene - if gene not in _alleles_by_gene(self._cfg, segment): - raise ValueError(f"{gene!r} is not a known {segment} gene in this cartridge") - # name must not collide with a catalogue allele or another novel. - if self._find_ref_allele(segment, name) is not None or name in self._novel: - raise ValueError(f"novel allele name {name!r} collides with an existing allele") + # Gene identity comes from the name; it must match the base's gene + # (no cross-gene synthesis — that would corrupt gene identity). + name_gene = name.split("*")[0] + if name_gene != base_allele.gene: + raise ValueError( + f"novel name {name!r} implies gene {name_gene!r} but base {base!r} " + f"belongs to gene {base_allele.gene!r}; a novel allele must belong " + f"to its base allele's gene" + ) + gene = base_allele.gene + # Name must be unique across the WHOLE catalogue (all segments) and + # all previously-defined novel alleles. + if name in self._novel: + raise ValueError(f"novel allele name {name!r} already defined") + for seg in _SEGMENTS: + if self._find_ref_allele(seg, name) is not None: + raise ValueError(f"novel allele name {name!r} collides with a catalogue allele") if (mutations is None) == (sequence is None): raise ValueError("provide exactly one of `mutations` or `sequence`") - seq = list(base_allele.ungapped_seq.upper()) + base_ungapped = base_allele.ungapped_seq.upper() + gapped = list(base_allele.gapped_seq) + # ungapped index -> gapped index (positions of non-gap characters) + ung_to_gap = [i for i, ch in enumerate(base_allele.gapped_seq) if ch != "."] + seq = list(base_ungapped) if sequence is not None: sequence = sequence.upper() if len(sequence) != len(seq): raise ValueError( f"explicit sequence length {len(sequence)} != base length {len(seq)}; " - "use `mutations` for indels-free variants or match the base length" + "novel alleles are substitution-only (same length as the base)" ) if any(b not in "ACGT" for b in sequence): raise ValueError("sequence must contain only A/C/G/T") - new_seq = sequence + seq = list(sequence) else: if not mutations: raise ValueError("`mutations` must be a non-empty list of (position, base)") for pos, b in mutations: + if not isinstance(pos, int) or isinstance(pos, bool): + raise ValueError(f"mutation position must be an int, got {pos!r}") + if not (isinstance(b, str) and len(b) == 1): + raise ValueError(f"mutation base must be a single character, got {b!r}") if not (0 <= pos < len(seq)): raise ValueError(f"mutation position {pos} out of range [0,{len(seq)})") if b.upper() not in "ACGT": raise ValueError(f"mutation base {b!r} must be A/C/G/T") seq[pos] = b.upper() - new_seq = "".join(seq) - if new_seq == base_allele.ungapped_seq.upper(): + new_ungapped = "".join(seq) + if new_ungapped == base_ungapped: raise ValueError("novel allele is identical to its base allele") + # Project the substitutions onto the gapped sequence too, so + # gap-dependent metadata stays consistent. + for k, b in enumerate(seq): + gapped[ung_to_gap[k]] = b novel = _copy.deepcopy(base_allele) novel.name = name - novel.ungapped_seq = new_seq + novel.gene = gene + novel.ungapped_seq = new_ungapped + novel.gapped_seq = "".join(gapped) if hasattr(novel, "ungapped_len"): - novel.ungapped_len = len(new_seq) + novel.ungapped_len = len(new_ungapped) + + # Functional validation (V/J have a conserved coding frame). + functional, reason = True, None + anchor = getattr(novel, "anchor", None) + if segment in ("V", "J") and anchor is not None: + conserved = {"V": {"C"}, "J": {"W", "F"}}[segment] + anchor_aa = translate(new_ungapped[anchor : anchor + 3]) + if anchor_aa not in conserved: + functional = False + reason = ( + f"conserved anchor codon now encodes {anchor_aa!r}, " + f"expected one of {sorted(conserved)}" + ) + coding = new_ungapped[:anchor] if segment == "V" else new_ungapped[anchor:] + if "*" in translate(coding): + reason = (reason + "; " if reason else "") + "internal stop codon in coding frame" + functional = False + if not functional and not allow_nonfunctional: + raise ValueError( + f"novel allele {name!r} is non-functional ({reason}); pass " + f"allow_nonfunctional=True to keep it anyway" + ) + if not functional: + try: + novel.functional_status = "pseudogene" + except Exception: + pass + self._novel[name] = { "allele": novel, "gene": gene, "segment": segment, "base": base, "mutations": list(mutations) if mutations else None, + "functional": functional, } return self @@ -366,6 +431,7 @@ def _fmt(detail): "zygosity", "haplotype_0", "haplotype_1", + "novel", "permissive", ] ) @@ -378,6 +444,7 @@ def _fmt(detail): r["zygosity"], _fmt(r["haplotype_0_detail"]), _fmt(r["haplotype_1_detail"]), + ";".join(r["novel"]), r["permissive"], ] ) diff --git a/tests/test_genotype_novel.py b/tests/test_genotype_novel.py index 2bea146..19658d1 100644 --- a/tests/test_genotype_novel.py +++ b/tests/test_genotype_novel.py @@ -1,4 +1,4 @@ -"""Novel / private allele support on genotypes (PR additions).""" +"""Novel / private allele support on genotypes.""" import pytest import GenAIRR as ga @@ -10,46 +10,97 @@ def _cfg(): return gdata.HUMAN_IGH_OGRDB +# Guaranteed-safe SNPs for IGHVF1-G1*01: wobble of non-T-starting codons in +# the framework — cannot create a stop, anchor untouched. +_SAFE = [(38, "C"), (41, "A")] + + def test_add_novel_allele_synthesizes_from_base_and_mutations(): cfg = _cfg() base = cfg.v_alleles["IGHVF1-G1"][0] g = Genotype.from_dataconfig(cfg).add_novel_allele( - "IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=[(120, "T"), (130, "A")] + "IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=_SAFE ) assert g.has_novel() assert "IGHVF1-G1*i01" in g.novel_allele_names() nv = g._novel["IGHVF1-G1*i01"]["allele"] - assert nv.ungapped_seq[120] == "T" and nv.ungapped_seq[130] == "A" - assert len(nv.ungapped_seq) == len(base.ungapped_seq) # point mutations: no length change - assert nv.gene == "IGHVF1-G1" and nv.anchor == base.anchor # metadata inherited + assert nv.ungapped_seq[38] == "C" and nv.ungapped_seq[41] == "A" + assert len(nv.ungapped_seq) == len(base.ungapped_seq) # substitution-only + assert nv.gene == "IGHVF1-G1" and nv.anchor == base.anchor # gene/anchor inherited + # gapped sequence projected consistently (ungapped derived from it) + assert nv.gapped_seq.replace(".", "") == nv.ungapped_seq + assert g._novel["IGHVF1-G1*i01"]["functional"] is True -def test_novel_allele_validation(): +def test_novel_name_gene_must_match_base_gene(): + cfg = _cfg() + with pytest.raises(ValueError, match="implies gene"): + Genotype.from_dataconfig(cfg).add_novel_allele( + "IGHVF1-G2*i01", base="IGHVF1-G1*01", mutations=_SAFE # name gene != base gene + ) + + +def test_novel_allele_basic_validation(): cfg = _cfg() G = lambda: Genotype.from_dataconfig(cfg) with pytest.raises(ValueError, match="base allele"): - G().add_novel_allele("X*i01", base="NOPE*01", mutations=[(1, "T")]) - with pytest.raises(ValueError, match="collides"): - G().add_novel_allele("IGHVF1-G1*01", base="IGHVF1-G1*01", mutations=[(1, "T")]) + G().add_novel_allele("NOPE*i01", base="NOPE*01", mutations=[(1, "T")]) + with pytest.raises(ValueError, match="collides with a catalogue"): + G().add_novel_allele("IGHVF1-G1*01", base="IGHVF1-G1*01", mutations=_SAFE) with pytest.raises(ValueError, match="out of range"): - G().add_novel_allele("X*i01", base="IGHVF1-G1*01", mutations=[(99999, "T")]) + G().add_novel_allele("IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=[(99999, "T")]) with pytest.raises(ValueError, match="exactly one"): - G().add_novel_allele("X*i01", base="IGHVF1-G1*01") # neither mutations nor sequence - with pytest.raises(ValueError, match="identical"): - # mutate to the same base it already is - b = cfg.v_alleles["IGHVF1-G1"][0].ungapped_seq.upper() - G().add_novel_allele("X*i01", base="IGHVF1-G1*01", mutations=[(0, b[0])]) + G().add_novel_allele("IGHVF1-G1*i01", base="IGHVF1-G1*01") + with pytest.raises(ValueError, match="must be an int"): + G().add_novel_allele("IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=[(1.5, "A")]) + + +def test_cross_segment_name_collision_rejected(): + # A novel can't be named for a different gene/segment than its base, so + # naming a V-derived novel after a real J allele is rejected outright — + # the truth table can never mislabel the real J row as novel. + cfg = _cfg() + j_name = next(iter(cfg.j_alleles[next(iter(cfg.j_alleles))])).name + with pytest.raises(ValueError): + Genotype.from_dataconfig(cfg).add_novel_allele( + j_name, base="IGHVF1-G1*01", mutations=_SAFE + ) + + +def test_nonfunctional_novel_rejected_by_default_and_allowed_explicitly(): + cfg = _cfg() + base = cfg.v_alleles["IGHVF1-G1"][0] + # Force a stop codon at framework codon 13 (positions 39,40,41 -> TAA). + stop = [(39, "T"), (40, "A"), (41, "A")] + with pytest.raises(ValueError, match="non-functional.*stop codon"): + Genotype.from_dataconfig(cfg).add_novel_allele( + "IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=stop + ) + # Explicit override keeps it, marked non-functional. + g = Genotype.from_dataconfig(cfg).add_novel_allele( + "IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=stop, allow_nonfunctional=True + ) + assert g._novel["IGHVF1-G1*i01"]["functional"] is False + + +def test_broken_anchor_codon_rejected(): + cfg = _cfg() + base = cfg.v_alleles["IGHVF1-G1"][0] + a = base.anchor + # rewrite the conserved Cys anchor codon to GGG (Gly) + with pytest.raises(ValueError, match="conserved anchor codon"): + Genotype.from_dataconfig(cfg).add_novel_allele( + "IGHVF1-G1*i01", base="IGHVF1-G1*01", + mutations=[(a, "G"), (a + 1, "G"), (a + 2, "G")], + ) def test_novel_allele_placed_and_sampled_appears_in_airr(): cfg = _cfg() gene = "IGHVF1-G1" - base_seq = cfg.v_alleles[gene][0].ungapped_seq.upper() - pos = 120 - new = "T" if base_seq[pos] != "T" else "A" g = ( Genotype.from_dataconfig(cfg) - .add_novel_allele(f"{gene}*i01", base=f"{gene}*01", mutations=[(pos, new)]) + .add_novel_allele(f"{gene}*i01", base=f"{gene}*01", mutations=_SAFE) .complete_from_reference() .homozygous(gene, f"{gene}*i01") # carry ONLY the novel allele for this gene .with_subject("DONOR_N") @@ -60,21 +111,34 @@ def test_novel_allele_placed_and_sampled_appears_in_airr(): .recombine() .run_records(n=300, seed=3, expose_provenance=True) ) - # every read assigned to this gene must be the novel allele (truth) truth_for_gene = { r["truth_v_call"] for r in res if r["truth_v_call"].startswith(gene + "*") } assert truth_for_gene == {f"{gene}*i01"}, truth_for_gene - # and the engine actually produced the mutated base in those reads novel_reads = [r for r in res if r["truth_v_call"] == f"{gene}*i01"] - assert novel_reads, "expected some reads from the novel allele" - assert any(r["sequence"][pos].upper() == new for r in novel_reads) + assert novel_reads + assert any(r["sequence"][38].upper() == "C" for r in novel_reads) + + +def test_to_table_and_tsv_expose_novel(tmp_path): + cfg = _cfg() + gene = "IGHVF1-G1" + g = ( + Genotype.from_dataconfig(cfg) + .add_novel_allele(f"{gene}*i01", base=f"{gene}*01", mutations=_SAFE) + .heterozygous(gene, f"{gene}*01", f"{gene}*i01") + ) + row = next(r for r in g.to_table() if r["gene"] == gene) + assert row["novel"] == [f"{gene}*i01"] + p = tmp_path / "truth.tsv" + g.to_tsv(str(p)) + header = p.read_text().splitlines()[0].split("\t") + assert "novel" in header def test_genotype_without_novel_is_unaffected(): cfg = _cfg() g = Genotype.from_dataconfig(cfg).complete_from_reference().with_subject("S1") assert g.has_novel() is False - # still runs (uses base refdata) res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records(n=10, seed=1) assert len(res) == 10 From 6d671e0f7341dee6b6e4406faa1f236a06c2c36e Mon Sep 17 00:00:00 2001 From: thomas Date: Wed, 17 Jun 2026 09:52:32 +0300 Subject: [PATCH 26/26] =?UTF-8?q?fix(genotype):=20novel-allele=20review=20?= =?UTF-8?q?round=204=20=E2=80=94=20no=20unplaced=20leak,=20arg=20validatio?= =?UTF-8?q?n,=20docs=20reproducibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - effective_dataconfig injects ONLY carried novel alleles (defined-but-unplaced novels no longer leak into the aligner reference / v_call). High. - add_novel_allele tolerates missing/empty/mismatched gapped_seq (falls back to ungapped) instead of IndexError. Medium. - delete_gene / duplicate_gene validate the haplotype argument (both/0/1) instead of silently no-op'ing or negative-indexing. Medium. Docs: - 'Reproduce it' now builds the EXACT 9-gene planted genotype behind the figure, writes reads.fasta (IgDiscover/partis) + germline + truth, and states the scoring + reported precision/recall. High. - Added a method-signature table (segment/haplotype defaults) + a D/J example; a 'Supported loci and chains' note (VDJ vs VJ, BCR/TCR); 'at compile time' wording fix. Tests: unplaced-novel non-leak, haplotype validation, missing-gapped-seq fallback. --- site_docs/guides/genotype.md | 78 ++++++++++++++++++++++++++++++------ src/GenAIRR/genotype.py | 53 +++++++++++++++++++----- tests/test_genotype_novel.py | 51 +++++++++++++++++++++++ 3 files changed, 158 insertions(+), 24 deletions(-) diff --git a/site_docs/guides/genotype.md b/site_docs/guides/genotype.md index 3879bce..b3a8107 100644 --- a/site_docs/guides/genotype.md +++ b/site_docs/guides/genotype.md @@ -35,6 +35,15 @@ a D and a J **from the same chromosome**. That linkage is exactly the signal haplotype-inference methods exploit (e.g. the IGHJ6-anchor approach), and GenAIRR reproduces it. +!!! note "Supported loci and chains" + Genotypes work on any GenAIRR reference cartridge — BCR **and** TCR, heavy + **and** light/α/β chains. On **VDJ** loci (IGH, TRB, TRD) the genotype spans + V, D and J and each rearrangement draws all three from one chromosome. On + **VJ** loci (IGK, IGL, TRA, TRG) there is no D segment: genotype V and J, + D rows are simply not required and are ignored. The examples below use the + human IGH cartridge, but the same API applies to every locus; just use that + cartridge's gene/allele names. + ## Quick start ```python @@ -100,7 +109,8 @@ the J drawn later (the phased choices are evaluated together, not independently) ### Strict vs permissive `Genotype.from_dataconfig(cfg)` is **strict**: any gene that could be used during -recombination but was never specified is an error at attach time — you must define +recombination but was never specified is an error when the experiment is compiled +(`compile()` / `run_records()`) — you must define the whole genotype (use `complete_from_reference` to fill the genes you don't care about). This guarantees a genuine diploid complement, which is what you want for a ground-truth benchmark. @@ -136,6 +146,27 @@ g.with_subject("DONOR01") # provenance label g.complete_from_reference("homozygous_first_reference") # fill every unspecified gene ``` +Every editing method takes a `segment` argument (`"V"` default, or `"D"` / `"J"`), +so genotype the D and J loci too — important since J anchors and D/J usage drive +haplotype-inference methods: + +| Method | Signature | Notes | +|---|---|---| +| `homozygous` | `(gene, allele, segment="V")` | one allele on both chromosomes | +| `heterozygous` | `(gene, allele0, allele1, segment="V")` | one allele per chromosome | +| `delete_gene` | `(gene, haplotype="both"\|0\|1, segment="V")` | whole-gene or one-chromosome (hemizygous) deletion | +| `duplicate_gene` | `(gene, alleles=[...], haplotype=0\|1, segment="V")` | >1 copy on one chromosome | +| `add_novel_allele` | `(name, *, base, mutations\|sequence, segment="V", allow_nonfunctional=False)` | define a private allele (see below) | +| `chromosome_weights` | `(w0, w1)` | allelic-expression imbalance (default 0.5/0.5) | +| `with_subject` | `(sid)` | provenance label stamped on every record | +| `complete_from_reference` | `(policy="homozygous_first_reference"\|"heterozygous_first_two")` | fill unspecified genes | + +```python +# Genotype the J locus too — e.g. heterozygous IGHJ6 + a homozygous IGHJ4: +g.heterozygous("IGHJ6", "IGHJ6*02", "IGHJ6*03", segment="J") +g.homozygous("IGHJ4", "IGHJ4*02", segment="J") +``` + Notes and guard-rails: - **`delete_gene(..., haplotype=0|1)`** (one chromosome) requires the gene to be @@ -359,36 +390,57 @@ deletions correct.* ### Reproduce it +This builds the **exact** genotype behind the figure — 3 heterozygous, 3 +homozygous, and 3 deleted study V genes, the rest filled from the reference — +simulates 4,000 reads with light SHM at `seed=7`, and writes every input the two +tools need plus the ground truth to score against: + ```python import GenAIRR as ga import GenAIRR.data as gdata from GenAIRR.genotype import Genotype cfg = gdata.HUMAN_IGH_OGRDB -g = ( - Genotype.from_dataconfig(cfg) - .complete_from_reference("homozygous_first_reference") - .heterozygous("IGHVF1-G1", "IGHVF1-G1*01", "IGHVF1-G1*02") - .homozygous("IGHVF2-G4", "IGHVF2-G4*01") - .delete_gene("IGHVF3-G7", haplotype="both") - .with_subject("DONOR01") -) +HET = ["IGHVF1-G1", "IGHVF1-G2", "IGHVF1-G3"] # 2 alleles each +HOM = ["IGHVF2-G4", "IGHVF3-G5", "IGHVF3-G6"] # 1 allele +DEL = ["IGHVF3-G7", "IGHVF3-G8", "IGHVF3-G9"] # deleted (both chromosomes) + +g = Genotype.from_dataconfig(cfg).complete_from_reference("homozygous_first_reference") +for gene in HET: + a0, a1 = (a.name for a in cfg.v_alleles[gene][:2]) + g.heterozygous(gene, a0, a1) +for gene in HOM: + g.homozygous(gene, cfg.v_alleles[gene][0].name) +for gene in DEL: + g.delete_gene(gene, haplotype="both") +g.with_subject("DONOR01") + res = ( ga.Experiment.on(cfg).with_genotype(g).recombine() .mutate(rate=0.004) # light SHM, as in real data - .run_records(n=4000, seed=7) + .run_records(n=4000, seed=7, expose_provenance=True) ) + res.to_tsv("repertoire.tsv") # AIRR table → TIgGER g.to_tsv("truth_genotype.tsv") # ground truth to score against -# export the cartridge V germline (names match v_call) for TIgGER's germline_db -with open("germline_V.fasta", "w") as fh: +with open("reads.fasta", "w") as fh: # raw reads → IgDiscover / partis + for r in res: + fh.write(f">{r['sequence_id']}\n{r['sequence'].upper()}\n") + +with open("germline_V.fasta", "w") as fh: # cartridge V germline (names match v_call) for gene, alleles in cfg.v_alleles.items(): for a in alleles: fh.write(f">{a.name}\n{a.ungapped_seq.upper()}\n") ``` -Then run the R snippet above and compare `geno` against `truth_genotype.tsv`. +**Score it.** Run TIgGER (R snippet above) on `repertoire.tsv`, or IgDiscover on +`reads.fasta` with the cartridge as its starting database +(`igdiscover init --database db/ --single-reads reads.fasta project/ && cd project +&& igdiscover run`). Then compare each tool's per-gene allele set against +`g.to_table()` (the planted truth): allele-presence precision/recall, zygosity, +and deletion calls. With the genotype above this yields TIgGER precision/recall +1.00 (52/52 genes) and IgDiscover precision 1.00 / recall 0.96 — the figure. ### Running other tools on the same data diff --git a/src/GenAIRR/genotype.py b/src/GenAIRR/genotype.py index fca72c3..89213ea 100644 --- a/src/GenAIRR/genotype.py +++ b/src/GenAIRR/genotype.py @@ -109,6 +109,10 @@ def heterozygous( return self def delete_gene(self, gene: str, haplotype="both", segment: str = "V") -> "Genotype": + if haplotype not in ("both", 0, 1): + raise ValueError( + f"haplotype must be 'both', 0, or 1, got {haplotype!r}" + ) # One-haplotype (hemizygous) deletion requires the gene to be # specified first, otherwise the *other* haplotype would also be # empty — silently producing a full deletion that @@ -133,11 +137,13 @@ def delete_gene(self, gene: str, haplotype="both", segment: str = "V") -> "Genot def duplicate_gene( self, gene: str, alleles: List[str], haplotype: int, segment: str = "V" ) -> "Genotype": + if haplotype not in (0, 1): + raise ValueError(f"haplotype must be 0 or 1, got {haplotype!r}") for a in alleles: self._check_allele(segment, gene, a) cur = self._slots[segment].get(gene, [[], []]) cur = [list(cur[0]), list(cur[1])] - cur[int(haplotype)] = [(a, 1, 1.0) for a in alleles] + cur[haplotype] = [(a, 1, 1.0) for a in alleles] self._slots[segment][gene] = cur return self @@ -210,9 +216,13 @@ def add_novel_allele( raise ValueError("provide exactly one of `mutations` or `sequence`") base_ungapped = base_allele.ungapped_seq.upper() - gapped = list(base_allele.gapped_seq) - # ungapped index -> gapped index (positions of non-gap characters) - ung_to_gap = [i for i, ch in enumerate(base_allele.gapped_seq) if ch != "."] + gapped = list(base_allele.gapped_seq or "") + # ungapped index -> gapped index (positions of non-gap characters). + # Some custom cartridges carry no (or inconsistent) gapped sequence; + # in that case we can't project onto gaps, so fall back to an + # ungapped novel sequence (no gap-derived metadata). + ung_to_gap = [i for i, ch in enumerate(base_allele.gapped_seq or "") if ch != "."] + project_gaps = len(ung_to_gap) == len(base_ungapped) seq = list(base_ungapped) if sequence is not None: sequence = sequence.upper() @@ -241,15 +251,20 @@ def add_novel_allele( if new_ungapped == base_ungapped: raise ValueError("novel allele is identical to its base allele") # Project the substitutions onto the gapped sequence too, so - # gap-dependent metadata stays consistent. - for k, b in enumerate(seq): - gapped[ung_to_gap[k]] = b + # gap-dependent metadata stays consistent. If the base has no + # usable gapped sequence, fall back to the ungapped form. + if project_gaps: + for k, b in enumerate(seq): + gapped[ung_to_gap[k]] = b + new_gapped = "".join(gapped) + else: + new_gapped = new_ungapped novel = _copy.deepcopy(base_allele) novel.name = name novel.gene = gene novel.ungapped_seq = new_ungapped - novel.gapped_seq = "".join(gapped) + novel.gapped_seq = new_gapped if hasattr(novel, "ungapped_len"): novel.ungapped_len = len(new_ungapped) @@ -296,15 +311,31 @@ def has_novel(self) -> bool: def novel_allele_names(self) -> Set[str]: return set(self._novel) + def _carried_allele_names(self) -> Set[str]: + """Allele names actually placed on a haplotype (across segments).""" + names: Set[str] = set() + for seg in _SEGMENTS: + for haps in self._slots[seg].values(): + for hap in haps: + names.update(a for (a, _c, _w) in hap) + return names + def effective_dataconfig(self): """Return a copy of the source ``DataConfig`` with this genotype's - novel alleles appended to their genes' allele lists — the reference - the engine actually runs against when novel alleles are present.""" + **carried** novel alleles appended to their genes' allele lists — + the reference the engine actually runs against when novel alleles + are present. A novel allele that was defined but never placed on a + haplotype is NOT injected (it would otherwise pollute the aligner + reference and could surface in ``v_call`` despite being absent from + the ground truth).""" import copy as _copy cfg = _copy.deepcopy(self._cfg) by_seg = {"V": cfg.v_alleles, "D": cfg.d_alleles, "J": cfg.j_alleles} - for info in self._novel.values(): + carried = self._carried_allele_names() + for name, info in self._novel.items(): + if name not in carried: + continue d = by_seg[info["segment"]] existing = list(d.get(info["gene"], [])) existing.append(_copy.deepcopy(info["allele"])) diff --git a/tests/test_genotype_novel.py b/tests/test_genotype_novel.py index 19658d1..d52d2fd 100644 --- a/tests/test_genotype_novel.py +++ b/tests/test_genotype_novel.py @@ -136,6 +136,57 @@ def test_to_table_and_tsv_expose_novel(tmp_path): assert "novel" in header +def test_unplaced_novel_allele_not_injected_or_emitted(): + cfg = _cfg() + gene = "IGHVF1-G1" + # Define a novel allele but NEVER place it on a haplotype. + g = ( + Genotype.from_dataconfig(cfg) + .add_novel_allele(f"{gene}*unplaced", base=f"{gene}*01", mutations=_SAFE) + .complete_from_reference() + .with_subject("S1") + ) + # Effective reference must not contain the unplaced novel allele. + eff = g.effective_dataconfig() + eff_names = {a.name for alleles in eff.v_alleles.values() for a in alleles} + assert f"{gene}*unplaced" not in eff_names + res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records( + n=200, seed=4, expose_provenance=True + ) + assert all(f"{gene}*unplaced" not in r["v_call"] for r in res) + assert all(f"{gene}*unplaced" not in r["truth_v_call"] for r in res) + + +def test_haplotype_argument_validation(): + cfg = _cfg() + gene = "IGHVF1-G1" + a0 = cfg.v_alleles[gene][0].name + g = Genotype.from_dataconfig(cfg).homozygous(gene, a0) + with pytest.raises(ValueError, match="haplotype must be"): + g.delete_gene(gene, haplotype=2) + with pytest.raises(ValueError, match="haplotype must be"): + g.delete_gene(gene, haplotype="x") + with pytest.raises(ValueError, match="haplotype must be 0 or 1"): + Genotype.from_dataconfig(cfg).duplicate_gene(gene, [a0], haplotype=-1) + with pytest.raises(ValueError, match="haplotype must be 0 or 1"): + Genotype.from_dataconfig(cfg).duplicate_gene(gene, [a0], haplotype=2) + + +def test_novel_synthesis_tolerates_missing_gapped_seq(): + import copy + + cfg = copy.deepcopy(_cfg()) + # Simulate a custom cartridge whose base allele has no gapped sequence. + cfg.v_alleles["IGHVF1-G1"][0].gapped_seq = "" + g = Genotype.from_dataconfig(cfg).add_novel_allele( + "IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=_SAFE + ) + nv = g._novel["IGHVF1-G1*i01"]["allele"] + # falls back to ungapped form (no crash) + assert nv.gapped_seq == nv.ungapped_seq + assert nv.ungapped_seq[38] == "C" + + def test_genotype_without_novel_is_unaffected(): cfg = _cfg() g = Genotype.from_dataconfig(cfg).complete_from_reference().with_subject("S1")