Skip to content

Commit a56db5f

Browse files
authored
Merge: genotypes — per-individual diploid germline (phased recombination + novel alleles)
Phased genotype foundation + novel/private alleles + dedicated docs with a TIgGER/IgDiscover detection showcase. Backward-compatible (no-genotype output unchanged). All CI green.
2 parents ef06c06 + 6d671e0 commit a56db5f

27 files changed

Lines changed: 3158 additions & 18 deletions

engine_rs/src/address.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ const SAMPLE_ALLELE_J: &str = "sample_allele.j";
5454
const SAMPLE_ALLELE_D_INVERTED: &str = "sample_allele.d.inverted";
5555
pub const SAMPLE_ALLELE_INVALID: &str = "sample_allele.<invalid>";
5656
pub const SAMPLE_ALLELE_UNSUPPORTED: &str = "sample_allele.<unsupported>";
57+
/// Per-rearrangement chromosome choice for a phased genotype.
58+
const SAMPLE_HAPLOTYPE: &str = "sample_haplotype";
5759

5860
/// Pass name for `InvertDPass`. Used as the `name()` return value
5961
/// and as the pass-plan signature token, so external consumers
@@ -458,6 +460,16 @@ pub enum ChoiceAddress {
458460
/// from `(allele, trim, orientation, length)` so only the
459461
/// length needs a trace address.
460462
PLength { end: PEnd },
463+
/// Haplotype (0/1): the chromosome drawn once per rearrangement by
464+
/// `SampleHaplotypePass` for a phased genotype. V/D/J read it back.
465+
SampleHaplotype,
466+
/// GeneId: the gene chosen within the drawn chromosome for a segment
467+
/// by `SampleGeneAllelePass`.
468+
SampleGene(VdjSegment),
469+
/// AlleleId: the within-slot allele draw, recorded only when a gene
470+
/// slot carries more than one copy (single-copy slots are
471+
/// deterministic and record nothing here).
472+
SampleAlleleInSlot(VdjSegment),
461473
}
462474

463475
impl ChoiceAddress {
@@ -534,6 +546,11 @@ impl fmt::Display for ChoiceAddress {
534546
Self::PairedEndR2Length => f.write_str(PAIRED_END_R2_LENGTH),
535547
Self::PairedEndInsertSize => f.write_str(PAIRED_END_INSERT_SIZE),
536548
Self::PLength { end } => write!(f, "p.{}.length", end.suffix()),
549+
Self::SampleHaplotype => f.write_str(SAMPLE_HAPLOTYPE),
550+
Self::SampleGene(segment) => write!(f, "sample_gene.{}", segment.suffix()),
551+
Self::SampleAlleleInSlot(segment) => {
552+
write!(f, "sample_allele_in_slot.{}", segment.suffix())
553+
}
537554
}
538555
}
539556
}
@@ -615,6 +632,13 @@ fn parse_choice_address(address: &str) -> Option<ChoiceAddress> {
615632
P_D5_LENGTH => Some(ChoiceAddress::PLength { end: PEnd::D5 }),
616633
P_D3_LENGTH => Some(ChoiceAddress::PLength { end: PEnd::D3 }),
617634
P_J5_LENGTH => Some(ChoiceAddress::PLength { end: PEnd::J5 }),
635+
SAMPLE_HAPLOTYPE => Some(ChoiceAddress::SampleHaplotype),
636+
"sample_gene.v" => Some(ChoiceAddress::SampleGene(VdjSegment::V)),
637+
"sample_gene.d" => Some(ChoiceAddress::SampleGene(VdjSegment::D)),
638+
"sample_gene.j" => Some(ChoiceAddress::SampleGene(VdjSegment::J)),
639+
"sample_allele_in_slot.v" => Some(ChoiceAddress::SampleAlleleInSlot(VdjSegment::V)),
640+
"sample_allele_in_slot.d" => Some(ChoiceAddress::SampleAlleleInSlot(VdjSegment::D)),
641+
"sample_allele_in_slot.j" => Some(ChoiceAddress::SampleAlleleInSlot(VdjSegment::J)),
618642
_ => None,
619643
};
620644
if exact.is_some() {
@@ -747,6 +771,15 @@ pub enum ChoiceAddressPattern {
747771
/// [`ChoiceAddress::PLength`]. One pattern instance per
748772
/// `PEnd` — declared by `PAdditionPass`.
749773
PLength { end: PEnd },
774+
/// Singleton-family mirror of [`ChoiceAddress::SampleHaplotype`].
775+
/// Declared by `SampleHaplotypePass`.
776+
SampleHaplotype,
777+
/// Family mirror of [`ChoiceAddress::SampleGene`]. Declared by
778+
/// `SampleGeneAllelePass`.
779+
SampleGene(VdjSegment),
780+
/// Family mirror of [`ChoiceAddress::SampleAlleleInSlot`]. Declared
781+
/// by `SampleGeneAllelePass` (a potential draw on multi-copy slots).
782+
SampleAlleleInSlot(VdjSegment),
750783
}
751784

752785
impl ChoiceAddressPattern {
@@ -793,6 +826,11 @@ impl fmt::Display for ChoiceAddressPattern {
793826
Self::PairedEndR2Length => f.write_str(PAIRED_END_R2_LENGTH),
794827
Self::PairedEndInsertSize => f.write_str(PAIRED_END_INSERT_SIZE),
795828
Self::PLength { end } => ChoiceAddress::PLength { end }.fmt(f),
829+
Self::SampleHaplotype => f.write_str(SAMPLE_HAPLOTYPE),
830+
Self::SampleGene(segment) => ChoiceAddress::SampleGene(segment).fmt(f),
831+
Self::SampleAlleleInSlot(segment) => {
832+
ChoiceAddress::SampleAlleleInSlot(segment).fmt(f)
833+
}
796834
}
797835
}
798836
}
@@ -889,6 +927,13 @@ fn parse_choice_address_pattern(address: &str) -> Option<ChoiceAddressPattern> {
889927
P_D5_LENGTH => Some(ChoiceAddressPattern::PLength { end: PEnd::D5 }),
890928
P_D3_LENGTH => Some(ChoiceAddressPattern::PLength { end: PEnd::D3 }),
891929
P_J5_LENGTH => Some(ChoiceAddressPattern::PLength { end: PEnd::J5 }),
930+
SAMPLE_HAPLOTYPE => Some(ChoiceAddressPattern::SampleHaplotype),
931+
"sample_gene.v" => Some(ChoiceAddressPattern::SampleGene(VdjSegment::V)),
932+
"sample_gene.d" => Some(ChoiceAddressPattern::SampleGene(VdjSegment::D)),
933+
"sample_gene.j" => Some(ChoiceAddressPattern::SampleGene(VdjSegment::J)),
934+
"sample_allele_in_slot.v" => Some(ChoiceAddressPattern::SampleAlleleInSlot(VdjSegment::V)),
935+
"sample_allele_in_slot.d" => Some(ChoiceAddressPattern::SampleAlleleInSlot(VdjSegment::D)),
936+
"sample_allele_in_slot.j" => Some(ChoiceAddressPattern::SampleAlleleInSlot(VdjSegment::J)),
892937
_ => None,
893938
};
894939

@@ -1501,5 +1546,27 @@ mod tests {
15011546
assert_pinned(ChoiceAddress::PLength { end: PEnd::D5 }, "p.d_5.length");
15021547
assert_pinned(ChoiceAddress::PLength { end: PEnd::D3 }, "p.d_3.length");
15031548
assert_pinned(ChoiceAddress::PLength { end: PEnd::J5 }, "p.j_5.length");
1549+
1550+
// Phased genotype (genotype-modeling PR1). New top-level
1551+
// `sample_haplotype` + `sample_gene.*` + `sample_allele_in_slot.*`
1552+
// namespaces; same additive policy as receptor revision /
1553+
// paired-end — old traces don't reference these strings, no
1554+
// ADDRESS_SCHEMA_VERSION bump.
1555+
assert_pinned(ChoiceAddress::SampleHaplotype, "sample_haplotype");
1556+
assert_pinned(ChoiceAddress::SampleGene(VdjSegment::V), "sample_gene.v");
1557+
assert_pinned(ChoiceAddress::SampleGene(VdjSegment::D), "sample_gene.d");
1558+
assert_pinned(ChoiceAddress::SampleGene(VdjSegment::J), "sample_gene.j");
1559+
assert_pinned(
1560+
ChoiceAddress::SampleAlleleInSlot(VdjSegment::V),
1561+
"sample_allele_in_slot.v",
1562+
);
1563+
assert_pinned(
1564+
ChoiceAddress::SampleAlleleInSlot(VdjSegment::D),
1565+
"sample_allele_in_slot.d",
1566+
);
1567+
assert_pinned(
1568+
ChoiceAddress::SampleAlleleInSlot(VdjSegment::J),
1569+
"sample_allele_in_slot.j",
1570+
);
15041571
}
15051572
}

engine_rs/src/feasibility.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,17 @@ impl VjProductiveFeasibility {
219219
.map(|instance| vec![instance.allele_id]);
220220
}
221221

222+
// Even at the *same* pass index, if the segment is already
223+
// assigned in the partial simulation, treat it as committed. The
224+
// consolidated `SampleGenotypePass` assigns V (and D) before
225+
// sampling J within one pass index; without this, J feasibility
226+
// would ignore the V already chosen and over-accept. The flat
227+
// one-segment-per-pass path never assigns another segment at the
228+
// same index, so this is a no-op there.
229+
if let Some(instance) = sim.assignments.get(segment) {
230+
return Some(vec![instance.allele_id]);
231+
}
232+
222233
Some(domain.values.clone())
223234
}
224235

engine_rs/src/genotype/mod.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
//! Per-individual diploid genotype model (PR1: known reference alleles).
2+
use std::collections::HashMap;
3+
4+
use crate::ir::Segment;
5+
use crate::refdata::{AlleleId, GeneId};
6+
7+
/// One carried allele in a haplotype gene slot. `copies` encodes
8+
/// gene-copy multiplicity for the *same* allele; two different alleles
9+
/// in a slot are two `GeneCopy` entries. `weight` is relative
10+
/// within-slot expression.
11+
#[derive(Clone, Copy, Debug, PartialEq)]
12+
pub struct GeneCopy {
13+
pub allele: AlleleId,
14+
pub copies: u8,
15+
pub weight: f32,
16+
}
17+
18+
/// One chromosome's carried alleles, per V/D/J gene. An absent or empty
19+
/// slot means the gene is deleted on this chromosome.
20+
#[derive(Clone, Debug, Default)]
21+
pub struct Haplotype {
22+
v: HashMap<GeneId, Vec<GeneCopy>>,
23+
d: HashMap<GeneId, Vec<GeneCopy>>,
24+
j: HashMap<GeneId, Vec<GeneCopy>>,
25+
}
26+
27+
impl Haplotype {
28+
pub fn new() -> Self {
29+
Self::default()
30+
}
31+
32+
fn map(&self, seg: Segment) -> &HashMap<GeneId, Vec<GeneCopy>> {
33+
match seg {
34+
Segment::V => &self.v,
35+
Segment::D => &self.d,
36+
Segment::J => &self.j,
37+
_ => panic!("Haplotype: segment must be V/D/J, got {seg:?}"),
38+
}
39+
}
40+
fn map_mut(&mut self, seg: Segment) -> &mut HashMap<GeneId, Vec<GeneCopy>> {
41+
match seg {
42+
Segment::V => &mut self.v,
43+
Segment::D => &mut self.d,
44+
Segment::J => &mut self.j,
45+
_ => panic!("Haplotype: segment must be V/D/J, got {seg:?}"),
46+
}
47+
}
48+
49+
/// Set (replace) the copies carried for a gene on this chromosome.
50+
/// An empty `copies` vec means the gene is deleted here.
51+
pub fn set(&mut self, seg: Segment, gene: GeneId, copies: Vec<GeneCopy>) {
52+
self.map_mut(seg).insert(gene, copies);
53+
}
54+
pub fn slot(&self, seg: Segment, gene: GeneId) -> &[GeneCopy] {
55+
self.map(seg).get(&gene).map(Vec::as_slice).unwrap_or(&[])
56+
}
57+
pub fn is_deleted(&self, seg: Segment, gene: GeneId) -> bool {
58+
self.slot(seg, gene).is_empty()
59+
}
60+
/// Genes with at least one carried copy on this chromosome, in
61+
/// ascending GeneId order (deterministic).
62+
pub fn present_genes(&self, seg: Segment) -> impl Iterator<Item = GeneId> + '_ {
63+
let mut genes: Vec<GeneId> = self
64+
.map(seg)
65+
.iter()
66+
.filter(|(_, v)| !v.is_empty())
67+
.map(|(g, _)| *g)
68+
.collect();
69+
genes.sort_by_key(|g| g.index());
70+
genes.into_iter()
71+
}
72+
/// (GeneId, usage-weight) for each present gene, weight from `usage`.
73+
pub fn gene_weights<F: Fn(GeneId) -> f64>(&self, seg: Segment, usage: &F) -> Vec<(GeneId, f64)> {
74+
self.present_genes(seg).map(|g| (g, usage(g))).collect()
75+
}
76+
/// All carried allele ids for a segment across all present genes.
77+
pub fn carried_alleles(&self, seg: Segment) -> Vec<AlleleId> {
78+
let mut out = Vec::new();
79+
for g in self.present_genes(seg) {
80+
for c in self.slot(seg, g) {
81+
out.push(c.allele);
82+
}
83+
}
84+
out
85+
}
86+
}
87+
88+
/// A diploid genotype: two chromosomes + draw weights + provenance.
89+
#[derive(Clone, Debug)]
90+
pub struct Genotype {
91+
haplotypes: [Haplotype; 2],
92+
chromosome_weights: [f32; 2],
93+
subject_id: Option<String>,
94+
source_refdata_hash: String,
95+
}
96+
97+
impl Genotype {
98+
pub fn new(
99+
haplotypes: [Haplotype; 2],
100+
chromosome_weights: [f32; 2],
101+
subject_id: Option<String>,
102+
source_refdata_hash: String,
103+
) -> Self {
104+
Self {
105+
haplotypes,
106+
chromosome_weights,
107+
subject_id,
108+
source_refdata_hash,
109+
}
110+
}
111+
pub fn haplotype(&self, c: usize) -> &Haplotype {
112+
&self.haplotypes[c]
113+
}
114+
pub fn chromosome_weights(&self) -> [f32; 2] {
115+
self.chromosome_weights
116+
}
117+
pub fn subject_id(&self) -> Option<&str> {
118+
self.subject_id.as_deref()
119+
}
120+
pub fn source_refdata_hash(&self) -> &str {
121+
&self.source_refdata_hash
122+
}
123+
}
124+
125+
#[cfg(test)]
126+
mod tests {
127+
use super::*;
128+
use crate::refdata::AlleleId;
129+
130+
fn copy(id: u32) -> GeneCopy {
131+
GeneCopy {
132+
allele: AlleleId::new(id),
133+
copies: 1,
134+
weight: 1.0,
135+
}
136+
}
137+
138+
#[test]
139+
fn haplotype_reports_carried_alleles_per_gene_with_deletion_as_empty() {
140+
let mut h = Haplotype::new();
141+
h.set(Segment::V, GeneId::new(0), vec![copy(10)]); // carried
142+
h.set(Segment::V, GeneId::new(1), vec![]); // deleted
143+
assert_eq!(h.slot(Segment::V, GeneId::new(0)).len(), 1);
144+
assert!(h.is_deleted(Segment::V, GeneId::new(1)));
145+
assert!(h.is_deleted(Segment::V, GeneId::new(2))); // absent == deleted
146+
let genes: Vec<GeneId> = h.present_genes(Segment::V).collect();
147+
assert_eq!(genes, vec![GeneId::new(0)]); // only non-empty slots
148+
}
149+
150+
#[test]
151+
fn genotype_carries_two_haplotypes_and_chromosome_weights() {
152+
let mut h0 = Haplotype::new();
153+
let mut h1 = Haplotype::new();
154+
h0.set(Segment::V, GeneId::new(0), vec![copy(10)]);
155+
h1.set(Segment::V, GeneId::new(0), vec![copy(11)]); // heterozygous
156+
let g = Genotype::new([h0, h1], [0.5, 0.5], Some("S1".into()), "sha256:x".into());
157+
assert_eq!(g.chromosome_weights(), [0.5, 0.5]);
158+
assert_eq!(g.subject_id(), Some("S1"));
159+
assert_eq!(
160+
g.haplotype(0).slot(Segment::V, GeneId::new(0))[0].allele,
161+
AlleleId::new(10)
162+
);
163+
assert_eq!(
164+
g.haplotype(1).slot(Segment::V, GeneId::new(0))[0].allele,
165+
AlleleId::new(11)
166+
);
167+
}
168+
169+
#[test]
170+
fn gene_weights_restrict_to_present_genes_and_apply_usage() {
171+
// chromosome 0 carries genes 0 and 1; usage favors gene 1.
172+
let mut h = Haplotype::new();
173+
h.set(Segment::V, GeneId::new(0), vec![copy(10)]);
174+
h.set(Segment::V, GeneId::new(1), vec![copy(20)]);
175+
let usage = |g: GeneId| if g.index() == 1 { 3.0 } else { 1.0 };
176+
let w = h.gene_weights(Segment::V, &usage);
177+
assert_eq!(w, vec![(GeneId::new(0), 1.0), (GeneId::new(1), 3.0)]);
178+
}
179+
}

engine_rs/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ pub mod contract;
4444
pub mod dist;
4545
pub mod event;
4646
pub mod feasibility;
47+
pub mod genotype;
4748
pub mod ir;
4849
pub mod junction;
4950
pub mod lineage;

engine_rs/src/passes/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub(crate) mod paramsig;
3131
pub mod receptor_revision;
3232
pub mod sample_allele;
3333
pub mod sample_base;
34+
pub mod sample_genotype;
3435
pub mod trim;
3536

3637
#[cfg(test)]

0 commit comments

Comments
 (0)