Merge branch 'worktree-agent-a7281df1f2cc3f1f1'
# Conflicts: # gerbil-manager-web/src/genetics/catalog.ts # gerbil-manager-web/src/genetics/genotype.ts # gerbil-manager-web/src/genetics/punnett.ts
This commit is contained in:
@@ -17,6 +17,8 @@ import {
|
||||
wildType,
|
||||
extractGenotypeFlags,
|
||||
displayGenotypeSafe,
|
||||
resolveAllelePair,
|
||||
inferUnknownsFromParents,
|
||||
} from '../genotype'
|
||||
import { combineLocus } from '../punnett'
|
||||
import { LOCI, type LocusKey } from '../loci'
|
||||
@@ -239,10 +241,11 @@ describe('Farbschlag catalog', () => {
|
||||
})
|
||||
|
||||
describe('Partially-unknown parents (wildcards)', () => {
|
||||
it('handles an A? parent (phenotype agouti, genotype unknown)', () => {
|
||||
// A? x aa at the A locus -> father gamete: 1/2 A, 1/4 (each of A,a) from "?"
|
||||
// = effectively 3/4 A, 1/4 a ; mother always a.
|
||||
// Offspring: 3/4 Aa, 1/4 aa.
|
||||
it('GEN-5: an A? parent reads as AA (unknown copies the known allele)', () => {
|
||||
// GEN-5 (ticket 3e643ef1, breeder rule): the unknown allele '?' is a COPY of
|
||||
// the known partner 'A', so A? = AA. AA × aa → all Aa → 100% Agouti.
|
||||
// (Previously '?' spread uniformly → 3/4 Agouti : 1/4 Schwarz, which invented
|
||||
// a recessive 'a' gamete the parent demonstrably does not show.)
|
||||
const father = makeGenotype({
|
||||
A: ['A', '?'],
|
||||
C: ['C', 'C'],
|
||||
@@ -257,10 +260,10 @@ describe('Partially-unknown parents (wildcards)', () => {
|
||||
const mother = fromDisplayString('aa CC DD EE GG PP spsp rere')
|
||||
const result = breed(father, mother)
|
||||
|
||||
const agouti = result.byFarbschlag.find((f) => f.farbschlag === 'Agouti')!
|
||||
const schwarz = result.byFarbschlag.find((f) => f.farbschlag === 'Schwarz')!
|
||||
expect(agouti.probability.text).toBe('3/4')
|
||||
expect(schwarz.probability.text).toBe('1/4')
|
||||
expect(result.offspring).toHaveLength(1)
|
||||
expect(result.offspring[0].farbschlag).toBe('Agouti')
|
||||
expect(result.offspring[0].probability.text).toBe('1')
|
||||
expect(result.byFarbschlag.some((f) => f.farbschlag === 'Schwarz')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -603,7 +606,10 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
||||
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute Agouti')
|
||||
expect(name('aa CC dd EE gg PP spsp rere')).toBe('Dilute Anthrazit')
|
||||
expect(name('aa CC dd ee GG PP spsp rere')).toBe('Dilute Kohlfuchs')
|
||||
expect(name('aa CC dd ee gg pp spsp rere')).toBe('Dilute Blaufuchs')
|
||||
// GEN-5 (ticket 3deab547): Blaufuchs is BLACK-eyed (P), and dilution is
|
||||
// independent of the P-locus, so Dilute Blaufuchs is aa dd ee gg P- (was
|
||||
// wrongly P:'p'). The pink-eyed variant is a different (REW-adjacent) colour.
|
||||
expect(name('aa CC dd ee gg PP spsp rere')).toBe('Dilute Blaufuchs')
|
||||
expect(name('AA CC dd EE GG pp spsp rere')).toBe('Dilute Gold')
|
||||
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
|
||||
})
|
||||
@@ -628,10 +634,13 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
||||
|
||||
it('Farbarten (categories) never appear as computed results', () => {
|
||||
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
|
||||
// het ef/e now resolves to specific variety via locusToken ef/e -> 'ef' fix.
|
||||
// GEN-5 (ticket 5826e8e2): het ef/e is the FUCHSSCHIMMEL family — it resolves to
|
||||
// a *fuchsschimmel variety, NEVER a pure Schimmel (Rotaugen-/Orangeschimmel).
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD eef GG PP spsp rere'))).toBe('Kohlfuchsschimmel')
|
||||
// Agouti ef/e: 'Orangeschimmel' wins (same token-set as Algierfuchsschimmel, listed first)
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Orangeschimmel')
|
||||
// Agouti ef/e black-eyed → Algierfuchsschimmel (A:A,E:ef,G:G,P:P), NOT Orangeschimmel.
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Algierfuchsschimmel')
|
||||
// hom ef/ef agouti black-eyed → the pure Orangeschimmel (Schimmel family).
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe('Orangeschimmel')
|
||||
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
|
||||
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
|
||||
// FK check: none of the 7 category names are in BASE_COLORS (no DB entries -> no FK risk)
|
||||
@@ -789,3 +798,118 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
||||
expect(displayGenotypeSafe('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: unknown allele = copy of known (resolveAllelePair, breeder rule)', () => {
|
||||
it('a single-unknown pair resolves to the homozygote of the KNOWN allele', () => {
|
||||
expect(resolveAllelePair('A', ['A', '?'])).toEqual(['A', 'A'])
|
||||
expect(resolveAllelePair('A', ['?', 'a'])).toEqual(['a', 'a'])
|
||||
expect(resolveAllelePair('D', ['D', '?'])).toEqual(['D', 'D'])
|
||||
expect(resolveAllelePair('E', ['e', '?'])).toEqual(['e', 'e']) // ee[-] = Fuchs
|
||||
expect(resolveAllelePair('E', ['ef', '?'])).toEqual(['ef', 'ef']) // ef[-] = Schimmel
|
||||
})
|
||||
it('a fully-unknown pair falls back to wild-type (markers stay unmarked)', () => {
|
||||
expect(resolveAllelePair('A', ['?', '?'])).toEqual(['A', 'A'])
|
||||
expect(resolveAllelePair('C', ['?', '?'])).toEqual(['C', 'C'])
|
||||
expect(resolveAllelePair('Sp', ['?', '?'])).toEqual(['sp', 'sp']) // never implies Schecke
|
||||
})
|
||||
it('a fully-known pair is returned unchanged', () => {
|
||||
expect(resolveAllelePair('C', ['C', 'ch'])).toEqual(['C', 'ch'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: genetics-engine ticket reproductions (real stored genotypes)', () => {
|
||||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||
|
||||
it('5826e8e2: ee[f] het (Fuchsschimmel) + pp → Goldfuchsschimmel, NOT Rotaugenschimmel', () => {
|
||||
// Tier bf6f4507: Aa C- D- ee[f] G- pp Spsp — het ef/e is a Fuchsschimmel.
|
||||
expect(name('Aa C- D- ee[f] G- pp Spsp')).toBe('Goldfuchsschimmel Schecke')
|
||||
// and the pure hom ef/ef pp stays the pure Schimmel:
|
||||
expect(name('AA CC DD efef GG pp spsp')).toBe('Rotaugenschimmel')
|
||||
})
|
||||
|
||||
it('b034ddd2: ee[-] (e + unknown) → ee Fuchs → Algierfuchs, NOT Agouti', () => {
|
||||
// Tier 33a7c1f9: Aa CC D- ee[-] Gg Pp spsp. Old engine read [e,?] as e/E → Agouti.
|
||||
expect(name('Aa CC D- ee[-] Gg Pp spsp')).toBe('Algierfuchs')
|
||||
})
|
||||
|
||||
it('3deab547 / efa2b232: aa cchm dd ee[-] gg P- → Dilute CP-Blaufuchs, NOT Zobel/blau/Unbekannt', () => {
|
||||
// Tier 6864eaef. Non-agouti Fuchs colourpoint is NOT a marten (Zobel) — it
|
||||
// derives a CP-fox base with the Dilute prefix.
|
||||
expect(name('aa c[chm]c[chm] dd ee[-] gg Pp Spsp')).toBe('Dilute CP-Blaufuchs Schecke')
|
||||
})
|
||||
|
||||
it('473dc345 / 5151ab20: Vance uw[d] (dense underwhite) parses (no crash) → Kohlfuchs', () => {
|
||||
// Tier f31eb1f9: aa Cc[chm] D- ee Uwuw[d] PP spsp. uw[d] is the G locus;
|
||||
// it must parse and NEVER render 'uw'.
|
||||
const g = fromDisplayString('aa Cc[chm] D- ee Uwuw[d] PP spsp')
|
||||
expect(g.G).toEqual(['G', 'g'])
|
||||
expect(toDisplayString(g)).not.toContain('uw')
|
||||
expect(genotypeToFarbschlag(g)).toBe('Kohlfuchs')
|
||||
})
|
||||
|
||||
it('Fuchsschimmel family never resolves to a pure Schimmel variety', () => {
|
||||
// Agouti het ef/e black-eyed → Algierfuchsschimmel; hom ef/ef → Orangeschimmel.
|
||||
expect(name('AA CC DD eef GG PP spsp')).toBe('Algierfuchsschimmel')
|
||||
expect(name('AA CC DD efef GG PP spsp')).toBe('Orangeschimmel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: no phantom colours in the expected-litter list (3e643ef1/c8ce27e2/3c46d0b4/1e7b66e6)', () => {
|
||||
it('Mamta Mini (D-, Ee[-]) × Gold (D-, Ee): unknown D copies known D → no Dilute, no Unbekannt, no efef', () => {
|
||||
// Real litter 98bfdf92. Both parents carry D- (unknown D) and an unknown E
|
||||
// partner. The old uniform-spread invented dd / ef / 'Unbekannt' offspring.
|
||||
const father = fromDisplayString('AA CC D- Ee[-] Gg PP spsp') // Mamta Mini
|
||||
const mother = fromDisplayString('Aa CC D- Ee Gg pp spsp') // Gold
|
||||
const result = breed(father, mother)
|
||||
|
||||
const names = result.byFarbschlag.map((f) => f.farbschlag)
|
||||
expect(names).not.toContain('Unbekannter Farbschlag')
|
||||
expect(names.some((n) => n.startsWith('Dilute'))).toBe(false)
|
||||
expect(result.offspring.every((o) => !o.genotype.includes('e[f]'))).toBe(true)
|
||||
// Probabilities still sum to exactly 1.
|
||||
const sum = result.offspring.reduce((acc, o) => acc + o.probability.value, 0)
|
||||
expect(sum).toBeCloseTo(1, 10)
|
||||
// Only agouti vs silver-agouti can fall here (G locus segregates; everything else fixed).
|
||||
expect(new Set(names)).toEqual(new Set(['Agouti', 'Silberagouti']))
|
||||
})
|
||||
|
||||
it('D- × D- never yields a dd (dilute) offspring at all', () => {
|
||||
const p = fromDisplayString('AA CC D- EE GG PP spsp')
|
||||
const result = breed(p, p)
|
||||
expect(result.offspring.every((o) => !o.genotype.includes('dd'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: parent inference fills unknown alleles (cc9ea3fe / 1a508c04)', () => {
|
||||
it('Mamta Mini Ee[-] + homozygous ee father Geely → Ee', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee[-] Gg PP spsp')
|
||||
const geely = fromDisplayString('aa CC DD ee gg PP spsp') // father: ee (hom fox)
|
||||
const res = inferUnknownsFromParents(child, geely, null)
|
||||
expect(res.genotype.E).toEqual(['E', 'e'])
|
||||
expect(toDisplayString(res.genotype)).toBe('AA CC DD Ee Gg PP spsp')
|
||||
expect(res.inferred).toEqual([{ locus: 'E', allele: 'e', from: 'father' }])
|
||||
})
|
||||
|
||||
it('falls back to the mother when only she is homozygous', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee[-] GG PP spsp')
|
||||
const father = fromDisplayString('AA CC DD Ee GG PP spsp') // het → no force
|
||||
const mother = fromDisplayString('aa CC DD ee GG PP spsp') // ee → forces e
|
||||
const res = inferUnknownsFromParents(child, father, mother)
|
||||
expect(res.genotype.E).toEqual(['E', 'e'])
|
||||
expect(res.inferred).toEqual([{ locus: 'E', allele: 'e', from: 'mother' }])
|
||||
})
|
||||
|
||||
it('leaves the genotype untouched when no parent is homozygous at the unknown locus', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee[-] GG PP spsp')
|
||||
const father = fromDisplayString('AA CC DD Ee GG PP spsp')
|
||||
const res = inferUnknownsFromParents(child, father, null)
|
||||
expect(res.inferred).toEqual([])
|
||||
expect(res.genotype.E).toEqual(child.E)
|
||||
})
|
||||
|
||||
it('no-op when there is nothing unknown', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee GG PP spsp')
|
||||
const res = inferUnknownsFromParents(child, child, child)
|
||||
expect(res.inferred).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,32 +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) {
|
||||
// #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'}`
|
||||
// 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'.
|
||||
@@ -333,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]
|
||||
|
||||
@@ -236,25 +236,25 @@
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD efe gg PP spsp rere",
|
||||
"sortOrder": 37,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD efe GG PP spsp rere",
|
||||
"sortOrder": 38,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
|
||||
"sortOrder": 39,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD efe gg PP spsp rere",
|
||||
"sortOrder": 40,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
@@ -272,7 +272,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD efe GG pp spsp rere",
|
||||
"sortOrder": 43,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -290,7 +290,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD efe GG pp spsp rere",
|
||||
"sortOrder": 46,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -302,7 +302,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
|
||||
"sortOrder": 48,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
@@ -332,7 +332,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Dilute Blaufuchs",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||
"canonicalGenotype": "aa CC dd ee gg PP spsp rere",
|
||||
"sortOrder": 53,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
|
||||
@@ -236,25 +236,25 @@
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
|
||||
"canonicalGenotype": "AA CC DD ee[f] gg PP spsp",
|
||||
"sortOrder": 37,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
|
||||
"canonicalGenotype": "AA CC DD ee[f] GG PP spsp",
|
||||
"sortOrder": 38,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
||||
"canonicalGenotype": "aa CC DD ee[f] GG PP spsp",
|
||||
"sortOrder": 39,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp",
|
||||
"canonicalGenotype": "aa CC DD ee[f] gg PP spsp",
|
||||
"sortOrder": 40,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
@@ -272,7 +272,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
|
||||
"canonicalGenotype": "AA CC DD ee[f] GG pp spsp",
|
||||
"sortOrder": 43,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -290,7 +290,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp",
|
||||
"canonicalGenotype": "aa CC DD ee[f] GG pp spsp",
|
||||
"sortOrder": 46,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -302,7 +302,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
||||
"canonicalGenotype": "aa CC DD ee[f] GG PP spsp",
|
||||
"sortOrder": 48,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
@@ -332,7 +332,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Dilute Blaufuchs",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp",
|
||||
"canonicalGenotype": "aa CC dd ee gg PP spsp",
|
||||
"sortOrder": 53,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
|
||||
@@ -40,6 +40,38 @@ export function canonicalPair(locus: LocusKey, a: string, b: string): AllelePair
|
||||
return rank(a) <= rank(b) ? [a, b] : [b, a]
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-5 — the breeder's UNKNOWN-allele rule (ticket 3e643ef1, confirmed by the
|
||||
* Züchterin): an unknown allele '?' is a COPY of the known, visible partner
|
||||
* allele until the gene is determined. So a single-unknown pair resolves to the
|
||||
* homozygote of the KNOWN allele:
|
||||
* A? → AA D? → DD [e,?] → ee [E,?] → EE [ef,?] → ef/ef
|
||||
* Only when BOTH alleles are unknown is the locus genuinely undetermined; it then
|
||||
* falls back to the wild-type reading (most-dominant colour allele, but the
|
||||
* recessive UNMARKED allele for the Sp/Re/Sls markers so an unknown marker never
|
||||
* implies Schecke/Rex/WP).
|
||||
*
|
||||
* This single rule is shared by phenotype/catalog resolution (catalog.ts) and the
|
||||
* Punnett gamete weights (punnett.ts), so an unknown allele never invents a
|
||||
* recessive phenotype (no phantom Dilute/efef/Unbekannt in offspring lists).
|
||||
*/
|
||||
export function resolveAllelePair(locus: LocusKey, pair: AllelePair): [string, string] {
|
||||
const [a, b] = pair
|
||||
const aUnknown = a === WILDCARD
|
||||
const bUnknown = b === WILDCARD
|
||||
if (!aUnknown && !bUnknown) return [a, b]
|
||||
if (aUnknown && bUnknown) {
|
||||
// Fully unknown: wild-type reading (markers default to the unmarked recessive).
|
||||
const alleles = LOCI[locus].alleles
|
||||
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
|
||||
const fb = isMarker ? alleles[alleles.length - 1] : alleles[0]
|
||||
return [fb, fb]
|
||||
}
|
||||
// Exactly one unknown → copy of the known partner allele (homozygous).
|
||||
const known = aUnknown ? b : a
|
||||
return [known, known]
|
||||
}
|
||||
|
||||
function assertAllele(locus: LocusKey, allele: string): void {
|
||||
if (allele === WILDCARD) return
|
||||
if (!LOCI[locus].alleles.includes(allele)) {
|
||||
@@ -180,27 +212,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')
|
||||
// GEN-5: dense-underwhite modifier uw[d]/Uw[d] (G-locus). The German "[d]"
|
||||
// dense marker is a shade qualifier, not a separate allele — strip it BEFORE
|
||||
// the Uw→G alias so e.g. "Uwuw[d]" / "uw[d]uw[d]" parse as Gg / gg, not "Gg[d]"
|
||||
// (which crashes splitToken). Mirrors tools/import/genotype.py _rewrite_uw.
|
||||
t = t.replace(/uw\[d\]/gi, 'uw')
|
||||
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
|
||||
// #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).
|
||||
// #42 (E-locus): a visible Fuchs is RECESSIVE → MUST be homozygous "ee". The herdbook
|
||||
// form "ee[-]" (fox allele + unknown E-type partner) therefore resolves to "ee" (Fuchs),
|
||||
// NOT [e,?] — the recessive phenotype implies homozygosity. A bare "e-"/"e[-]" (a lone
|
||||
// recessive fox with an unknown partner) is genetically impossible and is rejected below.
|
||||
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.
|
||||
// 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).
|
||||
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
|
||||
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
|
||||
t = t.replace(/(?<=[A-Za-z])c$/g, '?')
|
||||
@@ -266,12 +298,14 @@ 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.
|
||||
// #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 invalid-genotype message
|
||||
// instead of silently mis-computing the colour. ("ee[-]" was already normalized to "ee".)
|
||||
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)`)
|
||||
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)
|
||||
}
|
||||
@@ -301,3 +335,75 @@ export function displayGenotypeSafe(raw: string | null | undefined): string {
|
||||
export function hasUnknown(g: Genotype): boolean {
|
||||
return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD)
|
||||
}
|
||||
|
||||
/** Per-locus note about an allele that parent-inference filled in. */
|
||||
export interface ParentInferredLocus {
|
||||
readonly locus: LocusKey
|
||||
/** The allele a homozygous parent forced onto the child. */
|
||||
readonly allele: string
|
||||
/** 'father' | 'mother' — which parent was homozygous. */
|
||||
readonly from: 'father' | 'mother'
|
||||
}
|
||||
|
||||
export interface ParentInferenceResult {
|
||||
readonly genotype: Genotype
|
||||
/** Loci whose unknown allele was resolved from a parent (empty = nothing changed). */
|
||||
readonly inferred: ParentInferredLocus[]
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-5 (tickets cc9ea3fe / 1a508c04, breeder rule via Mendel): a child's UNKNOWN
|
||||
* allele can be filled in from a HOMOZYGOUS parent, which can only pass that one
|
||||
* allele. E.g. Mamta Mini stored E = [E,?]; her father Geely is ee (homozygous
|
||||
* fox) so he must pass an 'e' — the child's unknown E allele therefore IS 'e',
|
||||
* giving Ee (not the copy-of-known EE default).
|
||||
*
|
||||
* Rule, per locus, ONLY for an allele still unknown ('?') in the child:
|
||||
* - if a parent is homozygous (both alleles equal and known), that allele is
|
||||
* forced onto the child's unknown slot.
|
||||
* - the father is checked first; if he doesn't resolve it, the mother is tried.
|
||||
* - a parent allele is only accepted if it is one the child could legitimately
|
||||
* carry at that locus (it always is for a real parent, but we guard anyway).
|
||||
* Pairs with no unknown, or where no parent is homozygous, are left untouched
|
||||
* (still subject to the copy-of-known display/colour rule elsewhere).
|
||||
*/
|
||||
export function inferUnknownsFromParents(
|
||||
child: Genotype,
|
||||
father: Genotype | null | undefined,
|
||||
mother: Genotype | null | undefined,
|
||||
): ParentInferenceResult {
|
||||
const out = {} as Record<LocusKey, AllelePair>
|
||||
const inferred: ParentInferredLocus[] = []
|
||||
for (const locus of LOCUS_ORDER) {
|
||||
const [a, b] = child[locus]
|
||||
const aUnknown = a === WILDCARD
|
||||
const bUnknown = b === WILDCARD
|
||||
if (!aUnknown && !bUnknown) {
|
||||
out[locus] = child[locus]
|
||||
continue
|
||||
}
|
||||
const homForced = (p: Genotype | null | undefined): string | null => {
|
||||
if (!p) return null
|
||||
const [pa, pb] = p[locus]
|
||||
if (pa === WILDCARD || pb === WILDCARD) return null
|
||||
return pa === pb ? pa : null
|
||||
}
|
||||
const fatherAllele = homForced(father)
|
||||
const forced = fatherAllele ?? homForced(mother)
|
||||
const from: 'father' | 'mother' = fatherAllele ? 'father' : 'mother'
|
||||
if (forced && (aUnknown !== bUnknown)) {
|
||||
// Exactly one unknown slot → fill it with the forced parent allele.
|
||||
const known = aUnknown ? b : a
|
||||
out[locus] = canonicalPair(locus, known, forced)
|
||||
inferred.push({ locus, allele: forced, from })
|
||||
} else if (forced && aUnknown && bUnknown) {
|
||||
// Both unknown but a parent is homozygous → that allele is certain on one
|
||||
// slot; the other stays unknown.
|
||||
out[locus] = canonicalPair(locus, forced, WILDCARD)
|
||||
inferred.push({ locus, allele: forced, from })
|
||||
} else {
|
||||
out[locus] = child[locus]
|
||||
}
|
||||
}
|
||||
return { genotype: makeGenotype(out), inferred }
|
||||
}
|
||||
|
||||
@@ -21,9 +21,16 @@ export {
|
||||
fromJSON,
|
||||
hasUnknown,
|
||||
displayGenotypeSafe,
|
||||
resolveAllelePair,
|
||||
inferUnknownsFromParents,
|
||||
WILDCARD,
|
||||
} from './genotype'
|
||||
export type { Genotype, AllelePair } from './genotype'
|
||||
export type {
|
||||
Genotype,
|
||||
AllelePair,
|
||||
ParentInferenceResult,
|
||||
ParentInferredLocus,
|
||||
} from './genotype'
|
||||
|
||||
export { LOCI, LOCUS_ORDER } from './loci'
|
||||
export type { LocusKey, LocusDef } from './loci'
|
||||
|
||||
@@ -10,72 +10,40 @@
|
||||
* Wildcard ("?") alleles are expanded uniformly over the locus' allele set
|
||||
* before combining, so a parent known only by phenotype can still be paired.
|
||||
*/
|
||||
import { add, frac, multiply, ONE, type Fraction } from './fraction'
|
||||
import { dominanceRank, LOCI, LOCUS_ORDER, type LocusKey } from './loci'
|
||||
import { add, frac, multiply, type Fraction, ONE } from './fraction'
|
||||
import { LOCUS_ORDER, type LocusKey } from './loci'
|
||||
import {
|
||||
canonicalPair,
|
||||
resolveAllelePair,
|
||||
toDisplayString,
|
||||
WILDCARD,
|
||||
type AllelePair,
|
||||
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
|
||||
readonly probability: Fraction
|
||||
}
|
||||
|
||||
/** Expand a (possibly wildcard) parent allele pair into weighted concrete alleles. */
|
||||
/**
|
||||
* Expand a (possibly partly-unknown) parent allele pair into weighted concrete
|
||||
* gamete alleles.
|
||||
*
|
||||
* GEN-5 (ticket 3e643ef1, breeder rule): an unknown allele '?' is a COPY of the
|
||||
* known partner allele (A?→AA, D?→DD, [e,?]→ee), NOT a uniform spread over the
|
||||
* whole allele set. Spreading wrongly invented recessive gametes (d, ef, e) that
|
||||
* produced impossible offspring colours — phantom Dilute, efef Schimmel and
|
||||
* 'Unbekannter Farbschlag' in the expected-litter list. After resolution each of
|
||||
* the two (now concrete) alleles contributes 1/2 of the gamete. A fully-unknown
|
||||
* locus resolves to the wild-type homozygote (see resolveAllelePair).
|
||||
*/
|
||||
function parentAlleleWeights(locus: LocusKey, pair: AllelePair): Map<string, Fraction> {
|
||||
const weights = new Map<string, Fraction>()
|
||||
const addWeight = (allele: string, w: Fraction) => {
|
||||
weights.set(allele, add(weights.get(allele) ?? frac(0, 1), w))
|
||||
}
|
||||
// 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) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
for (const a of resolveAllelePair(locus, pair)) addWeight(a, frac(1, 2))
|
||||
return weights
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user