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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user