Mongoosetross's picture
Fast deploy (index 51f60c37cb32)
7b53677 verified
Raw
History Blame Contribute Delete
15 kB
"""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,
}