"""Etymology Redactle: guess modern EN/ES ends from a shared non-proto ancestor.""" from __future__ import annotations import hashlib import json import re import unicodedata from pathlib import Path from backend.atlas import Atlas, _bridge_label from backend.define import lookup_definitions ROOT = Path(__file__).resolve().parents[1] DECK_PATH = ROOT / "data" / "game" / "en_es_pairs.json" # Visible by default in glosses / redacted prose (Redactle-style crumbs). STOPWORDS = frozenset( """ a an the and or but of to in on for from by with as at into onto over under between among through during before after about against without within is are was were be been being am do does did done have has had having will would can could should may might must shall not no nor this that these those it its itself he she they them their we us our you your i my me which who whom whose what when where why how also than then so if than thus yet both either neither each every some any all most more less few other another such same own de del la el los las un una y o en por con sin para que """.split() ) TOKEN_RE = re.compile( r"[A-Za-zÀ-ÿĀ-žɑ-ʸʰʷ\*]+(?:['\u2019][A-Za-zÀ-ÿ]+)?|[0-9]+|[^\s]", re.UNICODE, ) SEED_PAIRS: list[dict] = [ {"en": "family", "es": "familia", "source": "seed"}, {"en": "nation", "es": "nación", "source": "seed"}, {"en": "name", "es": "nombre", "source": "seed"}, {"en": "star", "es": "estrella", "source": "seed"}, {"en": "school", "es": "escuela", "source": "seed"}, {"en": "music", "es": "música", "source": "seed"}, {"en": "animal", "es": "animal", "source": "seed"}, {"en": "hospital", "es": "hospital", "source": "seed"}, {"en": "nature", "es": "naturaleza", "source": "seed"}, {"en": "important", "es": "importante", "source": "seed"}, {"en": "private", "es": "privado", "source": "seed"}, {"en": "tradition", "es": "tradición", "source": "seed"}, {"en": "enormous", "es": "enorme", "source": "seed"}, {"en": "convention", "es": "convención", "source": "seed"}, ] def _load_deck() -> list[dict]: pairs = list(SEED_PAIRS) if DECK_PATH.exists(): try: data = json.loads(DECK_PATH.read_text()) for row in data.get("pairs") or []: en = (row.get("en") or "").strip() es = (row.get("es") or "").strip() if en and es: pairs.append({"en": en, "es": es, "source": row.get("source") or "deck"}) except (json.JSONDecodeError, OSError): pass seen: set[tuple[str, str]] = set() out: list[dict] = [] for p in pairs: key = (p["en"].casefold(), p["es"].casefold()) if key in seen: continue seen.add(key) out.append(p) return out def _is_proto_lang(lang: str) -> bool: lk = (lang or "").casefold().strip() if not lk: return False if lk.startswith("proto") or lk.startswith("pre-proto"): return True if "proto-" in lk or lk.startswith("pie"): return True # Reconstructed stages we treat like proto for this game. if lk in {"indo-european", "common germanic", "common slavic"}: return True return False def _norm_key(text: str) -> str: """Casefold + strip combining marks for fuzzy guess matching.""" s = unicodedata.normalize("NFKD", (text or "").strip()) s = "".join(ch for ch in s if not unicodedata.combining(ch)) return s.casefold() def _glosses(atlas: Atlas, term: str, lang: str, *, limit: int = 3) -> list[str]: assert atlas.conn is not None payload = lookup_definitions(atlas.conn, term=term, lang=lang, lang_meta=atlas.lang_meta) out: list[str] = [] for sense in payload.get("senses") or []: for g in sense.get("glosses") or []: g = (g or "").strip() if g and g not in out: out.append(g) if len(out) >= limit: return out return out def _display_lang(atlas: Atlas, lang: str) -> str: meta = atlas.lang_meta.get(lang) or {} return meta.get("display") or (lang or "").replace("-", " ").title() def _pack_node(atlas: Atlas, n: dict, *, gloss_limit: int = 2) -> dict: term = n["term"] lang = n["lang"] gloss_list = _glosses(atlas, term, lang, limit=gloss_limit) return { "term": term, "lang": lang, "lang_display": n.get("lang_display") or _display_lang(atlas, lang), "gloss": gloss_list[0] if gloss_list else "", "glosses": gloss_list, } def _round_id(en: str, es: str) -> str: return hashlib.sha1(f"{en}\0{es}".encode()).hexdigest()[:12] def _default_visible_token(token: str) -> bool: """Tokens shown before any guesses (structure crumbs, not solutions).""" if not token: return True if len(token) == 1 and not token.isalpha(): return True # punctuation key = _norm_key(token) if not key: return True if key in STOPWORDS: return True if key.isdigit(): return True # Tiny connectors stay visible; real lemmas stay hidden. if len(key) <= 1: return True return False def tokenize_text(text: str) -> list[dict]: """Split prose into Redactle tokens with default visibility.""" parts: list[dict] = [] for m in TOKEN_RE.finditer(text or ""): raw = m.group(0) if raw.isspace(): continue wordish = bool(re.search(r"[A-Za-zÀ-ÿĀ-žɑ-ʸ]", raw)) parts.append( { "t": raw, "k": _norm_key(raw) if wordish else "", "word": wordish, "open": (not wordish) or _default_visible_token(raw), } ) return parts def _path_root_to_end(modern_to_lca: list[dict]) -> list[dict]: return list(reversed(modern_to_lca or [])) def build_round(atlas: Atlas, en: str, es: str, *, source: str = "custom") -> dict: related = atlas.relate(en, "english", es, "spanish", max_depth=12) if not related.get("ok"): return {"ok": False, "error": related.get("error") or "unrelated", "en": en, "es": es} lca = related["lca"] if _is_proto_lang(lca.get("lang") or ""): return {"ok": False, "error": "proto_ancestor", "en": en, "es": es} path_a = related["path_a"] # modern EN → LCA path_b = related["path_b"] # modern ES → LCA root_to_a = _path_root_to_end(path_a) root_to_b = _path_root_to_end(path_b) if len(root_to_a) < 2 or len(root_to_b) < 2: return {"ok": False, "error": "trivial_path", "en": en, "es": es} bridge = _bridge_label(lca["lang"]) root = { **_pack_node(atlas, lca, gloss_limit=3), "bridge": bridge, # Root is the open clue: client shows term + glosses in the clear. "visible": True, } def branch_nodes(path: list[dict], *, end_role: str) -> list[dict]: # path is root → … → modern end nodes: list[dict] = [] for i, n in enumerate(path): if i == 0: continue # root handled separately packed = _pack_node(atlas, n, gloss_limit=2) is_end = i == len(path) - 1 packed["role"] = end_role if is_end else "hop" packed["depth"] = i # Ends + hops: terms redacted by default; langs always shown in UI. packed["visible"] = False nodes.append(packed) return nodes branch_a = branch_nodes(root_to_a, end_role="end_a") branch_b = branch_nodes(root_to_b, end_role="end_b") end_a = next(n for n in branch_a if n["role"] == "end_a") end_b = next(n for n in branch_b if n["role"] == "end_b") # Corpus for Redactle reveals: hop terms + all glosses (not the open root term, # which is already clear — but include root glosses so guessing sense words helps). corpus_bits: list[str] = [] corpus_bits.extend(root.get("glosses") or []) for n in [*branch_a, *branch_b]: corpus_bits.append(n["term"]) corpus_bits.extend(n.get("glosses") or []) tokens: list[dict] = [] for bit in corpus_bits: tokens.extend(tokenize_text(bit)) tokens.append({"t": " ", "k": "", "word": False, "open": True, "sep": True}) # Unique guessable keys present in the document (for hint pool). hidden_keys = sorted( {t["k"] for t in tokens if t.get("word") and t.get("k") and not t.get("open")} ) return { "ok": True, "id": _round_id(en, es), "source": source, "mode": "redactle_tree", "root": root, "branch_a": branch_a, "branch_b": branch_b, # Targets (also embedded in branches); used for win checks. "end_a": { "term": end_a["term"], "lang": end_a["lang"], "lang_display": end_a["lang_display"], "gloss": end_a.get("gloss") or "", "glosses": end_a.get("glosses") or [], }, "end_b": { "term": end_b["term"], "lang": end_b["lang"], "lang_display": end_b["lang_display"], "gloss": end_b.get("gloss") or "", "glosses": end_b.get("glosses") or [], }, "a": end_a, "b": end_b, "help": { "prompt": "A shared historical ancestor is open. Fill the redacted tree — guess the modern English and Spanish ends.", "tip": ( f"Both words descend from {bridge} {root['term']}. " "Type words to uncover them wherever they appear in definitions and forms. " "Language labels and small function words are already visible." ), }, "defaults": { "root_open": True, "langs_open": True, "stopwords_open": True, }, "hint_pool_size": len(hidden_keys), "answer": { "bridge": bridge, "en": end_a["term"], "es": end_b["term"], "en_key": _norm_key(end_a["term"]), "es_key": _norm_key(end_b["term"]), "lca": root, "path_a": [_pack_node(atlas, n) for n in root_to_a], "path_b": [_pack_node(atlas, n) for n in root_to_b], "hidden_keys": hidden_keys, }, } def next_round(atlas: Atlas, *, avoid: set[str] | None = None) -> dict: deck = _load_deck() avoid = avoid or set() def priority(p: dict) -> int: src = p.get("source") or "" # Latin-shared pairs almost always meet on a non-proto stage. if src == "latin": return 0 if src == "seed": return 1 if src == "iecor": return 2 return 3 buckets: dict[int, list[dict]] = {0: [], 1: [], 2: [], 3: []} for p in deck: rid = _round_id(p["en"], p["es"]) if rid in avoid: continue buckets[priority(p)].append(p) pool: list[dict] = [] pool.extend(buckets[0] * 3) pool.extend(buckets[1] * 2) pool.extend(buckets[2]) pool.extend(buckets[3]) if not pool: pool = list(deck) import random random.shuffle(pool) last_err = "empty_deck" for p in pool[:120]: rnd = build_round(atlas, p["en"], p["es"], source=p.get("source") or "deck") if rnd.get("ok"): return rnd last_err = rnd.get("error") or last_err return {"ok": False, "error": last_err} def apply_guess(round_payload: dict, word: str, revealed: list[str] | None = None) -> dict: """Apply one Redactle guess against the round's hidden corpus + ends.""" ans = round_payload.get("answer") or {} key = _norm_key(word) prev = {_norm_key(x) for x in (revealed or []) if x} if not key: return { "ok": False, "error": "empty", "revealed": sorted(prev), "hit": False, "en_ok": ans.get("en_key") in prev, "es_ok": ans.get("es_key") in prev, } hidden = set(ans.get("hidden_keys") or []) hit = key in hidden or key == ans.get("en_key") or key == ans.get("es_key") nxt = set(prev) if hit: nxt.add(key) en_ok = ans.get("en_key") in nxt es_ok = ans.get("es_key") in nxt return { "ok": True, "guess": word.strip(), "key": key, "hit": hit, "revealed": sorted(nxt), "en_ok": en_ok, "es_ok": es_ok, "solved": en_ok and es_ok, "new": key not in prev and hit, } def score_guess( round_payload: dict, en_guess: str | None = None, es_guess: str | None = None, *, # Legacy no-ops kept so old clients do not 500 branch_a_guess: list[str] | None = None, branch_b_guess: list[str] | None = None, bridge_guess: str | None = None, chain_guess: list[str] | None = None, guess_count: int | None = None, ) -> dict: ans = round_payload.get("answer") or {} en_ok = _norm_key(en_guess or "") == (ans.get("en_key") or _norm_key(ans.get("en") or "")) es_ok = _norm_key(es_guess or "") == (ans.get("es_key") or _norm_key(ans.get("es") or "")) solved = en_ok and es_ok guesses = int(guess_count or 0) # Fewer guesses is better; award a descending score once solved. max_score = 20 score = max(0, max_score - guesses) if solved else (int(en_ok) + int(es_ok)) return { "en_ok": en_ok, "es_ok": es_ok, "solved": solved, "guess_count": guesses, "score": score, "max_score": max_score, "answer": ans, # Legacy fields some UI still reads "bridge_ok": True, "chain_ok": solved, "branches_ok": solved, "correct_slots": int(en_ok) + int(es_ok), "slot_count": 2, } def pick_hint(round_payload: dict, revealed: list[str] | None = None) -> dict: """Reveal one still-hidden corpus token that is not an unsolved end (prefer gloss crumbs).""" ans = round_payload.get("answer") or {} have = {_norm_key(x) for x in (revealed or []) if x} ends = {ans.get("en_key"), ans.get("es_key")} - {None, ""} pool = [k for k in (ans.get("hidden_keys") or []) if k not in have and k not in ends] if not pool: # Fall back to revealing an end key as a strong hint. pool = [k for k in ends if k not in have] if not pool: return {"ok": False, "error": "nothing_left", "revealed": sorted(have)} # Prefer mid-length tokens (more informative than "of"-adjacent crumbs). pool.sort(key=lambda k: (abs(len(k) - 6), len(k), k)) choice = pool[0] have.add(choice) return { "ok": True, "key": choice, "revealed": sorted(have), "en_ok": ans.get("en_key") in have, "es_ok": ans.get("es_key") in have, "solved": ans.get("en_key") in have and ans.get("es_key") in have, }