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:
2026-06-23 09:29:42 +02:00
15 changed files with 2147 additions and 164 deletions

View File

@@ -400,25 +400,28 @@ public class ApplicationContext : DbContext
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
// GEN-5 (ticket 5826e8e2): *Fuchsschimmel = HET ef/e (internal 'efe'),
// not hom ef/ef — a Schimmel-modified Fox. Pure *schimmel stay efef.
("Polarfuchsschimmel", "AA CC DD efe gg PP spsp rere", 37),
("Algierfuchsschimmel", "AA CC DD efe GG PP spsp rere", 38),
("Kohlfuchsschimmel", "aa CC DD efe GG PP spsp rere", 39),
("Blaufuchsschimmel", "aa CC DD efe gg PP spsp rere", 40),
// --- Hell variants (IDs 38-48) ---
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
("Goldfuchsschimmel", "AA CC DD efe GG pp spsp rere", 43),
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
("Rotfuchsschimmel", "aa CC DD efe GG pp spsp rere", 46),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
("Kohlfuchsschimmel, hell","aa CC DD efe GG PP spsp rere", 48),
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
// --- Dilute (dd) renamed variants (IDs 49-50) ---
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed; dilution is P-independent.
("Dilute Blaufuchs","aa CC dd ee gg PP spsp rere", 53),
// --- Marder / Siam / CP- series (IDs 51-66) ---
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class Gen5FuchsschimmelHetSeedFix : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
column: "CanonicalGenotype",
value: "AA CC DD efe gg PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
column: "CanonicalGenotype",
value: "AA CC DD efe GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
column: "CanonicalGenotype",
value: "aa CC DD efe GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
column: "CanonicalGenotype",
value: "aa CC DD efe gg PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
column: "CanonicalGenotype",
value: "AA CC DD efe GG pp spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
column: "CanonicalGenotype",
value: "aa CC DD efe GG pp spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
column: "CanonicalGenotype",
value: "aa CC DD efe GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
column: "CanonicalGenotype",
value: "aa CC dd ee gg PP spsp rere");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
column: "CanonicalGenotype",
value: "AA CC DD efef gg PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
column: "CanonicalGenotype",
value: "AA CC DD efef GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
column: "CanonicalGenotype",
value: "aa CC DD efef GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
column: "CanonicalGenotype",
value: "aa CC DD efef gg PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
column: "CanonicalGenotype",
value: "AA CC DD efef GG pp spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
column: "CanonicalGenotype",
value: "aa CC DD efef GG pp spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
column: "CanonicalGenotype",
value: "aa CC DD efef GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
column: "CanonicalGenotype",
value: "aa CC dd ee gg pp spsp rere");
}
}
}

View File

@@ -483,28 +483,28 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000034"),
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
CanonicalGenotype = "AA CC DD efe gg PP spsp rere",
Name = "Polarfuchsschimmel",
SortOrder = 37
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000035"),
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
CanonicalGenotype = "AA CC DD efe GG PP spsp rere",
Name = "Algierfuchsschimmel",
SortOrder = 38
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000036"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
Name = "Kohlfuchsschimmel",
SortOrder = 39
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000037"),
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
CanonicalGenotype = "aa CC DD efe gg PP spsp rere",
Name = "Blaufuchsschimmel",
SortOrder = 40
},
@@ -525,7 +525,7 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000040"),
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
CanonicalGenotype = "AA CC DD efe GG pp spsp rere",
Name = "Goldfuchsschimmel",
SortOrder = 43
},
@@ -546,7 +546,7 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000043"),
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
CanonicalGenotype = "aa CC DD efe GG pp spsp rere",
Name = "Rotfuchsschimmel",
SortOrder = 46
},
@@ -560,7 +560,7 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000045"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
Name = "Kohlfuchsschimmel, hell",
SortOrder = 48
},
@@ -595,7 +595,7 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000050"),
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
CanonicalGenotype = "aa CC dd ee gg PP spsp rere",
Name = "Dilute Blaufuchs",
SortOrder = 53
},

View File

@@ -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([])
})
})

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,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]

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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 }
}

View File

@@ -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'

View File

@@ -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
}

View File

@@ -7,7 +7,14 @@ import { listColorVarieties, listContacts, listEnclosures, listLitters } from '.
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import {
fromDisplayString,
genotypeToFarbschlag,
displayGenotypeSafe,
toDisplayString,
hasUnknown,
inferUnknownsFromParents,
} from '../genetics'
import type { Gender, GerbilStatus } from '../api/types'
import FarbschlagImage from '../components/FarbschlagImage'
import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection'
@@ -140,6 +147,37 @@ export default function GerbilDetailPage() {
const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway'
const geno = describeGenotype(g.genotype)
// GEN-5 (Tickets cc9ea3fe / 1a508c04): unbekannte Gencode-Buchstaben aus einem
// reinerbigen Elternteil ergänzen (Mendel: ein reinerbiger Elternteil kann nur
// dieses eine Allel vererben). Greift nur, wenn der Genotyp ein '-' enthält UND
// mindestens ein Elternteil mit Genotyp am eigenen Wurf hinterlegt ist.
const genoInferred = (() => {
if (!g.genotype) return null
let child
try {
child = fromDisplayString(g.genotype)
} catch {
return null
}
if (!hasUnknown(child)) return null
const parse = (s: string | null | undefined) => {
if (!s) return null
try {
return fromDisplayString(s)
} catch {
return null
}
}
const f = parse(father.data?.genotype)
const m = parse(mother.data?.genotype)
if (!f && !m) return null
const res = inferUnknownsFromParents(child, f, m)
if (res.inferred.length === 0) return null
const display = toDisplayString(res.genotype)
if (display === geno?.display) return null
return { display, farbschlag: genotypeToFarbschlag(res.genotype) }
})()
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
@@ -346,14 +384,29 @@ export default function GerbilDetailPage() {
<dl className="ak-kvlist">
<Kv label={t.fields.genotype}>
<code className="ak-genotype">{geno.display}</code>
{genoInferred && (
<span className="ak-inferred" title={t.detail.genotypeInferredTitle}>
{' → '}
<code className="ak-genotype">{genoInferred.display}</code>{' '}
<small className="ak-inferred-chip"> {t.detail.genotypeInferred}</small>
</span>
)}
</Kv>
<Kv label={t.detail.resolvedPrefix}>
{geno.farbschlag}
{storedColorName &&
geno.farbschlag !== de.genetics.unknownFarbschlag &&
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
<small className="ak-mismatch"> {t.detail.farbschlagMismatch}</small>
)}
{(() => {
const resolvedFarbschlag = (genoInferred ?? geno).farbschlag
return (
<>
{resolvedFarbschlag}
{storedColorName &&
resolvedFarbschlag !== de.genetics.unknownFarbschlag &&
storedColorName !==
resolvedFarbschlag.replace(' Schecke', '').replace(' Rex', '') && (
<small className="ak-mismatch"> {t.detail.farbschlagMismatch}</small>
)}
</>
)
})()}
</Kv>
</dl>
) : (

View File

@@ -286,6 +286,20 @@
color: var(--ak-warn);
font-weight: 600;
}
/* GEN-5: parent-inferred genotype hint (Ee[-] → Ee aus den Eltern ergänzt). */
.ak-inferred {
white-space: nowrap;
}
.ak-inferred-chip {
display: inline-block;
background: var(--ak-tan);
color: var(--color-text);
border-radius: 999px;
padding: 1px 9px;
font-weight: 600;
font-size: 12px;
vertical-align: middle;
}
/* ---------- Character chips ---------- */
.ak-cgroup {

View File

@@ -112,6 +112,10 @@ export const de = {
resolvedPrefix: 'Errechnet',
farbschlagMismatch: 'Weicht vom eingetragenen Farbschlag ab.',
genotypeNotSet: 'Kein Genotyp hinterlegt.',
// GEN-5: ein unbekannter Gencode-Buchstabe wurde aus einem reinerbigen
// Elternteil ergänzt (z. B. Vater ee ⇒ Kind erbt e). %s = aufgelöster Genotyp.
genotypeInferred: 'Aus den Eltern ergänzt',
genotypeInferredTitle: 'Ein unbekanntes Gen wurde aus einem reinerbigen Elternteil abgeleitet.',
testMating: 'Probeverpaarung',
edit: 'Bearbeiten',
back: 'Zurück zur Liste',

View File

@@ -28,6 +28,11 @@ r = g.parse("uwuw")
check("uwuw -> gg", r["mapped8locus"].get("G") == ["g", "g"])
r = g.parse("uw[d]uw[d]")
check("uw[d]uw[d] -> gg (dense underwhite)", r["mapped8locus"].get("G") == ["g", "g"])
# GEN-5 (ticket 5151ab20 / Vance): the het 'Uwuw[d]' (one Underwhite, one dense
# underwhite) must parse to the G locus as Gg — never crash on the [d] modifier.
r = g.parse("aa Cc[chm] D- ee Uwuw[d] PP spsp")
check("Uwuw[d] -> Gg (Vance, het dense underwhite)", r["mapped8locus"].get("G") == ["G", "g"])
check("Uwuw[d] full string: nothing unmapped", r["unmappedTokens"] == [])
# Gg and Uwuw must produce the SAME mapped locus (so they stop being a conflict)
check("Gg identical to Uwuw at G locus",