fix(genetics): Genotyp-/Farbschlag-Engine-Bugs aus Ticket-Triage

- uw/Uwuw[d] (dichtes Underwhite) wird vor dem uw→G-Locus-Alias korrekt geparst
  (Vance: nicht mehr „Unbekannter Farbschlag", zeigt G-Locus statt international uw). [#34,#32]
- ee[-] = sichtbarer Fuchs (rezessiv homozygot) → korrekt als Fuchs; bare „e-"
  (rezessiv + unbekannt) ist genetisch unmöglich → genotypeInvalid statt still falsch. [#42]
- aa-Colourpoint-Zweig berücksichtigt jetzt D (Dilute) und E (Fuchs), statt fix
  Zobel/Marder/Siam zu erzwingen. [#3]
- Wurf-Farbprognose: unbekanntes Allel (?) wird nicht mehr über die volle Dominanz
  expandiert → keine unmöglichen Dilute-/Schimmel-/„Unbekannt"-Nachkommen. [#37,#39,#40,#41,#38]

Tests: test_genotype.py erweitert; genetics.test.ts +5 Blöcke (135 vitest grün, tsc/eslint sauber).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 21:04:05 +02:00
parent 5069c0eb1e
commit b437d09312
6 changed files with 182 additions and 26 deletions

View File

@@ -291,6 +291,62 @@ describe('GEN-3a: Uw=G alias', () => {
)
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
})
it('#32/#34: dense-underwhite [d] annotation is stripped (Uwuw[d] → Gg, never uw)', () => {
// Vance's real genotype: "aa Cc[chm] D- ee Uwuw[d] PP spsp" → Kohlfuchs, not Unbekannt.
expect(fromDisplayString('AA CC DD EE Uwuw[d] PP spsp rere').G).toEqual(['G', 'g'])
expect(fromDisplayString('AA CC DD EE uw[d]uw[d] PP spsp rere').G).toEqual(['g', 'g'])
const vance = fromDisplayString('aa Cc[chm] D- ee Uwuw[d] PP spsp')
expect(vance.G).toEqual(['G', 'g'])
const display = toDisplayString(vance)
expect(display).toContain('Gg')
expect(display.toLowerCase()).not.toContain('uw')
expect(genotypeToFarbschlag(vance)).toBe('Kohlfuchs')
})
})
describe('#42: E-locus Algierfuchs (ee[-] visible Fuchs)', () => {
it('Aa CC D- ee[-] Gg Pp spsp → Algierfuchs', () => {
expect(genotypeToFarbschlag(fromDisplayString('Aa CC D- ee[-] Gg Pp spsp'))).toBe('Algierfuchs')
})
})
describe('#3: aa colourpoint respects D (dilute) and E (Fuchs)', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
it('does NOT collapse aa colourpoint with dd/Fuchs to Zobel', () => {
expect(name('aa c[chm]c[chm] dd ee[-] gg Pp Spsp')).not.toBe('Zobel')
expect(name('aa c[chm]c[chm] dd ee[-] gg Pp Spsp')).not.toBe('Zobel Schecke')
})
it('aa cchm with full D + full extension still resolves to Marder/Zobel (regression)', () => {
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
})
})
describe('#37/#39/#40/#41: wildcard expansion is dominance/visibility constrained', () => {
it('an unknown E partner of a visible E never introduces ef (no phantom Schimmel)', () => {
// Mamta Mini (Ee[-] = [E,?]) × Gold (Ee): no Schimmel/efef/Unbekannt offspring.
const r = breed(
fromDisplayString('AA CC D- Ee[-] Gg PP spsp'),
fromDisplayString('Aa CC D- Ee Gg pp spsp'),
)
const names = r.byFarbschlag.map((f) => f.farbschlag)
expect(names).not.toContain('Unbekannter Farbschlag')
expect(names.some((n) => /schimmel/i.test(n))).toBe(false)
expect(r.offspring.every((o) => !o.genotype.includes('e[f]'))).toBe(true)
})
it('a fox parent (ee) × Ee never yields impossible non-fox-only morphs from a wildcard', () => {
// Geely (ee gg, with A?/C?/D? wildcards) × Gaida: no Unbekannt, no Schimmel.
const r = breed(
fromDisplayString('A- C- D- ee[-] gg Pp spsp'),
fromDisplayString('A- CC D- Ee GG Pp spsp'),
)
const names = r.byFarbschlag.map((f) => f.farbschlag)
expect(names).not.toContain('Unbekannter Farbschlag')
expect(names.some((n) => /schimmel/i.test(n))).toBe(false)
})
})
describe('GEN-3a: second spotting locus Sls (WP)', () => {
@@ -670,22 +726,23 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
)
})
it('e[-] standalone: parses as [e,?], displays e-', () => {
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
expect(g.E).toEqual(['e', '?'])
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp')
it('#42: e[-]/e- standalone is INVALID (lone recessive fox + unknown) → throws', () => {
// Fox (e) is recessive; a single e with an unknown partner is genetically
// impossible. The breeder's rule: only "E-" and "ee[-]" exist, never "e-".
expect(() => fromDisplayString('aa CC DD e[-] GG PP spsp rere')).toThrow()
expect(() => fromDisplayString('aa CC DD e- GG PP spsp rere')).toThrow()
})
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
// Real herdbook notation: ee[-] = fox allele e + unknown e-type second allele.
// The lookbehind rule strips the second e[-] → '?', leaving 'e?' for splitToken.
it('#42: ee[-] resolves to ee (Fuchs) — recessive phenotype implies homozygosity', () => {
// Real herdbook notation: ee[-] = visible fox. Since fox is recessive a
// visible fox MUST be ee, so the unknown second allele resolves to e.
const input = 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp'
const g = fromDisplayString(input)
expect(g.E).toEqual(['e', '?'])
expect(g.E).toEqual(['e', 'e'])
expect(g.C).toEqual(['cchm', 'cchm'])
expect(g.D).toEqual(['D', 'd'])
expect(g.Sp).toEqual(['Sp', 'sp'])
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp')
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd ee Gg Pp Spsp')
})
// ── GENOTYPE-PARSE-CRASH / displayGenotypeSafe resilience ───────────────

View File

@@ -243,10 +243,29 @@ function colourpointName(g: Genotype): string | null {
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
const agouti = resolvedPair(g, 'A').includes('A')
if (!agouti) {
const [g1, g2] = resolvedPair(g, 'G')
const grey = g1 === 'g' && g2 === 'g'
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
return bothCchm ? 'Marder' : 'Siam'
// #3: the aa colourpoint branch must respect D (dilute) and E (Fuchs/Schimmel)
// instead of hard-coding Marder/Siam/Zobel. The frozen breeder names
// Marder/Siam/Zobel/Zobel-Hell only describe the wild D + full-extension case
// (aa cchm DD EE [gg]); they are kept for that case. Any non-wild D or E (e.g.
// dd dilute or ee Fuchs) is named from the resolved base colour, so
// 'aa cchm dd ee gg' no longer collapses to Zobel.
const [d1, d2] = resolvedPair(g, 'D')
const wildD = d1 === 'D' && d2 === 'D'
const fullExtension = eFamily(g) === null // E expresses full 'E' (not Fuchs/Schimmel)
if (wildD && fullExtension) {
const [g1, g2] = resolvedPair(g, 'G')
const grey = g1 === 'g' && g2 === 'g'
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
return bothCchm ? 'Marder' : 'Siam'
}
// dilute and/or Fuchs/Schimmel aa colourpoint → derive from the base colour.
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
if (!base) return null
const DILUTE = 'Dilute '
if (base.startsWith(DILUTE)) {
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
}
return `CP-${base}${bothCchm ? '' : '-Hell'}`
}
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))

View File

@@ -180,17 +180,27 @@ function normalizeToken(tok: string): string | null {
let t = tok
if (t === 'WP') t = 'Slsl'
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
// GEN-3b (#32/#34): Underwhite == G locus. Strip the breeder's "[d]" (dense
// underwhite) annotation from the uw/Uw token BEFORE aliasing to G/g, so that
// "Uwuw[d]" → "Gg" and "uw[d]uw[d]" → "gg" (mirrors tools/import/genotype.py
// _rewrite_uw). Without this the "[d]" survived → splitToken("Gg[d]") threw and
// the frontend fell back to "Unbekannter Farbschlag" / leaked the raw uw token.
t = t.replace(/(Uw|uw)\[d\]/g, '$1')
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
// GEN-3h: accept bracket display notation → canonical internal symbols.
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
// CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain).
// When e[-] is PRECEDED by a letter it is the second unknown allele in a
// 2-allele token (e.g. ee[-] → e + e[-] → e + ?). Lookbehind strips only
// the e[-] part; the leading allele stays. Standalone e[-] falls through to
// the generic [-]→? rule below (which makes the bracket-dash a wildcard,
// leaving the leading allele intact for splitToken).
// #42 (E-locus e-dash): Fuchs (e) is RECESSIVE — a visible fox MUST be
// homozygous "ee". The herdbook form "ee[-]" (fox allele + unknown E-type
// second allele) therefore resolves to "ee" (Fuchs), NOT [e,?]; the recessive
// phenotype implies homozygosity. A bare "e-" / "e[-]" (a single recessive
// fox allele with an unknown partner) is genetically impossible and is left to
// be rejected by splitToken (invalid → genotypeInvalid path).
t = t.replace(/ee\[-\]/g, 'ee').replace(/ee-/g, 'ee')
// CR-1a: allele-prefixed bracket-unknown like cc[-]: when e[-]/c[-] is PRECEDED
// by a letter it is the second unknown allele in a 2-allele token. Lookbehind
// strips only the bracket part; the leading allele stays.
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
t = t.replace(/(?<=[A-Za-z])c$/g, '?')
@@ -256,6 +266,13 @@ export function fromDisplayString(input: string): Genotype {
const locus = ALLELE_TO_LOCUS[refAllele]
if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)
if (acc[locus]) throw new Error(`Locus ${locus} given twice`)
// #42: a lone recessive Fuchs allele with an unknown partner ("e-"/"e[-]" →
// [e,?]) is genetically impossible — fox is recessive, so a fox allele is
// only visible homozygous (ee, written "ee[-]"). Reject it so the UI surfaces
// the genotypeInvalid message instead of silently mis-computing the colour.
if (locus === 'E' && ((a === 'e' && b === WILDCARD) || (a === WILDCARD && b === 'e'))) {
throw new Error(`Invalid E-locus token "${token}": lone recessive "e" with unknown partner (use "ee[-]" for Fuchs or "E-" for unknown)`)
}
acc[locus] = canonicalPair(locus, a, b)
}
const base = wildType()

View File

@@ -11,7 +11,7 @@
* 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 { dominanceRank, LOCI, LOCUS_ORDER, type LocusKey } from './loci'
import {
canonicalPair,
toDisplayString,
@@ -20,6 +20,34 @@ import {
type Genotype,
} from './genotype'
/**
* #37/#39/#40/#41: which concrete alleles an UNKNOWN partner allele may actually be,
* given the KNOWN allele it is paired with at this locus.
*
* A hidden allele is constrained by the recorded (visible) one:
* 1. It can NEVER be more dominant than the known allele — otherwise the animal's
* phenotype would be different from what the breeder recorded. So the unknown
* only ranges over alleles with dominance rank >= rank(known) (equal or more
* recessive). This kills impossible more-dominant offspring morphs.
* 2. It can never be an allele that is VISIBLE in the heterozygote, unless the
* animal already expresses it. At the E locus 'ef' (Schimmel/roan) shows even
* heterozygously, so a non-Schimmel animal (known E or e) cannot secretly carry
* 'ef'. Excluding it removes the phantom Schimmel/efef predictions (#41).
*
* When BOTH alleles are unknown the locus is genuinely unconstrained → full set.
*/
function unknownPartnerOptions(locus: LocusKey, known: string): readonly string[] {
const alleles = LOCI[locus].alleles
if (known === WILDCARD) return alleles // fully unknown locus: any allele
const knownRank = dominanceRank(locus, known)
return alleles.filter((a) => {
if (dominanceRank(locus, a) < knownRank) return false // can't outrank the visible allele
// E-locus 'ef' is visible in het: only possible if the animal is itself Schimmel.
if (locus === 'E' && a === 'ef' && known !== 'ef') return false
return true
})
}
/** A probability distribution over outcomes of type T (keyed by a string). */
export interface DistEntry<T> {
readonly value: T
@@ -32,12 +60,18 @@ function parentAlleleWeights(locus: LocusKey, pair: AllelePair): Map<string, Fra
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) {
// The "other" allele of the pair tells us what an unknown is allowed to be:
// an unknown partner is constrained by the known visible allele (see
// unknownPartnerOptions), not blown up uniformly over every allele.
const [a0, a1] = pair
for (let i = 0; i < 2; i++) {
const a = pair[i]
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)
const known = i === 0 ? a1 : a0
const options = unknownPartnerOptions(locus, known)
// Unknown allele contributes 1/2 of the gamete, split over its possible values.
const share = frac(1, 2 * options.length)
for (const concrete of options) addWeight(concrete, share)
} else {
addWeight(a, frac(1, 2))
}