/** * Lethality rules applied to an offspring genotype distribution. * * Data-driven so the biology is easy to adjust: * - SpSp (Schecke homozygous): PRENATAL-LETHAL. Embryos die and are resorbed, * so they never appear as live young -> removed from the distribution, which * is then renormalised. Triggers a ScheckeLethal warning carrying the * fraction of young lost (~1/4 for Schecke × Schecke). * - ReRe (Rex homozygous): SEMI-LETHAL. Reduced viability but not modelled as a * hard removal (some survive) -> kept in the distribution, warning only. */ import { add, divide, toString, ZERO, type Fraction } from './fraction' import type { LocusKey } from './loci' import type { Genotype } from './genotype' import type { DistEntry } from './punnett' import { GeneticsWarningCode, type GeneticsWarning } from './warnings' interface LethalRule { readonly locus: LocusKey readonly allele: string /** 'lethal' = remove from live-birth distribution; 'semi' = warn only. */ readonly kind: 'lethal' | 'semi' readonly warning: GeneticsWarningCode } const LETHAL_RULES: readonly LethalRule[] = [ { locus: 'Sp', allele: 'Sp', kind: 'lethal', warning: GeneticsWarningCode.ScheckeLethal }, { locus: 'Sls', allele: 'Sl', kind: 'lethal', warning: GeneticsWarningCode.SlsLethal }, { locus: 'Re', allele: 'Re', kind: 'semi', warning: GeneticsWarningCode.RexSemiLethal }, ] function isHomozygous(g: Genotype, locus: LocusKey, allele: string): boolean { return g[locus][0] === allele && g[locus][1] === allele } export interface LethalityResult { /** Live-birth distribution (lethal genotypes removed, renormalised). */ readonly distribution: DistEntry[] readonly warnings: GeneticsWarning[] } /** * Apply lethality to a (merged) genotype distribution. * The input probabilities are assumed to sum to 1. */ export function applyLethality(dist: DistEntry[]): LethalityResult { const warnings: GeneticsWarning[] = [] // Total probability mass of fully-lethal genotypes (for the "young lost" stat). let lethalMass: Fraction = ZERO const survivors: DistEntry[] = [] for (const entry of dist) { const hardLethal = LETHAL_RULES.some( (r) => r.kind === 'lethal' && isHomozygous(entry.value, r.locus, r.allele), ) if (hardLethal) { lethalMass = add(lethalMass, entry.probability) } else { survivors.push(entry) } } // Renormalise survivors over the surviving mass. const survivingMass = survivors.reduce((acc, e) => add(acc, e.probability), ZERO) const distribution = survivingMass.num === 0 ? survivors : survivors.map((e) => ({ value: e.value, probability: divide(e.probability, survivingMass) })) // One lethal warning PER lethal rule that actually removed young (so SpSp -> // ScheckeLethal and S(l)S(l) -> SlsLethal are reported distinctly). for (const rule of LETHAL_RULES) { if (rule.kind !== 'lethal') continue const mass = dist.reduce( (acc, e) => (isHomozygous(e.value, rule.locus, rule.allele) ? add(acc, e.probability) : acc), ZERO, ) if (mass.num > 0) { warnings.push({ code: rule.warning, detail: { youngLostFraction: toString(mass), youngLostPercent: Number(((mass.num / mass.den) * 100).toFixed(2)), }, }) } } // Semi-lethal: warn if any surviving genotype is homozygous for a semi-lethal allele. for (const rule of LETHAL_RULES) { if (rule.kind !== 'semi') continue const affected = distribution.reduce( (acc, e) => (isHomozygous(e.value, rule.locus, rule.allele) ? add(acc, e.probability) : acc), ZERO, ) if (affected.num > 0) { warnings.push({ code: rule.warning, detail: { affectedFraction: toString(affected), affectedPercent: Number(((affected.num / affected.den) * 100).toFixed(2)), }, }) } } // Superschecke: surviving young carrying BOTH spotting markers (Sp present and // S(l) present) are very-high-white and deafness-prone — info warning. const superMass = distribution.reduce((acc, e) => { const hasSp = e.value.Sp.includes('Sp') const hasSl = e.value.Sls.includes('Sl') return hasSp && hasSl ? add(acc, e.probability) : acc }, ZERO) if (superMass.num > 0) { warnings.push({ code: GeneticsWarningCode.SuperscheckeDeaf, detail: { affectedFraction: toString(superMass), affectedPercent: Number(((superMass.num / superMass.den) * 100).toFixed(2)), }, }) } return { distribution, warnings } }