From 76d8380ea1c9721f6cc8fa6ad0970c98510332fa Mon Sep 17 00:00:00 2001 From: Gulum Date: Fri, 5 Jun 2026 23:39:31 +0200 Subject: [PATCH] GEN-1: add Punnett engine (per-locus + full genotype distribution, wildcard support) Co-Authored-By: Claude Opus 4.8 (1M context) --- gerbil-manager-web/src/genetics/punnett.ts | 109 +++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 gerbil-manager-web/src/genetics/punnett.ts diff --git a/gerbil-manager-web/src/genetics/punnett.ts b/gerbil-manager-web/src/genetics/punnett.ts new file mode 100644 index 0000000..23c583b --- /dev/null +++ b/gerbil-manager-web/src/genetics/punnett.ts @@ -0,0 +1,109 @@ +/** + * 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()) +}