fix(genetics): Engine-Fixes Ticket-Triage (Goldfuchsschimmel, Dilute CP-Blaufuchs, Eltern-Inferenz, Punnett, uw[d])

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 09:05:35 +02:00
parent 5e14124322
commit 8c1ee75b81
15 changed files with 2126 additions and 87 deletions

View File

@@ -20,12 +20,12 @@
* meta rows dropped, 17 matched the frozen names). Genotypes normalized from
* portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown).
*/
import { LOCI, LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
import { LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
import {
makeGenotype,
resolveAllelePair,
toDisplayString,
wildType,
WILDCARD,
type AllelePair,
type Genotype,
} from './genotype'
@@ -113,7 +113,11 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
{ name: 'Dilute Topas', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed (P, line 75); dilution dd is
// independent of the eye-pigment P-locus, so the dilute form is ALSO P:'P'
// (was P:'p', which made it an unreachable phantom and left dd CP-fox animals
// 'Unbekannt'/'blau'). Now aa cchm dd ee gg P- → 'Dilute CP-Blaufuchs'.
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-dd.jpg' },
// ── GEN-3f/3g: c^chm colourpoint varieties ──
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
@@ -163,13 +167,10 @@ export interface FarbschlagMatch {
* variety specifically.
*/
function locusToken(g: Genotype, locus: LocusKey): string {
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
// colour loci (unknown-C => full-colour 'C', not a white), but the recessive
// UNMARKED allele for the spotting/rex markers (unknown-Sp must NOT imply Schecke).
const alleles = LOCI[locus].alleles
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
// GEN-5: an unknown allele is a COPY of the known partner (resolveAllelePair),
// so e.g. [e,?] reads as ee (Fuchs), NOT e/E. Only a fully-unknown locus falls
// back to the wild-type reading (most-dominant colour / unmarked marker).
const [x, y] = resolveAllelePair(locus, g[locus])
if (locus === 'E') {
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
// GEN-4: het ef/e → 'ef' (ef is dominant for the Schimmel phenotype;
@@ -195,29 +196,50 @@ function matches(g: Genotype, entry: FarbschlagEntry): boolean {
* computed farbschlag output (the farbschlagFor category guard blocks them).
*/
function eFamily(g: Genotype): string | null {
const [x, y] = g.E
// GEN-5: resolve unknown E as a copy of the known allele first ([e,?]→ee Fuchs,
// [ef,?]→ef/ef Schimmel, [E,?]→EE full), so families are decided consistently.
const [x, y] = resolveAllelePair('E', g.E)
if (x === 'e' && y === 'e') return 'Fuchs'
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
if (x === 'ef' && y === 'ef') return 'Schimmel'
if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs'
return null
}
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
/** Resolve an allele pair to concrete alleles, defaulting unknown to wild-type. */
/**
* Resolve an allele pair to concrete alleles. GEN-5: an unknown allele copies the
* known partner (resolveAllelePair); a fully-unknown locus falls back to wild-type.
*/
function resolvedPair(g: Genotype, locus: LocusKey): [string, string] {
const alleles = LOCI[locus].alleles
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
return [x, y]
return resolveAllelePair(locus, g[locus])
}
/**
* Whether a catalog entry belongs to the given E-family by NAME. The Schimmel
* entries split into two breeder groups that share the same E:'ef' token but
* differ by zygosity of the live animal:
* - 'Fuchsschimmel' family (ef/e het) → only *fuchsschimmel entries
* (Goldfuchsschimmel, Kohlfuchsschimmel, …).
* - 'Schimmel' family (ef/ef hom) → the pure roan entries whose name ends
* in 'schimmel' but NOT 'fuchsschimmel' (Rotaugenschimmel, Orangeschimmel,
* Silberschimmel, …).
* GEN-5 (ticket 5826e8e2): this is why ef/e must NOT match a pure-Schimmel entry
* (Rotaugenschimmel) — a het Fuchsschimmel animal is a Goldfuchsschimmel.
*/
function entryInEFamily(entry: FarbschlagEntry, family: string): boolean {
if (entry.tokens.E === undefined) return false
const n = entry.name.toLowerCase()
if (family === 'Fuchsschimmel') return n.includes('fuchsschimmel')
if (family === 'Schimmel') return n.includes('schimmel') && !n.includes('fuchsschimmel')
// 'Fuchs' family: fox entries are E:'e' (no 'schimmel' in the name).
return !n.includes('schimmel')
}
/** Base colour name (no modifiers, no colourpoint prefix), via E-family + matches. */
function baseColourFor(g: Genotype): string | null {
const family = eFamily(g)
const base = family
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
? (BASE_COLORS.find((e) => entryInEFamily(e, family) && matches(g, e)) ?? null)
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
@@ -242,13 +264,20 @@ function colourpointName(g: Genotype): string | null {
// Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch).
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
const agouti = resolvedPair(g, 'A').includes('A')
if (!agouti) {
// GEN-5 (tickets 3deab547 / efa2b232): the aa marten names (Marder/Siam/Zobel/
// Zobel-Hell) are FULL-EXTENSION (E) sable varieties only. A non-agouti
// colourpoint that is Fuchs (ee) or Schimmel (ef) is NOT a Marder/Zobel — it
// must derive its base generically like the A- branch, so e.g.
// aa cchm dd ee gg → 'Dilute CP-Polarfuchs' (dilute + fox + grey), never Zobel.
if (!agouti && eFamily(g) === null) {
const [g1, g2] = resolvedPair(g, 'G')
const grey = g1 === 'g' && g2 === 'g'
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
return bothCchm ? 'Marder' : 'Siam'
}
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
// Colourpoint base derivation: name the colour as if C were full, then prefix
// 'CP-'; het (cchm/ch) gets the '-Hell' suffix. Used by A- and by non-agouti
// Fuchs/Schimmel colourpoints (which have no dedicated marten name).
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
if (!base) return null
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
@@ -314,12 +343,22 @@ export function genotypeToFarbschlag(g: Genotype): string {
export function representativeGenotype(entry: FarbschlagEntry): Genotype {
const base = wildType()
const out = {} as Record<LocusKey, AllelePair>
// GEN-5 (ticket 5826e8e2): a *Fuchsschimmel variety is the HETEROZYGOUS ef/e
// animal (a Schimmel-modified Fox), whereas the pure *schimmel varieties
// (Rotaugen-/Orange-/Silberschimmel) are HOMOZYGOUS ef/ef. The E token is the
// shared phenotype letter 'ef'; the representative genotype must encode the
// right zygosity so each entry round-trips back to its own family.
const isFuchsschimmel = entry.name.toLowerCase().includes('fuchsschimmel')
for (const locus of LOCUS_ORDER) {
const token = entry.tokens[locus]
if (!token) {
out[locus] = base[locus]
continue
}
if (locus === 'E' && token === 'ef' && isFuchsschimmel) {
out[locus] = ['ef', 'e'] // het Fuchsschimmel (ef/e), not hom ef/ef
continue
}
// GEN-3f: a token may encode a HETEROZYGOUS pair as "x/y" (e.g. the het
// colourpoints Siam/Zobel-Hell use C: 'cchm/ch'); otherwise it's homozygous.
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]