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.")

View File

@@ -154,6 +154,44 @@ check("names_overlap: matching father name", m.names_overlap(litter(None, fname=
check("names_overlap: nothing in common", not m.names_overlap(litter(None, fname="A"), litter(None, mname="B")))
# ── parent_age_plausible: born before child, within ~6y lifespan ──
check("age: parent 1y before child → plausible", m.parent_age_plausible("16.04.2021", "27.03.2022"))
check("age: parent born AFTER child → implausible", not m.parent_age_plausible("2023-01-01", "2022-03-27"))
check("age: parent born SAME day → implausible", not m.parent_age_plausible("2022-03-27", "2022-03-27"))
check("age: 9 years older (Jayjay→Solice) → implausible", not m.parent_age_plausible("19.06.2013", "27.03.2022"))
check("age: exactly ~5y older → plausible", m.parent_age_plausible("01.06.2017", "01.05.2022"))
check("age: 7 years older → implausible", not m.parent_age_plausible("25.10.2018", "13.08.2025"))
check("age: unknown parent dob → plausible (can't disprove)", m.parent_age_plausible(None, "2022-03-27"))
check("age: unknown litter date → plausible", m.parent_age_plausible("2021-04-16", None))
# ── pick_parent_ref: prefer age-plausible ref, avoid duplicating the other role ──
def pref(name, role, dob=None):
return {"name": name, "roleGuess": role, "dob": dob}
# Solice case: first father ref (Jayjay, no DOB) loses to the dated, plausible Lui.
solice_refs = [
pref("Jayjay", "father"),
pref("Lui von den Kleinen Chaoten", "mother", "16.04.2021"),
pref("Lui von den Kleinen Chaoten", "father", "16.04.2021"),
pref("Molly of Black Forest", "mother", "13.09.2021"),
]
f = m.pick_parent_ref(solice_refs, "father", "27.03.2022")
check("pick: father = plausible-dated Lui, not first-listed Jayjay",
f and f["name"] == "Lui von den Kleinen Chaoten")
mo = m.pick_parent_ref(solice_refs, "mother", "27.03.2022", avoid_name=f["name"])
check("pick: mother = Molly (Lui avoided as it is the father)",
mo and mo["name"] == "Molly of Black Forest")
check("pick: a dated-but-impossible ref loses to a plausible one",
m.pick_parent_ref([pref("Old", "father", "2010-01-01"), pref("Dad", "father", "2021-01-01")],
"father", "2022-03-27")["name"] == "Dad")
check("pick: no ref for role → None",
m.pick_parent_ref([pref("X", "mother", "2021-01-01")], "father", "2022-03-27") is None)
check("pick: single ref is returned",
m.pick_parent_ref([pref("Solo", "father")], "father", "2022-03-27")["name"] == "Solo")
if check.failed:
print(f"\n{check.failed} test(s) FAILED")
sys.exit(1)