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:
@@ -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 ───────────────
|
||||
|
||||
@@ -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'] }))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user