fix(import): Eltern-Alter-Sanity-Check + plausiblere Eltern-Auswahl aus Chart-Refs

Eine Rennmaus lebt max ~6 Jahre, kann also nicht Elternteil eines Tiers sein,
das nach ihrem Tod (oder vor ihrer Geburt) geboren wurde. Bisher fehlte diese
Prüfung: z. B. war Jayjay (*2013) als Vater von Solice (*2022) eingetragen, und
Solice/Silvain (Vollgeschwister, beide *27.03.2022) landeten in getrennten
Würfen mit verschiedenen Eltern.

- parent_age_plausible(): Elternteil muss vor dem Kind UND innerhalb der
  Lebensspanne (≤6 J.) geboren sein. Genutzt im Namens-Resolver (Kandidaten-
  filter) und als finaler Sanity-Pass, der unmögliche FatherId/MotherId verwirft
  und im Log auflistet.
- pick_parent_ref(): bei mehreren widersprüchlichen Chart-parentRefs wird nicht
  mehr blind der erste genommen, sondern ein alters-plausibler bevorzugt und das
  Tier der jeweils anderen Elternrolle gemieden (Vollgeschwister-Charts nennen
  denselben Namen in beiden Slots).

Ergebnis: Solice = Lui × Molly = Geschwister von Silvain (gemeinsamer Wurf);
0 Selbst-Verpaarungen, 0 Gender-Rollen-Fehler, 0 Eltern-Alter-Verletzungen über
alle 977 Würfe. Tests in test_merge_resolve.py ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:59:34 +02:00
parent 18efab6996
commit 03ad9e6e81
2 changed files with 111 additions and 4 deletions

View File

@@ -576,6 +576,52 @@ def assign_parent_roles(father_id, mother_id, gender_of):
return father, mother
# A gerbil lives at most ~6 years, so a parent can be at most ~6 years older than
# its offspring (and must be born before it). Links outside this window are
# impossible — e.g. a 2013 animal resolved onto a 2022 litter (Jayjay → Solice).
MAX_PARENT_AGE_DAYS = 6 * 366
def parent_age_plausible(parent_dob, litter_date):
"""Could a parent born `parent_dob` have offspring born on `litter_date`?
Requires birth strictly before the litter and within the gerbil lifespan.
Unknown/unparseable dates return True (cannot disprove). Accepts any date
format parse_date understands.
"""
pd = date_to_days(parse_date(parent_dob)) if parent_dob else None
ld = date_to_days(parse_date(litter_date)) if litter_date else None
if pd is None or ld is None:
return True
return 0 < (ld - pd) <= MAX_PARENT_AGE_DAYS
def pick_parent_ref(parent_refs, role, child_dob, avoid_name=None):
"""Choose the best parent ref for a role from possibly-conflicting chart refs.
A Stammbaum lists an animal at several positions, so its parentRefs can carry
contradictory guesses (the first one is not necessarily right). Prefer a ref
whose own DOB is age-plausible for the child, then a ref with no DOB, and
avoid re-using the other role's animal (full-sibling charts repeat the same
name in both parent slots). Returns the chosen ref dict or None.
"""
role_refs = [p for p in parent_refs if p.get("roleGuess") == role]
if not role_refs:
return None
avoid = normalize_name(avoid_name) if avoid_name else None
def rank(p):
if avoid is not None and normalize_name(p.get("name")) == avoid:
return 3 # would duplicate the other parent role
dob = p.get("dob")
if not dob:
return 1 # unknown age — usable, but a plausible-dated ref wins
return 0 if parent_age_plausible(dob, child_dob) else 2 # dated & impossible → last
order = sorted(range(len(role_refs)), key=lambda i: (rank(role_refs[i]), i))
return role_refs[order[0]]
def main():
print("Loading color variety seeds...")
variety_map = {}
@@ -917,8 +963,10 @@ def main():
created_virtual_litters = {}
for a in stammbaum_only_animals:
parent_refs = a.get("parentRefs", [])
father_ref = next((p for p in parent_refs if p.get("roleGuess") == "father"), None)
mother_ref = next((p for p in parent_refs if p.get("roleGuess") == "mother"), None)
child_dob_raw = a.get("dob")
father_ref = pick_parent_ref(parent_refs, "father", child_dob_raw)
mother_ref = pick_parent_ref(parent_refs, "mother", child_dob_raw,
avoid_name=father_ref.get("name") if father_ref else None)
a["_mapped_litter_scoped_id"] = None
if father_ref and mother_ref:
@@ -1862,8 +1910,9 @@ def main():
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:
# Parent must be age-plausible: born before the litter and within the
# gerbil lifespan (skips e.g. a 2013 animal for a 2022 litter).
if not parent_age_plausible(final_c["DateOfBirth"], litter_date):
continue
cands.append(final_c)
if not cands:
@@ -1922,6 +1971,26 @@ def main():
if role_fixes:
print(f"Role-normalization: corrected {role_fixes} litter(s) (gender roles / self-pairings).")
# Parent-age sanity check: drop any resolved parent that cannot belong to the
# litter — born after the offspring, or more than a gerbil lifespan earlier.
# Catches mis-resolved links the name matcher still let through (e.g. Jayjay,
# *2013, wrongly attached to Solice's 2022 litter).
age_drops = []
for l in resolved_litters:
ldate = l.get("Date")
for role in ("FatherId", "MotherId"):
pid = l.get(role)
if not pid:
continue
p = gerbil_by_id_final.get(pid)
if p and not parent_age_plausible(p.get("DateOfBirth"), ldate):
age_drops.append((l.get("Name"), role, p.get("Name"), p.get("DateOfBirth"), ldate))
l[role] = None
if age_drops:
print(f"Parent-age sanity check: dropped {len(age_drops)} implausible parent link(s):")
for lname, role, pname, pdob, ldate in age_drops[:20]:
print(f" {lname}: {role}={pname} (*{pdob}) vs litter {ldate}")
print(f"Globally resolved {resolved_fathers} fathers and {resolved_mothers} mothers.")