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

@@ -509,6 +509,73 @@ def parse_death_info(notes, status, existing_dod, existing_cod):
return resolved_status, dod, cod
# ── Litter dedup & parent-role helpers (pure, unit-tested in test_merge_resolve.py) ──
def _norm_pname(s):
return normalize_name(s) if s else ""
def names_no_conflict(l1, l2):
"""Parent names don't contradict (equal per role, or one side empty)."""
f1, f2 = _norm_pname(l1.get("_father_name")), _norm_pname(l2.get("_father_name"))
m1, m2 = _norm_pname(l1.get("_mother_name")), _norm_pname(l2.get("_mother_name"))
f_ok = (not f1) or (not f2) or (f1 == f2)
m_ok = (not m1) or (not m2) or (m1 == m2)
return f_ok and m_ok
def names_overlap(l1, l2):
"""At least one role has a non-empty matching name (positive evidence)."""
f1, f2 = _norm_pname(l1.get("_father_name")), _norm_pname(l2.get("_father_name"))
m1, m2 = _norm_pname(l1.get("_mother_name")), _norm_pname(l2.get("_mother_name"))
return bool((f1 and f1 == f2) or (m1 and m1 == m2))
def litter_compatible(l1, l2):
"""Two litter records describe the same litter: same date and compatible parents.
- Both sides have both parents → must match exactly.
- Asymmetric (one side resolved, the other not) → merge only if names don't
contradict; for dateless litters require a POSITIVE name match (a shared
null date is no evidence), so unrelated nameless stubs stay separate.
- Neither side has parents → never blind-merge.
"""
if l1["Date"] != l2["Date"]:
return False
f1, m1 = l1.get("FatherId"), l1.get("MotherId")
f2, m2 = l2.get("FatherId"), l2.get("MotherId")
if f1 and f2 and m1 and m2:
return f1 == f2 and m1 == m2
asymmetric = (bool(f1 or m1) and not (f2 or m2)) or (bool(f2 or m2) and not (f1 or m1))
if asymmetric:
if not names_no_conflict(l1, l2):
return False
if l1["Date"] is None:
return names_overlap(l1, l2)
return True
return False
def assign_parent_roles(father_id, mother_id, gender_of):
"""Assign two resolved parent IDs to father/mother roles by gender.
Drops self-pairing duplicates (same animal in both roles) and never returns
two same-role parents. `gender_of` maps an id to 'male'|'female'|'unknown'|None.
Returns (father_id, mother_id).
"""
ids = []
for gid in (father_id, mother_id):
if gid and gid not in ids:
ids.append(gid)
males = [g for g in ids if gender_of(g) == "male"]
females = [g for g in ids if gender_of(g) == "female"]
unknowns = [g for g in ids if gender_of(g) == "unknown"]
father = males[0] if males else (unknowns.pop(0) if unknowns else None)
mother = females[0] if females else (unknowns.pop(0) if unknowns else None)
return father, mother
def main():
print("Loading color variety seeds...")
variety_map = {}
@@ -1049,6 +1116,60 @@ def main():
print(f"Processed {len(resolved_litters)} litters.")
# 3b. Deduplicate litters: same date + compatible parents → merge
# This handles the "sibling pairing" case: Stammbaum shows the same parental
# litter twice (once under the father branch, once under the mother branch),
# generating two separate litter records with the same date but only one of
# them has FatherId/MotherId resolved.
litter_canonical_map = {} # old_id -> canonical_id (for dedup within this step)
# Group by date for efficiency
by_date = {}
for l in resolved_litters:
by_date.setdefault(l["Date"], []).append(l)
litter_dedup_canonical = {} # old_litter_id -> canonical_litter_id
deduped_litters = []
for date_val, group in by_date.items():
# Partition into compatible subsets
sub_groups = []
for l in group:
placed = False
for sub in sub_groups:
if all(litter_compatible(l, member) for member in sub):
sub.append(l)
placed = True
break
if not placed:
sub_groups.append([l])
for sub in sub_groups:
if len(sub) == 1:
deduped_litters.append(sub[0])
litter_dedup_canonical[sub[0]["Id"]] = sub[0]["Id"]
continue
# Pick the canonical record: prefer the one with parents set
canonical = next((l for l in sub if l.get("FatherId") or l.get("MotherId")), sub[0])
for l in sub:
litter_dedup_canonical[l["Id"]] = canonical["Id"]
if l is not canonical:
litter_id_map[l["Id"]] = canonical["Id"]
deduped_litters.append(canonical)
if len(sub) > 1:
merged_names = [l["Id"] for l in sub if l is not canonical]
print(f"Litter-Dedup: merged {len(sub)} same-date litters on {date_val}{canonical['Name']} (absorbed: {', '.join(merged_names)})")
n_merged = len(resolved_litters) - len(deduped_litters)
if n_merged:
print(f"Litter-Dedup: {n_merged} redundant litter record(s) removed.")
resolved_litters = deduped_litters
litter_by_scoped_id = {l["Id"]: l for l in resolved_litters}
# 4. Normalize and group Gerbils
# Helper to lookup litter dates for birth date estimation
def get_litter_date(l_id):
if l_id in litter_by_scoped_id:
@@ -1057,7 +1178,6 @@ def main():
return d
return None
# 4. Normalize and group Gerbils
all_processed_gerbils = []
for rg in raw_gerbils:
filename = rg.get("_filename")
@@ -1716,59 +1836,70 @@ def main():
# Parent Resolver (Global Name Matching)
resolved_fathers = 0
resolved_mothers = 0
gerbil_by_id_final = {g["Id"]: g for g in resolved_gerbils}
def _final_gender(gid):
g = gerbil_by_id_final.get(gid)
return g["Gender"] if g else None
def _resolve_name(name, prefer_gender, litter_date):
"""Resolve a parent name to the best matching final gerbil.
Gender is a PREFERENCE, not a hard filter: a reversed parent (e.g. a
female listed in the father position, as the Stammbaum often does) still
resolves to a gerbil — the role is corrected afterwards by gender. This
is what previously left FatherId/MotherId null (the candidate was
filtered out for having the "wrong" gender for its slot).
"""
if not name:
return None
cands = []
for c in gerbil_by_norm_name.get(normalize_name(name), []):
final_id = gerbil_id_map.get(c["Id"])
if not final_id:
continue
final_c = gerbil_by_id_final.get(final_id)
if not final_c:
continue
# Parent must be born before the litter (when both dates are known).
if litter_date and final_c["DateOfBirth"] and final_c["DateOfBirth"] >= litter_date:
continue
cands.append(final_c)
if not cands:
return None
# Prefer the gender expected for this role, then unknown, then anything.
for pool in (
[c for c in cands if c["Gender"] == prefer_gender],
[c for c in cands if c["Gender"] == "unknown"],
cands,
):
if pool:
return pool[0]
return None
for l in resolved_litters:
# Match Father by Name
f_name = l["_father_name"]
if f_name and not l["FatherId"]:
f_norm = normalize_name(f_name)
candidates = gerbil_by_norm_name.get(f_norm, [])
valid_candidates = []
for c in candidates:
# Map to final deduplicated ID
final_id = gerbil_id_map.get(c["Id"])
if not final_id:
continue
# Retrieve final record
final_c = next((rg for rg in resolved_gerbils if rg["Id"] == final_id), None)
if final_c and final_c["Gender"] in ["male", "unknown"]:
# Ensure parent is born before litter if birth date is known
if l["Date"] and final_c["DateOfBirth"]:
if final_c["DateOfBirth"] < l["Date"]:
valid_candidates.append(final_c)
else:
valid_candidates.append(final_c)
if len(valid_candidates) == 1:
l["FatherId"] = valid_candidates[0]["Id"]
resolved_fathers += 1
elif len(valid_candidates) > 1:
l["FatherId"] = valid_candidates[0]["Id"]
# Pre-check: if _father_name points to a known female and _mother_name to a
# known male → swap names (Stammbaum positions reversed). Helps the name
# resolver pick the right same-name candidate before role normalization.
f_name_pre = l.get("_father_name", "")
m_name_pre = l.get("_mother_name", "")
if f_name_pre and m_name_pre:
f_gender = next((g["Gender"] for g in gerbil_by_norm_name.get(normalize_name(f_name_pre), []) if g["Gender"] != "unknown"), None)
m_gender = next((g["Gender"] for g in gerbil_by_norm_name.get(normalize_name(m_name_pre), []) if g["Gender"] != "unknown"), None)
if f_gender == "female" and m_gender == "male":
l["_father_name"], l["_mother_name"] = m_name_pre, f_name_pre
if l["_father_name"] and not l["FatherId"]:
cand = _resolve_name(l["_father_name"], "male", l["Date"])
if cand:
l["FatherId"] = cand["Id"]
resolved_fathers += 1
# Match Mother by Name
m_name = l["_mother_name"]
if m_name and not l["MotherId"]:
m_norm = normalize_name(m_name)
candidates = gerbil_by_norm_name.get(m_norm, [])
valid_candidates = []
for c in candidates:
final_id = gerbil_id_map.get(c["Id"])
if not final_id:
continue
final_c = next((rg for rg in resolved_gerbils if rg["Id"] == final_id), None)
if final_c and final_c["Gender"] in ["female", "unknown"]:
if l["Date"] and final_c["DateOfBirth"]:
if final_c["DateOfBirth"] < l["Date"]:
valid_candidates.append(final_c)
else:
valid_candidates.append(final_c)
if len(valid_candidates) == 1:
l["MotherId"] = valid_candidates[0]["Id"]
resolved_mothers += 1
elif len(valid_candidates) > 1:
l["MotherId"] = valid_candidates[0]["Id"]
if l["_mother_name"] and not l["MotherId"]:
cand = _resolve_name(l["_mother_name"], "female", l["Date"])
if cand:
l["MotherId"] = cand["Id"]
resolved_mothers += 1
# Cleanup internal keys
@@ -1776,8 +1907,89 @@ def main():
del l["_mother_name"]
del l["_filename"]
# Role normalization: assign each resolved parent to the role matching its
# gender, eliminate self-pairings (same animal in both roles), and never let
# impossible duplicates survive (two males / two females). This corrects
# reversed Stammbaum positions including the cases the simple swap missed
# (one parent of "unknown" gender, or a self-paired litter).
role_fixes = 0
for l in resolved_litters:
father, mother = assign_parent_roles(l.get("FatherId"), l.get("MotherId"), _final_gender)
if (l.get("FatherId"), l.get("MotherId")) != (father, mother):
role_fixes += 1
l["FatherId"] = father
l["MotherId"] = mother
if role_fixes:
print(f"Role-normalization: corrected {role_fixes} litter(s) (gender roles / self-pairings).")
print(f"Globally resolved {resolved_fathers} fathers and {resolved_mothers} mothers.")
# 5b. Second-pass litter dedup: now that FatherId/MotherId are known,
# merge litters that have the same date AND the same parents.
# This is the core of the "sibling pairing" fix: Blue Wave and Sunny Sky
# both come from Wonderman × Unique — their two separate litter records
# must now become one, so their children share the same LitterId.
litter_by_id_post = {l["Id"]: l for l in resolved_litters}
gerbil_by_litter = {}
for g in resolved_gerbils:
lid = g.get("LitterId")
if lid:
gerbil_by_litter.setdefault(lid, []).append(g)
def _litter_same_parents(l1, l2):
"""Strict: same date + both parents known and matching."""
if l1["Date"] != l2["Date"]:
return False
f1, m1 = l1.get("FatherId"), l1.get("MotherId")
f2, m2 = l2.get("FatherId"), l2.get("MotherId")
if not f1 or not f2 or not m1 or not m2:
return False
return f1 == f2 and m1 == m2
by_date2 = {}
for l in resolved_litters:
by_date2.setdefault(l["Date"], []).append(l)
deduped2 = []
litter_remap2 = {} # old_id -> canonical_id
for date_val, group in by_date2.items():
sub_groups = []
for l in group:
placed = False
for sub in sub_groups:
if all(_litter_same_parents(l, m) for m in sub):
sub.append(l)
placed = True
break
if not placed:
sub_groups.append([l])
for sub in sub_groups:
# Prefer the canonical that has the most children
canonical = max(sub, key=lambda l: len(gerbil_by_litter.get(l["Id"], [])))
for l in sub:
litter_remap2[l["Id"]] = canonical["Id"]
deduped2.append(canonical)
if len(sub) > 1:
siblings = [g["Name"] for l in sub for g in gerbil_by_litter.get(l["Id"], []) if l is not canonical]
print(f"Sibling-Litter-Merge on {date_val}: {canonical['Name']} absorbed sibling half — children now share LitterId: {[g['Name'] for g in gerbil_by_litter.get(canonical['Id'], [])] + siblings}")
# Remap LitterId in all gerbils
n_remapped = 0
for g in resolved_gerbils:
old_lid = g.get("LitterId")
if old_lid and old_lid in litter_remap2 and litter_remap2[old_lid] != old_lid:
g["LitterId"] = litter_remap2[old_lid]
n_remapped += 1
n_merged2 = len(resolved_litters) - len(deduped2)
if n_merged2:
print(f"Sibling-Litter-Dedup: {n_merged2} additional litter record(s) merged ({n_remapped} gerbil LitterIds remapped).")
resolved_litters = deduped2
litter_by_scoped_id = {l["Id"]: l for l in resolved_litters}
# Set IsBreeder and IsReceiver flags on contacts
breeder_ids = {g["OriginContactId"] for g in resolved_gerbils if g.get("OriginContactId")}
receiver_ids = {g["ReceiverContactId"] for g in resolved_gerbils if g.get("ReceiverContactId")}