#!/usr/bin/env python3
# ---------------------------------------------------------------------------
# linguabase-clue-game.py — build four-clues-one-word puzzles from the
# Linguabase tables (https://www.linguabase.org · CC0, public domain).
#
# The game: the player sees 4 CLUES and 4 OPTIONS (1 answer + 3 wrong).
# The answer connects to all four clues; each wrong option has zero
# connection to at least one clue — that zero is the elimination path.
#
# Needs these five downloads beside it (parquet, core tier):
#     vocabulary.core.parquet         word, core_rank (1 = most familiar)
#     scores_labels.core.parquet      content_rating, sensitivity (high=safe)
#     associations.core.parquet       word -> ranked association cloud
#     senses.core.parquet             word -> canonical sense labels
#     sense_associations.core.parquet (word, sense) -> that sense's cloud
#     word_families.core.parquet      word -> relatives
#
# Writes: puzzles.tsv (one row per puzzle) and puzzles.json (the play pack:
# answer, clues, options, and the full strength matrix for feedback).
#
# Method (measured on the June 2026 release: the 10,000 most familiar words
# yield 5,488 puzzle anchors — one a day for 15.0 years):
#   1. answers    = top ANSWER_POOL familiar words, family-safe
#                   (content_rating G/PG, sensitivity > 3), 3+ letters,
#                   with an association cloud and 2+ canonical senses
#   2. clues      = 4 of the answer's top-30 associates, spread across
#                   2+ senses, none a family relative of the answer
#   3. strength   = 100 - the clue's position in a word's ranked cloud
#                   (1-based); absent or past position 99 = 0
#   4. wrong opts = 3 words from the top DECOY_POOL familiar words (same
#                   safety filter), each with strength > 0 on exactly 2-3
#                   clues and 0 on the rest, none a relative of the answer
#                   or of any clue; together they must cover all 4 clues;
#                   the answer's total must beat the best wrong option's
#                   by SEPARATION_MIN (100) or more
# ---------------------------------------------------------------------------
import collections, itertools, json
import pandas as pd

ANSWER_POOL    = 10_000   # answers come from the most familiar words
DECOY_POOL     = 30_000   # wrong options may run three times deeper
CLUE_POOL      = 30       # clue candidates: the answer's strongest associates
HORIZON        = 99       # association position beyond this = strength 0
SEPARATION_MIN = 100      # the answer must win by at least this much

# ---------------------------------------------------------------- load
v = pd.read_parquet('vocabulary.core.parquet',
                    columns=['word', 'core_rank']).sort_values('core_rank')
rank = dict(zip(v.word, v.core_rank))
ranked = v.word.tolist()                     # note: the FILE is not
                                             # rank-sorted; sort first
sl = pd.read_parquet('scores_labels.core.parquet',
                     columns=['word', 'content_rating', 'sensitivity'])
safe = set(sl[(sl.content_rating.isin(['G', 'PG']))
              & (sl.sensitivity > 3)].word)  # sensitivity is high = safe

a = pd.read_parquet('associations.core.parquet')
assoc = {w: list(l) for w, l in zip(a.word, a.associates)}

s = pd.read_parquet('senses.core.parquet')
senses = {w: list(l) for w, l in zip(s.word, s.sense_labels)}

sa = pd.read_parquet('sense_associations.core.parquet')
sa = sa[sa.word.isin(set(ranked[:ANSWER_POOL]))]
cloud = {}                                   # (word, canonical sense) -> set
for w, sn, l in zip(sa.word, sa.sense, sa.associates):
    if sn in (senses.get(w) or ()):          # canonical senses only; the
        cloud[(w, sn)] = set(l)              # table also holds finer senses

wf = pd.read_parquet('word_families.core.parquet')
fam = {w: set(l) for w, l in zip(wf.word, wf.relatives)}

# ---------------------------------------------------------------- primitives
def related(x, y):
    """Family relatives, either direction, plus a substring guard —
    keeps BARKING out of BARK's clues and options."""
    if x == y: return True
    if y in fam.get(x, ()) or x in fam.get(y, ()): return True
    if len(x) > 3 and len(y) > 3 and (x in y or y in x): return True
    return False

def strength(w, clue):
    """100 - position of clue in w's ranked cloud; absent = 0."""
    l = assoc.get(w)
    if not l: return 0
    try: p = l.index(clue) + 1
    except ValueError: return 0
    return 100 - p if p <= HORIZON else 0

def pick_clues(w):
    """4 clues from the top-30 associates, spread across 2+ canonical
    senses, none a relative of the answer. Deterministic; None on fail."""
    my_senses = senses.get(w) or []
    cands = []                               # (clue, its primary sense)
    for c in assoc[w][:CLUE_POOL]:
        if related(w, c): continue
        prim = next((S for S in my_senses if c in cloud.get((w, S), ())), None)
        if prim is not None:
            cands.append((c, prim))
    by_sense = collections.OrderedDict()
    for c, S in cands:
        by_sense.setdefault(S, []).append(c)
    if len(by_sense) < 2: return None
    order = sorted(by_sense, key=lambda S: -len(by_sense[S]))
    picked, seen, i = [], set(), 0
    while len(picked) < 4:                   # round-robin across senses
        advanced = False
        for S in order:
            if i < len(by_sense[S]):
                c = by_sense[S][i]
                if c not in seen:
                    picked.append((c, S)); seen.add(c)
                    advanced = True
                    if len(picked) == 4: break
        if not advanced: break
        i += 1
    if len(picked) < 4 or len({S for _, S in picked}) < 2: return None
    return picked

# reverse index: which pool words list a given clue in their cloud?
pool = [w for w in ranked[:DECOY_POOL]
        if w in safe and w in assoc and len(w) > 2]
rev = collections.defaultdict(set)
for w in pool:
    for c in assoc[w][:HORIZON]:
        rev[c].add(w)

def pick_decoys(w, clues, ans_total):
    """3 wrong options: each matches 2-3 clues and misses the rest, none
    related to the answer or a clue, all 4 clues covered, and the answer
    wins by SEPARATION_MIN+. Returns [(word, [4 strengths], total)]."""
    cl = [c for c, _ in clues]
    hits = collections.Counter()
    for c in cl:
        for d in rev.get(c, ()): hits[d] += 1
    cands = []
    for d, k in hits.items():
        if k < 2 or d == w or d in cl: continue
        if related(w, d) or any(related(c, d) for c in cl): continue
        sc = [strength(d, c) for c in cl]
        if sum(1 for x in sc if x > 0) not in (2, 3): continue
        cands.append((d, sc, sum(sc)))
    cands.sort(key=lambda t: (-t[2], rank.get(t[0], 10**9)))
    cands = cands[:12]                       # strongest first: harder puzzle
    if len(cands) < 3: return None
    for tri in itertools.combinations(range(len(cands)), 3):
        trio = [cands[i] for i in tri]
        if ans_total - max(t[2] for t in trio) < SEPARATION_MIN: continue
        covered = set()
        for _, sc, _ in trio:
            covered |= {j for j, x in enumerate(sc) if x > 0}
        if len(covered) == 4:                # every clue matched by someone
            return trio
    return None

# ---------------------------------------------------------------- build
puzzles = []
for w in ranked[:ANSWER_POOL]:
    if w not in safe: continue
    if w not in assoc or len(w) <= 2: continue
    if len(senses.get(w) or []) < 2: continue
    clues = pick_clues(w)
    if clues is None: continue
    ans_sc = [strength(w, c) for c, _ in clues]
    trio = pick_decoys(w, clues, sum(ans_sc))
    if trio is None: continue
    puzzles.append({'a': w,
                    'c': [c for c, _ in clues],
                    'o': [d for d, _, _ in trio],
                    's': [ans_sc] + [sc for _, sc, _ in trio],
                    'sep': sum(ans_sc) - max(t[2] for t in trio)})

with open('puzzles.tsv', 'w') as f:
    f.write('answer\tclue1\tclue2\tclue3\tclue4\twrong1\twrong2\twrong3\tsep\n')
    for p in puzzles:
        f.write('\t'.join([p['a']] + p['c'] + p['o'] + [str(p['sep'])]) + '\n')
with open('puzzles.json', 'w') as f:
    json.dump(puzzles, f, separators=(',', ':'))
print(f'{len(puzzles)} puzzles written'
      f' — one a day for {len(puzzles)/365:.1f} years')
