diff --git a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts index 5755a7b..5fd503c 100644 --- a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts +++ b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts @@ -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 ─────────────── diff --git a/gerbil-manager-web/src/genetics/catalog.ts b/gerbil-manager-web/src/genetics/catalog.ts index 0aa0502..2502c30 100644 --- a/gerbil-manager-web/src/genetics/catalog.ts +++ b/gerbil-manager-web/src/genetics/catalog.ts @@ -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'] })) diff --git a/gerbil-manager-web/src/genetics/genotype.ts b/gerbil-manager-web/src/genetics/genotype.ts index eb98135..fb089ab 100644 --- a/gerbil-manager-web/src/genetics/genotype.ts +++ b/gerbil-manager-web/src/genetics/genotype.ts @@ -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() diff --git a/gerbil-manager-web/src/genetics/punnett.ts b/gerbil-manager-web/src/genetics/punnett.ts index 23c583b..a416548 100644 --- a/gerbil-manager-web/src/genetics/punnett.ts +++ b/gerbil-manager-web/src/genetics/punnett.ts @@ -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 { readonly value: T @@ -32,12 +60,18 @@ function parentAlleleWeights(locus: LocusKey, pair: AllelePair): Map { 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)) } diff --git a/tools/import/genotype.py b/tools/import/genotype.py index 8e8941d..53a05d3 100644 --- a/tools/import/genotype.py +++ b/tools/import/genotype.py @@ -126,7 +126,15 @@ def parse(raw): for locus in LOCI: pat = _LOCUS_TOKEN.get(locus) if pat and pat.match(t): - mapped.setdefault(locus, _alleles_for(locus, t)) # first occurrence wins + alleles = _alleles_for(locus, t) + # #42 (E-locus e-dash): Fuchs (e) is RECESSIVE — a visible fox MUST be + # homozygous "ee" (written "ee[-]", which already parses to [e, e]). + # A lone recessive "e" with an unknown partner ("e-"/"e[-]" -> [e, ?]) + # is genetically impossible; mirror the frontend by REJECTING it + # (leave it unmapped/invalid) instead of mapping a wrong colour. + if locus == "E" and alleles in (["e", "?"], ["?", "e"]): + break # invalid -> falls through to unmappedTokens below + mapped.setdefault(locus, alleles) # first occurrence wins matched = True break if matched: diff --git a/tools/import/test_genotype.py b/tools/import/test_genotype.py index 7d9ba44..4267c18 100644 --- a/tools/import/test_genotype.py +++ b/tools/import/test_genotype.py @@ -61,6 +61,27 @@ check("DP -> tag", g.parse("[DP]")["tags"] == ["DP"]) check("tag not in genotype loci", g.parse("WFNZ")["mapped8locus"] == {}) check("tag not in unmapped", g.parse("aa WFNZ")["unmappedTokens"] == []) +# --- #32/#34: dense-underwhite [d] annotation stripped, maps to G locus (never uw) --- +r = g.parse("Uwuw[d]") +check("Uwuw[d] -> Gg (dense underwhite, mixed case)", r["mapped8locus"].get("G") == ["G", "g"]) +check("Uwuw[d] leaves nothing unmapped (no stray uw token)", r["unmappedTokens"] == []) +r = g.parse("aa Cc[chm] D- ee Uwuw[d] PP spsp") +check("Vance genotype: G locus = Gg (display via G, not uw)", r["mapped8locus"].get("G") == ["G", "g"]) +check("Vance genotype: E locus = ee (Fuchs)", r["mapped8locus"].get("E") == ["e", "e"]) +check("Vance genotype: no unmapped tokens", r["unmappedTokens"] == []) + +# --- #42: E-locus e-dash semantics --- +# ee[-] = visible Fuchs -> ee (recessive phenotype implies homozygosity) +check("ee[-] -> ee (visible Fuchs)", g.parse("ee[-]")["mapped8locus"].get("E") == ["e", "e"]) +check("E- stays [E, ?] (valid unknown partner)", g.parse("E-")["mapped8locus"].get("E") == ["E", "?"]) +# bare e- / e[-] is genetically impossible -> invalid (not mapped, surfaced as unmapped) +check("e- invalid -> not mapped at E", g.parse("e-")["mapped8locus"].get("E") is None) +check("e- invalid -> in unmappedTokens", "e-" in g.parse("e-")["unmappedTokens"]) +check("e[-] invalid -> not mapped at E", g.parse("e[-]")["mapped8locus"].get("E") is None) +r = g.parse("Aa CC D- ee[-] Gg Pp spsp") +check("Algierfuchs genotype: E = ee (Fuchs)", r["mapped8locus"].get("E") == ["e", "e"]) +check("Algierfuchs genotype: no unmapped tokens", r["unmappedTokens"] == []) + # --- looks_like_genotype recognizes Uw-bearing cells --- check("looks_like_genotype sees Uw as G", g.looks_like_genotype("aa Cc Uwuw") is True)