GEN-1: add lethality rules + warning codes (SpSp lethal+renormalize, ReRe semi)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
99
gerbil-manager-web/src/genetics/lethality.ts
Normal file
99
gerbil-manager-web/src/genetics/lethality.ts
Normal file
@@ -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<Genotype>[]
|
||||||
|
readonly warnings: GeneticsWarning[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply lethality to a (merged) genotype distribution.
|
||||||
|
* The input probabilities are assumed to sum to 1.
|
||||||
|
*/
|
||||||
|
export function applyLethality(dist: DistEntry<Genotype>[]): LethalityResult {
|
||||||
|
const warnings: GeneticsWarning[] = []
|
||||||
|
|
||||||
|
// Total probability mass of fully-lethal genotypes (for the "young lost" stat).
|
||||||
|
let lethalMass: Fraction = ZERO
|
||||||
|
const survivors: DistEntry<Genotype>[] = []
|
||||||
|
|
||||||
|
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<Fraction>((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<Fraction>(
|
||||||
|
(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 }
|
||||||
|
}
|
||||||
16
gerbil-manager-web/src/genetics/warnings.ts
Normal file
16
gerbil-manager-web/src/genetics/warnings.ts
Normal file
@@ -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<string, string | number>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user