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), ("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
// --- Schimmel / Fuchsschimmel (IDs 33-37) --- // --- Schimmel / Fuchsschimmel (IDs 33-37) ---
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36), ("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37), // GEN-5 (ticket 5826e8e2): *Fuchsschimmel = HET ef/e (internal 'efe'),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38), // not hom ef/ef — a Schimmel-modified Fox. Pure *schimmel stay efef.
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39), ("Polarfuchsschimmel", "AA CC DD efe gg PP spsp rere", 37),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40), ("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) --- // --- Hell variants (IDs 38-48) ---
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41), ("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42), ("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), ("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45), ("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), ("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), ("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50), ("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51), ("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
// --- Dilute (dd) renamed variants (IDs 49-50) --- // --- Dilute (dd) renamed variants (IDs 49-50) ---
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52), ("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 / Siam / CP- series (IDs 51-66) ---
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54), ("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
("Siam", "aa cchmch DD EE GG PP spsp rere", 55), ("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 new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000034"), 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", Name = "Polarfuchsschimmel",
SortOrder = 37 SortOrder = 37
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000035"), 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", Name = "Algierfuchsschimmel",
SortOrder = 38 SortOrder = 38
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000036"), 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", Name = "Kohlfuchsschimmel",
SortOrder = 39 SortOrder = 39
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000037"), 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", Name = "Blaufuchsschimmel",
SortOrder = 40 SortOrder = 40
}, },
@@ -525,7 +525,7 @@ namespace GerbilManagerWebAPI.Migrations
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000040"), 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", Name = "Goldfuchsschimmel",
SortOrder = 43 SortOrder = 43
}, },
@@ -546,7 +546,7 @@ namespace GerbilManagerWebAPI.Migrations
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000043"), 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", Name = "Rotfuchsschimmel",
SortOrder = 46 SortOrder = 46
}, },
@@ -560,7 +560,7 @@ namespace GerbilManagerWebAPI.Migrations
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000045"), 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", Name = "Kohlfuchsschimmel, hell",
SortOrder = 48 SortOrder = 48
}, },
@@ -595,7 +595,7 @@ namespace GerbilManagerWebAPI.Migrations
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000050"), 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", Name = "Dilute Blaufuchs",
SortOrder = 53 SortOrder = 53
}, },

View File

@@ -17,6 +17,8 @@ import {
wildType, wildType,
extractGenotypeFlags, extractGenotypeFlags,
displayGenotypeSafe, displayGenotypeSafe,
resolveAllelePair,
inferUnknownsFromParents,
} from '../genotype' } from '../genotype'
import { combineLocus } from '../punnett' import { combineLocus } from '../punnett'
import { LOCI, type LocusKey } from '../loci' import { LOCI, type LocusKey } from '../loci'
@@ -239,10 +241,11 @@ describe('Farbschlag catalog', () => {
}) })
describe('Partially-unknown parents (wildcards)', () => { describe('Partially-unknown parents (wildcards)', () => {
it('handles an A? parent (phenotype agouti, genotype unknown)', () => { it('GEN-5: an A? parent reads as AA (unknown copies the known allele)', () => {
// A? x aa at the A locus -> father gamete: 1/2 A, 1/4 (each of A,a) from "?" // GEN-5 (ticket 3e643ef1, breeder rule): the unknown allele '?' is a COPY of
// = effectively 3/4 A, 1/4 a ; mother always a. // the known partner 'A', so A? = AA. AA × aa → all Aa → 100% Agouti.
// Offspring: 3/4 Aa, 1/4 aa. // (Previously '?' spread uniformly → 3/4 Agouti : 1/4 Schwarz, which invented
// a recessive 'a' gamete the parent demonstrably does not show.)
const father = makeGenotype({ const father = makeGenotype({
A: ['A', '?'], A: ['A', '?'],
C: ['C', 'C'], C: ['C', 'C'],
@@ -257,10 +260,10 @@ describe('Partially-unknown parents (wildcards)', () => {
const mother = fromDisplayString('aa CC DD EE GG PP spsp rere') const mother = fromDisplayString('aa CC DD EE GG PP spsp rere')
const result = breed(father, mother) const result = breed(father, mother)
const agouti = result.byFarbschlag.find((f) => f.farbschlag === 'Agouti')! expect(result.offspring).toHaveLength(1)
const schwarz = result.byFarbschlag.find((f) => f.farbschlag === 'Schwarz')! expect(result.offspring[0].farbschlag).toBe('Agouti')
expect(agouti.probability.text).toBe('3/4') expect(result.offspring[0].probability.text).toBe('1')
expect(schwarz.probability.text).toBe('1/4') 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 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 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 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 Gold')
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin') 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', () => { it('Farbarten (categories) never appear as computed results', () => {
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard. // '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') 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) // 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('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') // Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true) 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) // 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('') 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 * meta rows dropped, 17 matched the frozen names). Genotypes normalized from
* portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown). * 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 { import {
makeGenotype, makeGenotype,
resolveAllelePair,
toDisplayString, toDisplayString,
wildType, wildType,
WILDCARD,
type AllelePair, type AllelePair,
type Genotype, type Genotype,
} from './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: '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: '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 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/3g: c^chm colourpoint varieties ──
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell). // GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
@@ -163,13 +167,10 @@ export interface FarbschlagMatch {
* variety specifically. * variety specifically.
*/ */
function locusToken(g: Genotype, locus: LocusKey): string { function locusToken(g: Genotype, locus: LocusKey): string {
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the // GEN-5: an unknown allele is a COPY of the known partner (resolveAllelePair),
// colour loci (unknown-C => full-colour 'C', not a white), but the recessive // so e.g. [e,?] reads as ee (Fuchs), NOT e/E. Only a fully-unknown locus falls
// UNMARKED allele for the spotting/rex markers (unknown-Sp must NOT imply Schecke). // back to the wild-type reading (most-dominant colour / unmarked marker).
const alleles = LOCI[locus].alleles const [x, y] = resolveAllelePair(locus, g[locus])
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))
if (locus === 'E') { if (locus === 'E') {
if (x === y) return x // ee->'e', efef->'ef', EE->'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; // 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). * computed farbschlag output (the farbschlagFor category guard blocks them).
*/ */
function eFamily(g: Genotype): string | null { 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 === 'e') return 'Fuchs'
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel' if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
if (x === 'ef' && y === 'ef') return 'Schimmel' if (x === 'ef' && y === 'ef') return 'Schimmel'
if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs'
return null return null
} }
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */ /** 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] { function resolvedPair(g: Genotype, locus: LocusKey): [string, string] {
const alleles = LOCI[locus].alleles return resolveAllelePair(locus, g[locus])
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] * 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. */ /** Base colour name (no modifiers, no colourpoint prefix), via E-family + matches. */
function baseColourFor(g: Genotype): string | null { function baseColourFor(g: Genotype): string | null {
const family = eFamily(g) const family = eFamily(g)
const base = family 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) : (BASE_COLORS.find((e) => matches(g, e)) ?? null)
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are // GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches, // 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). // Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch).
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm' const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
const agouti = resolvedPair(g, 'A').includes('A') const agouti = resolvedPair(g, 'A').includes('A')
if (!agouti) { // GEN-5 (tickets 3deab547 / efa2b232): the aa marten names (Marder/Siam/Zobel/
// #3: the aa colourpoint branch must respect D (dilute) and E (Fuchs/Schimmel) // Zobel-Hell) are FULL-EXTENSION (E) sable varieties only. A non-agouti
// instead of hard-coding Marder/Siam/Zobel. The frozen breeder names // colourpoint that is Fuchs (ee) or Schimmel (ef) is NOT a Marder/Zobel — it
// Marder/Siam/Zobel/Zobel-Hell only describe the wild D + full-extension case // must derive its base generically like the A- branch, so e.g.
// (aa cchm DD EE [gg]); they are kept for that case. Any non-wild D or E (e.g. // aa cchm dd ee gg → 'Dilute CP-Polarfuchs' (dilute + fox + grey), never Zobel.
// dd dilute or ee Fuchs) is named from the resolved base colour, so if (!agouti && eFamily(g) === null) {
// 'aa cchm dd ee gg' no longer collapses to Zobel. const [g1, g2] = resolvedPair(g, 'G')
const [d1, d2] = resolvedPair(g, 'D') const grey = g1 === 'g' && g2 === 'g'
const wildD = d1 === 'D' && d2 === 'D' if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
const fullExtension = eFamily(g) === null // E expresses full 'E' (not Fuchs/Schimmel) return bothCchm ? 'Marder' : 'Siam'
if (wildD && fullExtension) {
const [g1, g2] = resolvedPair(g, 'G')
const grey = g1 === 'g' && g2 === 'g'
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
return bothCchm ? 'Marder' : 'Siam'
}
// dilute and/or Fuchs/Schimmel aa colourpoint → derive from the base colour.
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
if (!base) return null
const DILUTE = 'Dilute '
if (base.startsWith(DILUTE)) {
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
}
return `CP-${base}${bothCchm ? '' : '-Hell'}`
} }
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix. // 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'] })) const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
if (!base) return null if (!base) return null
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'. // 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 { export function representativeGenotype(entry: FarbschlagEntry): Genotype {
const base = wildType() const base = wildType()
const out = {} as Record<LocusKey, AllelePair> 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) { for (const locus of LOCUS_ORDER) {
const token = entry.tokens[locus] const token = entry.tokens[locus]
if (!token) { if (!token) {
out[locus] = base[locus] out[locus] = base[locus]
continue 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 // 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. // colourpoints Siam/Zobel-Hell use C: 'cchm/ch'); otherwise it's homozygous.
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token] const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]

View File

@@ -236,25 +236,25 @@
}, },
{ {
"name": "Polarfuchsschimmel", "name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere", "canonicalGenotype": "AA CC DD efe gg PP spsp rere",
"sortOrder": 37, "sortOrder": 37,
"image": "polarfuchsschimmel.jpg" "image": "polarfuchsschimmel.jpg"
}, },
{ {
"name": "Algierfuchsschimmel", "name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere", "canonicalGenotype": "AA CC DD efe GG PP spsp rere",
"sortOrder": 38, "sortOrder": 38,
"image": "algierfuchsschimmel.jpg" "image": "algierfuchsschimmel.jpg"
}, },
{ {
"name": "Kohlfuchsschimmel", "name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere", "canonicalGenotype": "aa CC DD efe GG PP spsp rere",
"sortOrder": 39, "sortOrder": 39,
"image": "kohlfuchsschimmel.jpg" "image": "kohlfuchsschimmel.jpg"
}, },
{ {
"name": "Blaufuchsschimmel", "name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD efef gg PP spsp rere", "canonicalGenotype": "aa CC DD efe gg PP spsp rere",
"sortOrder": 40, "sortOrder": 40,
"image": "blaufuchsschimmel.jpg" "image": "blaufuchsschimmel.jpg"
}, },
@@ -272,7 +272,7 @@
}, },
{ {
"name": "Goldfuchsschimmel", "name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere", "canonicalGenotype": "AA CC DD efe GG pp spsp rere",
"sortOrder": 43, "sortOrder": 43,
"image": "goldfuchsschimmel.jpg" "image": "goldfuchsschimmel.jpg"
}, },
@@ -290,7 +290,7 @@
}, },
{ {
"name": "Rotfuchsschimmel", "name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG pp spsp rere", "canonicalGenotype": "aa CC DD efe GG pp spsp rere",
"sortOrder": 46, "sortOrder": 46,
"image": "rotfuchsschimmel.jpg" "image": "rotfuchsschimmel.jpg"
}, },
@@ -302,7 +302,7 @@
}, },
{ {
"name": "Kohlfuchsschimmel, hell", "name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere", "canonicalGenotype": "aa CC DD efe GG PP spsp rere",
"sortOrder": 48, "sortOrder": 48,
"image": "kohlfuchsschimmel-hell.jpg" "image": "kohlfuchsschimmel-hell.jpg"
}, },
@@ -332,7 +332,7 @@
}, },
{ {
"name": "Dilute Blaufuchs", "name": "Dilute Blaufuchs",
"canonicalGenotype": "aa CC dd ee gg pp spsp rere", "canonicalGenotype": "aa CC dd ee gg PP spsp rere",
"sortOrder": 53, "sortOrder": 53,
"image": "blaufuchs-dd.jpg" "image": "blaufuchs-dd.jpg"
}, },

View File

@@ -236,25 +236,25 @@
}, },
{ {
"name": "Polarfuchsschimmel", "name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp", "canonicalGenotype": "AA CC DD ee[f] gg PP spsp",
"sortOrder": 37, "sortOrder": 37,
"image": "polarfuchsschimmel.jpg" "image": "polarfuchsschimmel.jpg"
}, },
{ {
"name": "Algierfuchsschimmel", "name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp", "canonicalGenotype": "AA CC DD ee[f] GG PP spsp",
"sortOrder": 38, "sortOrder": 38,
"image": "algierfuchsschimmel.jpg" "image": "algierfuchsschimmel.jpg"
}, },
{ {
"name": "Kohlfuchsschimmel", "name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp", "canonicalGenotype": "aa CC DD ee[f] GG PP spsp",
"sortOrder": 39, "sortOrder": 39,
"image": "kohlfuchsschimmel.jpg" "image": "kohlfuchsschimmel.jpg"
}, },
{ {
"name": "Blaufuchsschimmel", "name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp", "canonicalGenotype": "aa CC DD ee[f] gg PP spsp",
"sortOrder": 40, "sortOrder": 40,
"image": "blaufuchsschimmel.jpg" "image": "blaufuchsschimmel.jpg"
}, },
@@ -272,7 +272,7 @@
}, },
{ {
"name": "Goldfuchsschimmel", "name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp", "canonicalGenotype": "AA CC DD ee[f] GG pp spsp",
"sortOrder": 43, "sortOrder": 43,
"image": "goldfuchsschimmel.jpg" "image": "goldfuchsschimmel.jpg"
}, },
@@ -290,7 +290,7 @@
}, },
{ {
"name": "Rotfuchsschimmel", "name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp", "canonicalGenotype": "aa CC DD ee[f] GG pp spsp",
"sortOrder": 46, "sortOrder": 46,
"image": "rotfuchsschimmel.jpg" "image": "rotfuchsschimmel.jpg"
}, },
@@ -302,7 +302,7 @@
}, },
{ {
"name": "Kohlfuchsschimmel, hell", "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, "sortOrder": 48,
"image": "kohlfuchsschimmel-hell.jpg" "image": "kohlfuchsschimmel-hell.jpg"
}, },
@@ -332,7 +332,7 @@
}, },
{ {
"name": "Dilute Blaufuchs", "name": "Dilute Blaufuchs",
"canonicalGenotype": "aa CC dd ee gg pp spsp", "canonicalGenotype": "aa CC dd ee gg PP spsp",
"sortOrder": 53, "sortOrder": 53,
"image": "blaufuchs-dd.jpg" "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] 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 { function assertAllele(locus: LocusKey, allele: string): void {
if (allele === WILDCARD) return if (allele === WILDCARD) return
if (!LOCI[locus].alleles.includes(allele)) { if (!LOCI[locus].alleles.includes(allele)) {
@@ -180,27 +212,27 @@ function normalizeToken(tok: string): string | null {
let t = tok let t = tok
if (t === 'WP') t = 'Slsl' if (t === 'WP') t = 'Slsl'
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl') 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 // GEN-5: dense-underwhite modifier uw[d]/Uw[d] (G-locus). The German "[d]"
// underwhite) annotation from the uw/Uw token BEFORE aliasing to G/g, so that // dense marker is a shade qualifier, not a separate allele — strip it BEFORE
// "Uwuw[d]" → "Gg" and "uw[d]uw[d]" → "gg" (mirrors tools/import/genotype.py // the Uw→G alias so e.g. "Uwuw[d]" / "uw[d]uw[d]" parse as Gg / gg, not "Gg[d]"
// _rewrite_uw). Without this the "[d]" survived → splitToken("Gg[d]") threw and // (which crashes splitToken). Mirrors tools/import/genotype.py _rewrite_uw.
// the frontend fell back to "Unbekannter Farbschlag" / leaked the raw uw token. t = t.replace(/uw\[d\]/gi, 'uw')
t = t.replace(/(Uw|uw)\[d\]/g, '$1')
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g') t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
// GEN-3h: accept bracket display notation → canonical internal symbols. // GEN-3h: accept bracket display notation → canonical internal symbols.
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal 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\[chm\]/g, 'cchm') // Colourpoint display form → internal
t = t.replace(/c\[h\]/g, 'ch') // Himalayan 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 // #42 (E-locus): a visible Fuchs is RECESSIVE → MUST be homozygous "ee". The herdbook
// homozygous "ee". The herdbook form "ee[-]" (fox allele + unknown E-type // form "ee[-]" (fox allele + unknown E-type partner) therefore resolves to "ee" (Fuchs),
// second allele) therefore resolves to "ee" (Fuchs), NOT [e,?]; the recessive // NOT [e,?] — the recessive phenotype implies homozygosity. A bare "e-"/"e[-]" (a lone
// phenotype implies homozygosity. A bare "e-" / "e[-]" (a single recessive // recessive fox with an unknown partner) is genetically impossible and is rejected below.
// fox allele with an unknown partner) is genetically impossible and is left to
// be rejected by splitToken (invalid → genotypeInvalid path).
t = t.replace(/ee\[-\]/g, 'ee').replace(/ee-/g, 'ee') t = t.replace(/ee\[-\]/g, 'ee').replace(/ee-/g, 'ee')
// CR-1a: allele-prefixed bracket-unknown like cc[-]: when e[-]/c[-] is PRECEDED // CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain).
// by a letter it is the second unknown allele in a 2-allele token. Lookbehind // When e[-] is PRECEDED by a letter it is the second unknown allele in a
// strips only the bracket part; the leading allele stays. // 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])e\[-\]/g, '?')
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?') t = t.replace(/(?<=[A-Za-z])c\[-\]/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] const locus = ALLELE_TO_LOCUS[refAllele]
if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`) if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)
if (acc[locus]) throw new Error(`Locus ${locus} given twice`) if (acc[locus]) throw new Error(`Locus ${locus} given twice`)
// #42: a lone recessive Fuchs allele with an unknown partner ("e-"/"e[-]" → // #42: a lone recessive Fuchs allele with an unknown partner ("e-"/"e[-]" → [e,?]) is
// [e,?]) is genetically impossible — fox is recessive, so a fox allele is // genetically impossible — fox is recessive, so a fox allele is only visible homozygous
// only visible homozygous (ee, written "ee[-]"). Reject it so the UI surfaces // ("ee", written "ee[-]"). Reject it so the UI surfaces the invalid-genotype message
// the genotypeInvalid message instead of silently mis-computing the colour. // instead of silently mis-computing the colour. ("ee[-]" was already normalized to "ee".)
if (locus === 'E' && ((a === 'e' && b === WILDCARD) || (a === WILDCARD && b === 'e'))) { 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) acc[locus] = canonicalPair(locus, a, b)
} }
@@ -301,3 +335,75 @@ export function displayGenotypeSafe(raw: string | null | undefined): string {
export function hasUnknown(g: Genotype): boolean { export function hasUnknown(g: Genotype): boolean {
return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD) 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, fromJSON,
hasUnknown, hasUnknown,
displayGenotypeSafe, displayGenotypeSafe,
resolveAllelePair,
inferUnknownsFromParents,
WILDCARD, WILDCARD,
} from './genotype' } from './genotype'
export type { Genotype, AllelePair } from './genotype' export type {
Genotype,
AllelePair,
ParentInferenceResult,
ParentInferredLocus,
} from './genotype'
export { LOCI, LOCUS_ORDER } from './loci' export { LOCI, LOCUS_ORDER } from './loci'
export type { LocusKey, LocusDef } from './loci' export type { LocusKey, LocusDef } from './loci'

View File

@@ -10,72 +10,40 @@
* Wildcard ("?") alleles are expanded uniformly over the locus' allele set * Wildcard ("?") alleles are expanded uniformly over the locus' allele set
* before combining, so a parent known only by phenotype can still be paired. * before combining, so a parent known only by phenotype can still be paired.
*/ */
import { add, frac, multiply, ONE, type Fraction } from './fraction' import { add, frac, multiply, type Fraction, ONE } from './fraction'
import { dominanceRank, LOCI, LOCUS_ORDER, type LocusKey } from './loci' import { LOCUS_ORDER, type LocusKey } from './loci'
import { import {
canonicalPair, canonicalPair,
resolveAllelePair,
toDisplayString, toDisplayString,
WILDCARD,
type AllelePair, type AllelePair,
type Genotype, type Genotype,
} from './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). */ /** A probability distribution over outcomes of type T (keyed by a string). */
export interface DistEntry<T> { export interface DistEntry<T> {
readonly value: T readonly value: T
readonly probability: Fraction 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> { function parentAlleleWeights(locus: LocusKey, pair: AllelePair): Map<string, Fraction> {
const weights = new Map<string, Fraction>() const weights = new Map<string, Fraction>()
const addWeight = (allele: string, w: Fraction) => { const addWeight = (allele: string, w: Fraction) => {
weights.set(allele, add(weights.get(allele) ?? frac(0, 1), w)) 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: for (const a of resolveAllelePair(locus, pair)) addWeight(a, frac(1, 2))
// 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))
}
}
return weights return weights
} }

View File

@@ -7,7 +7,14 @@ import { listColorVarieties, listContacts, listEnclosures, listLitters } from '.
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels' import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits' 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 type { Gender, GerbilStatus } from '../api/types'
import FarbschlagImage from '../components/FarbschlagImage' import FarbschlagImage from '../components/FarbschlagImage'
import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection' import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection'
@@ -140,6 +147,37 @@ export default function GerbilDetailPage() {
const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway' const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway'
const geno = describeGenotype(g.genotype) 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 lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
@@ -346,14 +384,29 @@ export default function GerbilDetailPage() {
<dl className="ak-kvlist"> <dl className="ak-kvlist">
<Kv label={t.fields.genotype}> <Kv label={t.fields.genotype}>
<code className="ak-genotype">{geno.display}</code> <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>
<Kv label={t.detail.resolvedPrefix}> <Kv label={t.detail.resolvedPrefix}>
{geno.farbschlag} {(() => {
{storedColorName && const resolvedFarbschlag = (genoInferred ?? geno).farbschlag
geno.farbschlag !== de.genetics.unknownFarbschlag && return (
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && ( <>
<small className="ak-mismatch"> {t.detail.farbschlagMismatch}</small> {resolvedFarbschlag}
)} {storedColorName &&
resolvedFarbschlag !== de.genetics.unknownFarbschlag &&
storedColorName !==
resolvedFarbschlag.replace(' Schecke', '').replace(' Rex', '') && (
<small className="ak-mismatch"> {t.detail.farbschlagMismatch}</small>
)}
</>
)
})()}
</Kv> </Kv>
</dl> </dl>
) : ( ) : (

View File

@@ -286,6 +286,20 @@
color: var(--ak-warn); color: var(--ak-warn);
font-weight: 600; 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 ---------- */ /* ---------- Character chips ---------- */
.ak-cgroup { .ak-cgroup {

View File

@@ -112,6 +112,10 @@ export const de = {
resolvedPrefix: 'Errechnet', resolvedPrefix: 'Errechnet',
farbschlagMismatch: 'Weicht vom eingetragenen Farbschlag ab.', farbschlagMismatch: 'Weicht vom eingetragenen Farbschlag ab.',
genotypeNotSet: 'Kein Genotyp hinterlegt.', 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', testMating: 'Probeverpaarung',
edit: 'Bearbeiten', edit: 'Bearbeiten',
back: 'Zurück zur Liste', back: 'Zurück zur Liste',

View File

@@ -28,6 +28,11 @@ r = g.parse("uwuw")
check("uwuw -> gg", r["mapped8locus"].get("G") == ["g", "g"]) check("uwuw -> gg", r["mapped8locus"].get("G") == ["g", "g"])
r = g.parse("uw[d]uw[d]") r = g.parse("uw[d]uw[d]")
check("uw[d]uw[d] -> gg (dense underwhite)", r["mapped8locus"].get("G") == ["g", "g"]) 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) # Gg and Uwuw must produce the SAME mapped locus (so they stop being a conflict)
check("Gg identical to Uwuw at G locus", check("Gg identical to Uwuw at G locus",