GEN-3a: Uw=G alias, second spotting locus Sls (SlSl lethal), Sp×Sls Superschecke, flag/metadata token tolerance (dea/WFNZ/DP/RV/GV)

This commit is contained in:
2026-06-06 10:25:48 +02:00
parent c93c24f37c
commit 1e7dc8f91d
4 changed files with 128 additions and 21 deletions

View File

@@ -64,18 +64,28 @@ export function makeGenotype(input: Record<LocusKey, AllelePair>): Genotype {
export function wildType(): Genotype {
const out = {} as Record<LocusKey, AllelePair>
for (const locus of LOCUS_ORDER) {
// Wild-type is homozygous for the most dominant allele, EXCEPT the
// marker loci Sp/Re whose wild form is the recessive (unmarked) allele.
// Wild-type is homozygous for the most dominant allele, EXCEPT the marker
// loci Sp/Re/Sls whose wild form is the recessive (unmarked) allele.
const alleles = LOCI[locus].alleles
const a = locus === 'Sp' || locus === 'Re' ? alleles[alleles.length - 1] : alleles[0]
const marker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
const a = marker ? alleles[alleles.length - 1] : alleles[0]
out[locus] = [a, a]
}
return out
}
/** Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere". */
/**
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
* the colour catalog stay byte-identical; it only appears for WP/Sls carriers
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
*/
export function toDisplayString(g: Genotype): string {
return LOCUS_ORDER.map((locus) => g[locus][0] + g[locus][1]).join(' ')
return LOCUS_ORDER.filter(
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
)
.map((locus) => g[locus][0] + g[locus][1])
.join(' ')
}
/** Stable JSON-storable object (already the in-memory shape; returned as a copy). */
@@ -114,14 +124,72 @@ function splitToken(token: string): [string, string] {
}
/**
* Parse a display string ("Aa CC Dd EE GG Pp Spsp rere") back into a Genotype.
* Tokens may be given in any order; each token must belong to a distinct locus.
* GEN-3a: tokens that are NOT genotype loci — health/provenance metadata that may
* appear in a herd-book genotype string. Stripped on parse (see extractGenotypeFlags).
* - dea/Dea/taub = deafness flag (after spsp); DP/DarkPatch = non-Mendelian patch flag
* - WFNZ/RV/GV = provenance/breeding-method annotations
*/
const FLAG_TOKENS = new Set(['DP', 'DarkPatch', 'dea', 'Dea', 'taub', 'WFNZ', 'RV', 'GV'])
/**
* Normalize one whitespace-token to canonical allele symbols, or null if it is a
* non-genotype flag/metadata token (to be stripped):
* - Uw/uw -> G/g (international Underwhite == German Grey locus)
* - S(l)/s(l) -> Sl/sl (second spotting locus notation)
* - WP -> Slsl (WP is the visible S(l)s(l) heterozygote)
*/
function normalizeToken(tok: string): string | null {
if (FLAG_TOKENS.has(tok)) return null
let t = tok
if (t === 'WP') t = 'Slsl'
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
return t
}
/**
* Canonical normalized genotype string (flags/metadata removed, Uw/S(l)/WP
* resolved). Exported so the import pipeline (GEN-3b) can mirror this exactly.
*/
export function normalizeGenotypeString(input: string): string {
return input
.trim()
.split(/\s+/)
.filter(Boolean)
.map(normalizeToken)
.filter((t): t is string => t !== null)
.join(' ')
}
/**
* Extract non-Punkett flags from a raw genotype string: deafness (dea/taub =
* deaf, Dea = hearing) and provenance/pattern tags (WFNZ/RV/GV/DP).
*/
export function extractGenotypeFlags(input: string): { deaf?: boolean; tags: string[] } {
const tokens = input.trim().split(/\s+/).filter(Boolean)
let deaf: boolean | undefined
const tags: string[] = []
for (const tok of tokens) {
if (tok === 'dea' || tok === 'taub') deaf = true
else if (tok === 'Dea') deaf = false
else if (tok === 'DP' || tok === 'DarkPatch' || tok === 'WFNZ' || tok === 'RV' || tok === 'GV')
tags.push(tok)
}
return { deaf, tags }
}
/**
* Parse a display string ("Aa CC Dd EE GG Pp Spsp rere [Slsl]") back into a
* Genotype. Tokens may be in any order; each must belong to a distinct locus.
* Uw/S(l)/WP are normalized and flag/metadata tokens (dea, WFNZ, …) are stripped.
* Missing loci default to wild-type.
*/
export function fromDisplayString(input: string): Genotype {
const tokens = input.trim().split(/\s+/).filter(Boolean)
const acc = {} as Record<LocusKey, AllelePair>
for (const token of tokens) {
for (const raw of tokens) {
const token = normalizeToken(raw)
if (token === null) continue // flag/metadata token — not a locus
const [a, b] = splitToken(token)
const refAllele = a === WILDCARD ? b : a
if (refAllele === WILDCARD) {

View File

@@ -25,6 +25,7 @@ interface LethalRule {
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 },
]
@@ -67,14 +68,23 @@ export function applyLethality(dist: DistEntry<Genotype>[]): LethalityResult {
? 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)),
},
})
// 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<Fraction>(
(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.
@@ -95,5 +105,22 @@ export function applyLethality(dist: DistEntry<Genotype>[]): LethalityResult {
}
}
// 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<Fraction>((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 }
}

View File

@@ -12,8 +12,12 @@
* - de.wikibooks.org/wiki/Die_Rennmaus/_Farbvarianten_und_Farbgenetik
*/
/** Canonical locus keys, in conventional display order. */
export const LOCUS_ORDER = ['A', 'C', 'D', 'E', 'G', 'P', 'Sp', 'Re'] as const
/**
* Canonical locus keys, in conventional display order. Sls (second spotting
* locus) is appended LAST so legacy 8-locus genotype strings still parse — a
* missing Sls token defaults to wild-type sl/sl.
*/
export const LOCUS_ORDER = ['A', 'C', 'D', 'E', 'G', 'P', 'Sp', 'Re', 'Sls'] as const
export type LocusKey = (typeof LOCUS_ORDER)[number]
export interface LocusDef {
@@ -33,9 +37,12 @@ export interface LocusDef {
* E = full extension
* ef = Schimmel/roan (progressive whitening)
* e = Fox (suppresses eumelanin)
* Sp/Re are dominant markers, lethal/semi-lethal when homozygous (see lethality.ts):
* Sp = Schecke (checkered); checkered animals are always Spsp, SpSp dies in utero.
* Re = Rex (curly coat); rex animals are Re-, ReRe is semi-lethal.
* Sp/Re/Sls are dominant markers, lethal/semi-lethal when homozygous (see lethality.ts):
* Sp = Schecke (checkered); checkered animals are always Spsp, SpSp dies in utero.
* Re = Rex (curly coat); rex animals are Re-, ReRe is semi-lethal.
* Sls = second spotting locus (S(l), WP/Minimalschecke). S(l)s(l) het = the WP
* phenotype; S(l)S(l) homozygous = lethal (Rumpback/megacolon). Sp + Sls
* together => Superschecke (very high white, deafness-prone).
*/
export const LOCI: Readonly<Record<LocusKey, LocusDef>> = {
A: { key: 'A', nameDe: 'Agouti', alleles: ['A', 'a'] },
@@ -46,6 +53,7 @@ export const LOCI: Readonly<Record<LocusKey, LocusDef>> = {
P: { key: 'P', nameDe: 'Rotaugenaufhellung (Pink-Eye)', alleles: ['P', 'p'] },
Sp: { key: 'Sp', nameDe: 'Schecke', alleles: ['Sp', 'sp'] },
Re: { key: 'Re', nameDe: 'Rex', alleles: ['Re', 're'] },
Sls: { key: 'Sls', nameDe: 'Zweite Scheckung (WP)', alleles: ['Sl', 'sl'] },
}
/** Set of all valid allele symbols, longest-first (for maximal-munch parsing). */

View File

@@ -10,6 +10,10 @@ export const GeneticsWarningCode = {
ScheckeLethal: 'SCHECKE_LETHAL',
/** Rex × Rex: ReRe is semi-lethal; reduced viability of homozygous young. */
RexSemiLethal: 'REX_SEMI_LETHAL',
/** WP × WP: S(l)S(l) is prenatal-lethal (Rumpback/megacolon); fewer live young. */
SlsLethal: 'SLS_LETHAL',
/** Sp + Sls together -> Superschecke: very high white, deafness-prone (info). */
SuperscheckeDeaf: 'SUPERSCHECKE_DEAF',
} as const
export type GeneticsWarningCode =