GEN-1: add Punnett engine (per-locus + full genotype distribution, wildcard support)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
109
gerbil-manager-web/src/genetics/punnett.ts
Normal file
109
gerbil-manager-web/src/genetics/punnett.ts
Normal file
@@ -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<T> {
|
||||
readonly value: T
|
||||
readonly probability: Fraction
|
||||
}
|
||||
|
||||
/** Expand a (possibly wildcard) parent allele pair into weighted concrete alleles. */
|
||||
function parentAlleleWeights(locus: LocusKey, pair: AllelePair): Map<string, Fraction> {
|
||||
const weights = new Map<string, Fraction>()
|
||||
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<string, DistEntry<AllelePair>> {
|
||||
const fa = parentAlleleWeights(locus, father)
|
||||
const mo = parentAlleleWeights(locus, mother)
|
||||
const result = new Map<string, DistEntry<AllelePair>>()
|
||||
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<Genotype>[] {
|
||||
// Start with one certain empty partial genotype, then fold in each locus.
|
||||
let dist: DistEntry<Record<LocusKey, AllelePair>>[] = [
|
||||
{ value: {} as Record<LocusKey, AllelePair>, probability: ONE },
|
||||
]
|
||||
for (const locus of LOCUS_ORDER) {
|
||||
const locusDist = combineLocus(locus, father[locus], mother[locus])
|
||||
const next: DistEntry<Record<LocusKey, AllelePair>>[] = []
|
||||
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<Genotype>[]): DistEntry<Genotype>[] {
|
||||
const merged = new Map<string, DistEntry<Genotype>>()
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user