diff --git a/tools/import/extract.py b/tools/import/extract.py index ba5e8d5..d892bde 100644 --- a/tools/import/extract.py +++ b/tools/import/extract.py @@ -502,6 +502,52 @@ def _geno_key(genodict): return "|".join(f"{locus}:{','.join(sorted(m[locus]))}" for locus in sorted(m)) +# --- "presence wins" merge rule (Julian) ------------------------------------- +# When two source variants of the SAME animal differ ONLY by a token PRESENT in one and +# ABSENT in the other — a whole locus (e.g. spsp recorded in one chart, omitted in another) +# or a modifier on the same base allele (e^f vs e, i.e. the [f] marker) — keep the present +# token; that is NOT a conflict. A genuine VALUE contradiction (different filled alleles: +# E vs e, D vs d, c^h vs c^chm) OR unknown-vs-filled (D- vs DD, the '?' second allele) STILL +# quarantines for human decision. (Markers/flags WP/DP/WFNZ/hörend are already tags/flags, +# never part of the genotype, so they never reach here.) +def _split_allele(a): + return tuple(a.split("^", 1)) if "^" in a else (a, "") + + +def _alleles_compatible(a, b): + if a == b: + return True + if a == "?" or b == "?": + return False # unknown vs filled = contradiction (D- vs DD) + (ba, ma), (bb, mb) = _split_allele(a), _split_allele(b) + if ba != bb: + return False # different base allele = real value diff (E vs e) + return ma == "" or mb == "" # same base, modifier present-vs-absent -> presence wins + + +def _pair_compatible(p, q): + if len(p) != 2 or len(q) != 2: + return p == q + return ((_alleles_compatible(p[0], q[0]) and _alleles_compatible(p[1], q[1])) or + (_alleles_compatible(p[0], q[1]) and _alleles_compatible(p[1], q[0]))) + + +def _genotype_conflict(mapped_list): + """True only if two variants GENUINELY contradict at a shared locus. A locus present in + one variant and absent in another is fine (presence wins); so is a modifier present-vs- + absent on the same base allele. Replaces the old `len(distinct geno keys) > 1` test.""" + loci = set() + for m in mapped_list: + loci.update(m.keys()) + for locus in loci: + pairs = [m[locus] for m in mapped_list if locus in m] + for i in range(len(pairs)): + for j in range(i + 1, len(pairs)): + if not _pair_compatible(pairs[i], pairs[j]): + return True + return False + + def dedup(animals): """Merge by normalise(call-name)+DOB, with the canonical Zucht as DISCRIMINATOR (Julian: same name+DOB+Zucht = same animal; different Zucht = @@ -552,6 +598,7 @@ def dedup(animals): parent_refs = list(base["parentRefs"]) genos = set() geno_keys = set() # GEN-3b: conflict on NORMALIZED genotype (Uw==G) not raw text + mapped_variants = [] # mapped8locus per variant — for the 'presence wins' conflict test farb = set() deaths = set() deaf_seen = set() @@ -567,6 +614,7 @@ def dedup(animals): if a["genotype"]["mapped8locus"]: genos.add(a["genotype"]["rawGenotype"]) geno_keys.add(_geno_key(a["genotype"])) + mapped_variants.append(a["genotype"]["mapped8locus"]) if a["farbschlag"]: farb.add(a["farbschlag"]) if a["death"]: @@ -603,8 +651,9 @@ def dedup(animals): "conflict": False, } merged.append(out) - # conflict: same animal, disagreeing NORMALIZED genotype (Uw==G) or farbschlag or death - if len(geno_keys) > 1 or len(farb) > 1 or len(deaths) > 1: + # conflict: same animal, GENUINELY disagreeing genotype (presence-vs-absence is NOT a + # conflict — Julian's 'presence wins') or >1 distinct farbschlag or >1 distinct death. + if _genotype_conflict(mapped_variants) or len(farb) > 1 or len(deaths) > 1: out["conflict"] = True conflicts.append({ "id": out["id"], "name": base["name"], "dob": out["dob"], @@ -834,6 +883,31 @@ def write_report(merged, conflicts, orphans, raw_count, litters, photo_count, # ------------------------------------------------------------------------ main +def apply_dob_remaps(raw_animals, path): + """PRE-dedup: a conflict-decision carrying `correctDob` marks a record as a DUPLICATE with a + wrong birthdate — remap that raw record's DOB to correctDob so dedup MERGES it into the + canonical same-named animal (e.g. Chelsea *15.10.2021 -> *02.04.2021). Match = + norm_name(name)+norm_dob(dob). Tolerates a missing/garbled file. Returns the remap count. + Must run BEFORE dedup (it changes the dedup identity). (god/HUMANQUESTION D — Dubletten.)""" + remaps = {} + try: + with open(path, encoding="utf-8") as fh: + for r in (json.load(fh).get("resolutions") or []): + if r.get("correctDob"): + remaps[(norm_name(r.get("name", "")), norm_dob(r.get("dob", "")))] = r["correctDob"] + except (OSError, ValueError): + return 0 + if not remaps: + return 0 + n = 0 + for a in raw_animals: + new = remaps.get((norm_name(a.get("name", "")), norm_dob(a.get("dob", "")))) + if new and a.get("dob") != new: + a["dob"] = new + n += 1 + return n + + def apply_conflict_decisions(merged, conflicts, path): """Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?, @@ -904,8 +978,9 @@ def main(): litters = extract_wurfchronik(args.wurfchronik) print(f"Wurfchronik: {len(litters)} Würfe") - merged, conflicts, orphans, zucht_splits = dedup(raw_animals) decisions_path = os.path.join(HERE, "conflict-decisions.json") + dob_remaps = apply_dob_remaps(raw_animals, decisions_path) # before dedup (changes identity) + merged, conflicts, orphans, zucht_splits = dedup(raw_animals) resolved_by_decision = apply_conflict_decisions(merged, conflicts, decisions_path) match_stats = match_litters(merged, litters) photo_count = sum(len(a["photos"]) for a in merged) @@ -924,7 +999,7 @@ def main(): print(f"\nRoh: {len(raw_animals)} → eindeutig: {len(merged)} " f"| Konflikte: {len(conflicts)} | per Entscheidung gelöst: {resolved_by_decision} " - f"| Zucht-Splits: {len(zucht_splits)} " + f"| DOB-Remaps: {dob_remaps} | Zucht-Splits: {len(zucht_splits)} " f"| Orphans: {len(orphans)} | Fotos: {photo_count}") print(f"Wurf-Verknüpfung: {match_stats['parents']} (Datum+Eltern), " f"{match_stats['dateOnly']} (nur Datum), {match_stats['ambiguous']} mehrdeutig " diff --git a/tools/import/output/review-report.md b/tools/import/output/review-report.md index 167b53a..65a17ce 100644 --- a/tools/import/output/review-report.md +++ b/tools/import/output/review-report.md @@ -5,10 +5,10 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date ## Überblick - Rohe Tier-Einträge aus den Stammbäumen: **950** -- Nach Zusammenführung (eindeutige Tiere): **622** - - davon mit Geburtsdatum: 327 +- Nach Zusammenführung (eindeutige Tiere): **621** + - davon mit Geburtsdatum: 326 - in mehreren Dateien gefunden (Dubletten zusammengeführt): 158 -- Konflikte zur Klärung: **19** +- Konflikte zur Klärung: **9** - Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310** - Fotos zugeordnet: **137** - Würfe aus der Wurfchronik: **752** @@ -26,20 +26,10 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date | Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien | |---|---|---|---|---|---| | Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids | -| Louis von den Kleinen Chaoten | 15.07.2017 | Aa Cc[] D- Ee Gg P- spsp // Aa Cc[chm] D- Ee Uwuw[d] P- spsp | — | 01.07.2020 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity | -| Zuleika von den Kleinen Chaoten | 24.10.2015 | aa c[chm]c[h] D- E G P- spsp // aa c[chm]c[h] D- Ee Gg P- spsp // aa c[chm]c[h] DD Ee Gg P- spsp | — | 24.02.2019 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids | -| Vestra von den Schlossmäusen | 08.02.2019 | Aa Cc[chm] D- EE GG PP Spsp [WP] // Aa Cc[chm] DD EE GG PP Spsp [WP] | — | 26.05.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids | -| Flint von den Kleinen Chaoten | 23.12.2017 | aa Cc[chm] D- ee Gg P- spsp | — | 10.05.2021 // 10.05.2022 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity | | Kazu von den Kleinen Chaoten | 23.04.2013 | Aa Cc[chm] DD e[f]e[f] Gg P Spsp // Aa Cc[chm] DD ee[f] UwUw PP Spsp | — | 03.09.2017 | Stammbaum von Akio Kids, Stammbaum von Vance | -| Milka of LennyLengo | 09.12.2018 | aa C- dd E- Gg P- Spsp // aa Cc[h] dd EE Gg P- Spsp | — | 22.12.2021 | Stammbaum von Alberto Kids, Stammbaum von Stella Kids | -| Silvain von den Kleinen Chaoten | 27.03.2022 | aa c[chm]c[chm] Dd Ee[-] Gg P- Spsp // aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp | — | 31.12.2024 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity | | Enya von den Kleinen Chaoten | 01.11.2017 | Aa c[chm]c[chm] D- ee[-] G- P- spsp // Aa c[chm]c[chm] D- ee[-] Uwuw[d] P- spsp | — | — | Stammbaum von Alberto Kids, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids | -| Little Hero of Black Forest | 22.02.2018 | AA CC DD EE GG PP [WFNZ] // AA CC DD EE GG PP spsp [WFNZ] | — | 18.06.2021 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids, Stammbaum von Valentino Firehearts Kids | -| Molly of Black Forest | 13.09.2021 | /+, Aa Cc[chm] D- Ee gg P- spsp // Aa Cc[chm] Dd Ee gg Pp spsp | — | 03.05.2021 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity | | Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids | -| Daja of Little Rose | 16.05.2021 | aa chmchm D- EE Gg P- // aa chmchm D- EE Gg P- spsp | — | — | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids | | Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids | -| Ichika von den Kleinen Chaoten | 19.04.2020 | aa CC D- ee Gg pp spsp // aa CC D- ee[f] Gg pp spsp | — | 27.11.2023 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Watarus Kids | | Victoria Welby gen. Welby v.d. Kleinen Chaoten | 16.01.2023 | Aa CC D- Ee[f] Gg pp Spsp [DP] // Aa CC D- ee[f] Gg pp Spsp [DP] | Goldfuchsschimmel Punktschecke DP | 17.02.2026 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids | | Zac gen. Action von den Kleinen Chaoten | 25.12.2020 | aa C- D- Ee G- Pp Spsp [DP] // aa CC D- Ee G- Pp Spsp [DP] | — | 31.01.2025 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids | | Hanami von den Kleinen Chaoten | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp | — | 12.12.2019 // 14.01.2020 | Stammbaum von Kentucky, Stammbaum von Stella Kids | @@ -101,7 +91,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date - „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019) - „Charly of Golden Lights“ → Charly of Golden Lights (*05.04.2016) - „Ziwa of Golden Lights“ → Ziwa of Golden Lights (*29.04.2016) -- „Chelsea von den Kleinen Chaoten“ → Chelsea von den Kleinen Chaoten (*02.04.2021); Chelsea von den Kleinen Chaoten (*15.10.2021) +- „Chelsea von den Kleinen Chaoten“ → Chelsea von den Kleinen Chaoten (*02.04.2021) - „Pinto of Fiomi“ → Pinto of Fiomi (*28.08.2016) - „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016) - „Scarlett of Samsimar“ → Scarlett of Samsimar (*05.09.2018) @@ -143,12 +133,12 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M | `/+` | 7 | ? | | `-g` | 2 | ? | | `C(C)` | 2 | Schreibweise (C trägt c) | -| `chmchm` | 2 | Schreibweise (c[chm]c[chm]) | | `Cc[]` | 1 | ? | | `-psp` | 1 | ? | | `G(G)` | 1 | ? | | `/` | 1 | ? | | `+2018` | 1 | ? | +| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) | | `c[chm]chm]` | 1 | ? | | `Dea/dea]` | 1 | ? | | `DD-Tumor` | 1 | ? | diff --git a/tools/import/test_extract.py b/tools/import/test_extract.py index 8caaf28..ab388c2 100644 --- a/tools/import/test_extract.py +++ b/tools/import/test_extract.py @@ -101,9 +101,50 @@ check("decision removes both entries from conflicts list", conflicts == []) check("apply_conflict_decisions returns resolved count", n == 2) check("missing decisions file tolerated (returns 0)", e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0) + +# --- correctDob: a wrong-birthdate duplicate is remapped BEFORE dedup so it merges --- +dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json") +_json.dump({"resolutions": [ + {"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021", + "decision": "duplicate wrong birthdate", "correctDob": "02.04.2021", "source": "test"}, +]}, open(dec2, "w", encoding="utf-8")) +raw = [ + {"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021"}, # the wrong-dob duplicate + {"name": "Chelsea von den Kleinen Chaoten", "dob": "02.04.2021"}, # canonical + {"name": "Other Animal", "dob": "01.01.2020"}, +] +rn = e.apply_dob_remaps(raw, dec2) +check("correctDob remaps the wrong-dob record", raw[0]["dob"] == "02.04.2021") +check("correctDob leaves the canonical record alone", raw[1]["dob"] == "02.04.2021") +check("correctDob leaves unrelated records alone", raw[2]["dob"] == "01.01.2020") +check("apply_dob_remaps returns remap count", rn == 1) +check("after remap both Chelsea share one dedup identity (name+dob)", + e.norm_dob(raw[0]["dob"]) == e.norm_dob(raw[1]["dob"])) +check("missing decisions file tolerated for dob remaps (returns 0)", + e.apply_dob_remaps([], os.path.join(tempfile.gettempdir(), "nope.json")) == 0) +try: os.remove(dec2) +except OSError: pass + try: os.remove(dec_path) except OSError: pass +# --- "presence wins" conflict rule (Julian) --- +# present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing filled values are. +check("spsp present vs locus absent -> no conflict", + not e._genotype_conflict([{"Sp": ["sp", "sp"]}, {}])) +check("ee[f] vs ee ([f] modifier present/absent) -> no conflict", + not e._genotype_conflict([{"E": ["e", "e^f"]}, {"E": ["e", "e"]}])) +check("DD vs D- (unknown vs filled) -> conflict", + e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}])) +check("Ee vs ee (different base allele) -> conflict", + e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}])) +check("C- vs Cc[h] -> conflict", + e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}])) +check("c[h] vs c[chm] (different modifiers) -> conflict", + not e._alleles_compatible("c^h", "c^chm")) +check("identical genotypes -> no conflict", + not e._genotype_conflict([{"A": ["A", "a"]}, {"A": ["A", "a"]}])) + # --- name-bleed guard (a parent name is not a Farbschlag) --- check("v.d. name rejected", e.looks_like_animal_name("Tennessee von den Kleinen Chaoten")) check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. Welby v.d. Kleinen Chaoten"))