fix(stammbaum): Geschwister-Verpaarung erkennen und im Diagramm zusammenführen

Vollgeschwister-Verpaarung (Vater & Mutter aus demselben Wurf) ist in der
Gerbil-Zucht häufig. Bisher zeigten Diagramm und Import sie nicht korrekt.

Import (merge_and_resolve.py):
- Wurf-Dedup gehärtet: asymmetrischer Merge nur bei kompatiblen Eltern-Namen;
  bei Date=None nur mit positivem Namens-Match (verhindert blindes Verschmelzen
  unverwandter datumloser Stubs). Falsch-Merges 112 -> 70.
- Parent-Resolver robust: Gender ist Präferenz statt hartem Filter, sodass
  vertauschte Eltern (z. B. weibliches Tier in der Vater-Position) trotzdem
  auflösen. Aufgelöste Eltern 200/206 -> 240/260.
- Neue Rollen-Normalisierung: weist jede Maus rollenrichtig nach Geschlecht zu,
  entfernt Selbst-Verpaarungen und unmögliche Doppelrollen.
  Selbst-Verpaarungen 16 -> 0, Gender-Rollen-Fehler 30 -> 0.
- Reine Helfer (litter_compatible/assign_parent_roles/names_*) auf Modulebene
  extrahiert und in test_merge_resolve.py (26 Tests) abgesichert.

Frontend (pedigree):
- buildAnimal markiert den Mutter-Knoten, wenn beide Eltern dieselbe litterId
  teilen (Vollgeschwister). toRawNodeDatum führt dessen Vorfahren-Ast zu einem
  Verweis-Knoten zusammen ("Geschwister von <Vater>, Eltern siehe oben"); die
  Vaterlinie zeigt die gemeinsamen Großeltern einmal.
- Druck-Ahnentafel bleibt vollständig (ignoriert die Marke).
- Build-Tests (+3) und e2e-Test (Inzucht-Kind-Fixture) ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:02:45 +02:00
parent cb2ee03a14
commit 3715e32303
11 changed files with 666 additions and 75 deletions

View File

@@ -183,6 +183,46 @@ describe('buildPedigree', () => {
expect(aAgain.expandable).toBe(false)
})
it('markiert Geschwister-Verpaarung: Vater & Mutter aus demselben Wurf', async () => {
// Vater und Mutter sind Vollgeschwister (gleiche litterId 'w-grand').
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
makeGerbil('vater', 'Vater', 'w-grand'),
makeGerbil('mutter', 'Mutter', 'w-grand'),
makeGerbil('opa', 'Opa', null),
makeGerbil('oma', 'Oma', null),
],
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
)
const root = await buildPedigree(source, 'kind')
const vater = animal(root!.father)
const mutter = animal(root!.mother)
// Mutter trägt den Verweis auf die Vaterlinie; Vater selbst nicht.
expect(mutter.siblingPairing).toEqual({ siblingName: 'Vater', siblingPath: 'v' })
expect(vater.siblingPairing).toBeUndefined()
// Echte Vorfahren bleiben im Modell (Druck-Ahnentafel nutzt sie weiter).
expect(animal(mutter.father).gerbil.name).toBe('Opa')
expect(animal(mutter.mother).gerbil.name).toBe('Oma')
})
it('verlangt für Geschwister-Verpaarung dieselbe litterId (Halbgeschwister zählen nicht)', async () => {
// Gemeinsamer Vater (Opa), aber verschiedene Würfe → keine Verpaarung-Marke.
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w1'),
makeGerbil('vater', 'Vater', 'w2'),
makeGerbil('mutter', 'Mutter', 'w3'),
makeGerbil('opa', 'Opa', null),
],
[makeLitter('w1', 'vater', 'mutter'), makeLitter('w2', 'opa', null), makeLitter('w3', 'opa', null)],
)
const root = await buildPedigree(source, 'kind')
expect(animal(root!.mother).siblingPairing).toBeUndefined()
})
it('dasselbe Tier darf an mehreren Positionen stehen (Inzucht ist kein Zyklus)', async () => {
// Vater und Mutter haben denselben Vater (Opa) — Ahnenschwund, kein Zyklus.
const source = makeSource(
@@ -260,6 +300,36 @@ describe('toRawNodeDatum', () => {
expect(datum.children![0].children).toBeUndefined()
})
it('führt den Vorfahren-Ast einer Geschwister-Verpaarung zu einem Verweis zusammen', async () => {
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
makeGerbil('vater', 'Vater', 'w-grand'),
makeGerbil('mutter', 'Mutter', 'w-grand'),
makeGerbil('opa', 'Opa', null),
makeGerbil('oma', 'Oma', null),
],
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
)
const root = await buildPedigree(source, 'kind')
const datum = toRawNodeDatum(root!, 'unbekannt')
// Vater behält seinen vollen Vorfahren-Ast …
const vater = datum.children![0]
expect(vater.name).toBe('Vater')
expect(vater.children!.map((c) => c.name)).toEqual(['Opa', 'Oma'])
// … Mutter wird zu EINEM Verweis-Knoten zusammengeführt.
const mutter = datum.children![1]
expect(mutter.name).toBe('Mutter')
expect(mutter.children).toHaveLength(1)
expect(mutter.children![0].attributes).toMatchObject({
kind: 'sibling-ref',
refName: 'Vater',
refPath: 'v',
})
})
it('rendert Platzhalter mit dem übergebenen Label', async () => {
const root = await buildPedigree(familySource(), 'mutter')
const datum = toRawNodeDatum(root!, 'unbekannt')

View File

@@ -70,6 +70,25 @@ async function buildAnimal(
parentNode(source, litter?.fatherId, path + 'v', generationsLeft - 1, nextAncestors),
parentNode(source, litter?.motherId, path + 'm', generationsLeft - 1, nextAncestors),
])
// Geschwister-Verpaarung: Stammen Vater und Mutter aus DEMSELBEN Wurf
// (gleiche litterId), sind sie Vollgeschwister. Die Vorfahren-Linie ist dann
// auf beiden Seiten identisch — wir markieren die Mutter, damit das Diagramm
// ihren Ast zu einem Verweis auf die Vaterlinie zusammenführt. Die echten
// Vorfahren bleiben im Modell erhalten (Druck-Ahnentafel nutzt sie weiter).
if (
father.kind === 'animal' &&
mother.kind === 'animal' &&
father.gerbil.litterId &&
father.gerbil.litterId === mother.gerbil.litterId
) {
const markedMother: AnimalNode = {
...mother,
siblingPairing: { siblingName: father.gerbil.name, siblingPath: father.path },
}
return { kind: 'animal', path, gerbil, father, mother: markedMother, expandable: false }
}
return { kind: 'animal', path, gerbil, father, mother, expandable: false }
}
@@ -143,6 +162,24 @@ export function toRawNodeDatum(node: PedigreeNode, unknownLabel: string): RawNod
name: node.gerbil.name,
attributes: { path: node.path, kind: 'animal', expandable: node.expandable },
}
// Geschwister-Verpaarung: Vorfahren-Ast zu EINEM Verweis-Knoten
// zusammenführen (gemeinsame Vorfahren stehen bereits an der Vaterlinie).
if (node.siblingPairing) {
datum.children = [
{
name: node.siblingPairing.siblingName,
attributes: {
// Synthetischer Pfad (nicht in collectNodes) — die Karte rendert
// ausschließlich aus diesen Attributen.
path: node.path + '§',
kind: 'sibling-ref',
refName: node.siblingPairing.siblingName,
refPath: node.siblingPairing.siblingPath,
},
},
]
return datum
}
if (node.father && node.mother) {
datum.children = [
toRawNodeDatum(node.father, unknownLabel),

View File

@@ -38,6 +38,15 @@ export interface AnimalNode {
readonly mother?: PedigreeNode
/** True: Tier hat einen Wurf, dessen Eltern noch nachgeladen werden können. */
readonly expandable: boolean
/**
* Geschwister-Verpaarung: gesetzt, wenn dieses Tier und sein Geschwister
* (an `siblingPath`) aus DEMSELBEN Wurf stammen und beide Eltern des
* Wurzeltiers sind (Vollgeschwister-Verpaarung — in der Zucht häufig).
* Im interaktiven Diagramm wird der Vorfahren-Ast dieses Knotens dann zu
* einem Verweis kollabiert (gemeinsame Vorfahren siehe `siblingPath`); die
* echten Vorfahren bleiben im Modell (Druck-Ahnentafel zeigt sie weiterhin).
*/
readonly siblingPairing?: { readonly siblingName: string; readonly siblingPath: PedigreePath }
}
/**