diff --git a/gerbil-manager-web/src/genetics/lethality.ts b/gerbil-manager-web/src/genetics/lethality.ts new file mode 100644 index 0000000..993698d --- /dev/null +++ b/gerbil-manager-web/src/genetics/lethality.ts @@ -0,0 +1,99 @@ +/** + * 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: '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) })) + + if (lethalMass.num > 0) { + warnings.push({ + code: GeneticsWarningCode.ScheckeLethal, + detail: { + youngLostFraction: toString(lethalMass), + youngLostPercent: Number(((lethalMass.num / lethalMass.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)), + }, + }) + } + } + + return { distribution, warnings } +} diff --git a/gerbil-manager-web/src/genetics/warnings.ts b/gerbil-manager-web/src/genetics/warnings.ts new file mode 100644 index 0000000..4c685c9 --- /dev/null +++ b/gerbil-manager-web/src/genetics/warnings.ts @@ -0,0 +1,16 @@ +/** + * Warning codes emitted by the genetics engine. The engine is language-neutral: + * it emits CODES only. German texts are mapped in de.ts (UI layer). + */ +export enum GeneticsWarningCode { + /** Schecke × Schecke: SpSp embryos die in utero; ~25% fewer live young. */ + ScheckeLethal = 'SCHECKE_LETHAL', + /** Rex × Rex: ReRe is semi-lethal; reduced viability of homozygous young. */ + RexSemiLethal = 'REX_SEMI_LETHAL', +} + +export interface GeneticsWarning { + readonly code: GeneticsWarningCode + /** Optional structured detail for the UI (e.g. fraction of young lost). */ + readonly detail?: Record +}