From 03ad9e6e8133663f033a1912f56f932162ed0ff9 Mon Sep 17 00:00:00 2001 From: Gulum Date: Mon, 22 Jun 2026 14:59:34 +0200 Subject: [PATCH] fix(import): Eltern-Alter-Sanity-Check + plausiblere Eltern-Auswahl aus Chart-Refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tools/import/merge_and_resolve.py | 77 ++++++++++++++++++++++++++++-- tools/import/test_merge_resolve.py | 38 +++++++++++++++ 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/tools/import/merge_and_resolve.py b/tools/import/merge_and_resolve.py index 63acb79..1bf6c95 100644 --- a/tools/import/merge_and_resolve.py +++ b/tools/import/merge_and_resolve.py @@ -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.") diff --git a/tools/import/test_merge_resolve.py b/tools/import/test_merge_resolve.py index 173a88c..b7b95a0 100644 --- a/tools/import/test_merge_resolve.py +++ b/tools/import/test_merge_resolve.py @@ -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)