/** * Punnett engine: combine two parent genotypes into the offspring genotype * distribution, with exact fractional probabilities. * * Per locus, each parent contributes one of its two alleles with prob 1/2, * giving 4 equally-likely gametic combinations that we fold into unordered * (canonically-ordered) allele pairs. The full-genotype distribution is the * product across all loci. * * Wildcard ("?") alleles are expanded uniformly over the locus' allele set * before combining, so a parent known only by phenotype can still be paired. */ import { add, frac, multiply, ONE, type Fraction } from './fraction' import { LOCI, LOCUS_ORDER, type LocusKey } from './loci' import { canonicalPair, toDisplayString, WILDCARD, type AllelePair, type Genotype, } from './genotype' /** A probability distribution over outcomes of type T (keyed by a string). */ export interface DistEntry { readonly value: T readonly probability: Fraction } /** Expand a (possibly wildcard) parent allele pair into weighted concrete alleles. */ function parentAlleleWeights(locus: LocusKey, pair: AllelePair): Map { const weights = new Map() const addWeight = (allele: string, w: Fraction) => { weights.set(allele, add(weights.get(allele) ?? frac(0, 1), w)) } const alleles = LOCI[locus].alleles for (const a of pair) { if (a === WILDCARD) { // Unknown allele: uniform over the locus set, each contributing 1/2 of the gamete. const share = frac(1, 2 * alleles.length) for (const concrete of alleles) addWeight(concrete, share) } else { addWeight(a, frac(1, 2)) } } return weights } /** Offspring allele-pair distribution at a single locus. */ export function combineLocus( locus: LocusKey, father: AllelePair, mother: AllelePair, ): Map> { const fa = parentAlleleWeights(locus, father) const mo = parentAlleleWeights(locus, mother) const result = new Map>() for (const [fAllele, fw] of fa) { for (const [mAllele, mw] of mo) { const pair = canonicalPair(locus, fAllele, mAllele) const key = pair[0] + pair[1] const p = multiply(fw, mw) const existing = result.get(key) result.set(key, { value: pair, probability: existing ? add(existing.probability, p) : p, }) } } return result } /** Full offspring genotype distribution (Cartesian product across loci). */ export function combineGenotypes( father: Genotype, mother: Genotype, ): DistEntry[] { // Start with one certain empty partial genotype, then fold in each locus. let dist: DistEntry>[] = [ { value: {} as Record, probability: ONE }, ] for (const locus of LOCUS_ORDER) { const locusDist = combineLocus(locus, father[locus], mother[locus]) const next: DistEntry>[] = [] for (const partial of dist) { for (const entry of locusDist.values()) { next.push({ value: { ...partial.value, [locus]: entry.value }, probability: multiply(partial.probability, entry.probability), }) } } dist = next } return dist.map((e) => ({ value: e.value as Genotype, probability: e.probability })) } /** Merge distribution entries that share the same display string, summing probs. */ export function mergeByGenotype(dist: DistEntry[]): DistEntry[] { const merged = new Map>() for (const entry of dist) { const key = toDisplayString(entry.value) const existing = merged.get(key) merged.set(key, { value: entry.value, probability: existing ? add(existing.probability, entry.probability) : entry.probability, }) } return Array.from(merged.values()) }