# linguabase-categories-board.py — build boards for a categories game:
# 16 words hiding four groups of four, every word fitting exactly one
# group (the genre Connections made famous).
#
# NEEDS  five Linguabase tables (parquet), CC0, in the working
#        directory — download from linguabase.org/download.html:
#          topics.full.parquet          the group supply (headword, sense,
#                                       members, verdict, quality, rating)
#          vocabulary.core.parquet      word, core_rank (1 = most familiar)
#          scores_labels.core.parquet   content_rating, sensitivity
#          associations.core.parquet    word -> ranked association cloud
#          categories.full.parquet      72,802 pools (the phantom check)
# RUN    python3 linguabase-categories-board.py     (requires pandas)
# OUT    boards.tsv   one row per group: board_no, position, name, sense,
#                     the 4 tile words, mean tile rank
#        boards.json  one record per board: shuffled 16-tile arrangement
#                     plus the groups in easiest-first order
# TUNE   the constants below. At TILE_CAP 30,000 / QMIN 11 the funnel
#        leaves 42,314 board-ready groups, and the top 4,000 of the
#        queue seat 844 boards (measured, June 2026 release). Every
#        downstream count moves with the constants.
import pandas as pd, json, re, random, statistics

TILE_FLOOR = 1000     # ranks above this are grammar's plumbing, not tiles
TILE_CAP   = 30000    # deepest familiarity rank a tile may have
NEED       = 6        # board-ready members a group must hold (4 seats + spares)
QMIN       = 11       # topics quality bar (scale tops at 12)
SHARED_MAX = 8        # two group names may share at most 7 cloud entries
TOP        = 4000     # how far down the queue this run reads

# ---- load ------------------------------------------------------------------
tp = pd.read_parquet('topics.full.parquet')
v  = pd.read_parquet('vocabulary.core.parquet', columns=['word', 'core_rank'])
rank = dict(zip(v.word, v.core_rank))
s  = pd.read_parquet('scores_labels.core.parquet',
                     columns=['word', 'content_rating', 'sensitivity'])
rate = dict(zip(s.word, s.content_rating))
sens = dict(zip(s.word, s.sensitivity))
a  = pd.read_parquet('associations.core.parquet')
cloud = {w: set(str(x).casefold() for x in lst)          # casefold: the lists
         for w, lst in zip(a.word, a.associates)         # hold capitals and
         if lst is not None}                             # phrases
pool_of = {}                                             # word -> pool ids
cats = pd.read_parquet('categories.full.parquet', columns=['category', 'members'])
pools = list(cats.category)
for i, ms in enumerate(cats.members):                    # NB: comma-joined raw
    if ms is None: continue                              # strings, unlike the
    for m in str(ms).split(','):                         # pipe-lists elsewhere
        m = m.strip().casefold()
        if m: pool_of.setdefault(m, set()).add(i)

# ---- the funnel: 373,382 topic rows -> board-ready groups ------------------
ok = re.compile(r'^[a-z]+$')
def board_ready(ms):
    """The members of one group that could sit on a tile, curated order."""
    out, seen = [], set()
    for m in (ms if ms is not None else []):
        m = str(m)
        if m in seen: continue
        seen.add(m)
        if ok.match(m) and TILE_FLOOR <= rank.get(m, 9e9) <= TILE_CAP \
           and rate.get(m) in ('G', 'PG') and (sens.get(m) or 0) > 3:
            out.append(m)
    return out

tp = tp[(tp.verdict == 'good')
        & tp.content_rating.isin(['G', 'PG'])
        & (tp.quality_score >= QMIN)]
tp = tp.sort_values(['headword', 'quality_score', 'member_count', 'sense'],
                    ascending=[True, False, False, True], kind='mergesort') \
       .drop_duplicates('headword').sort_index()

groups = []
for r in tp.itertuples():
    bm = board_ready(r.members)
    if len(bm) >= NEED:
        tiles = bm[:4]                       # the table's own order is curated
        groups.append(dict(
            head=r.headword, sense=str(r.sense), q=int(r.quality_score),
            members=set(str(m).casefold() for m in r.members),
            tiles=tiles, mr=statistics.mean(rank[w] for w in tiles)))
print(f'{len(groups):,} board-ready groups')

# the queue: best quality first, then the groups whose tiles run commonest
groups.sort(key=lambda g: (-g['q'], g['mr'], g['head'].casefold(), g['sense']))

# ---- the five checks -------------------------------------------------------
def fails(cand, board):
    """First reason cand cannot join board, or None."""
    ct, ch = cand['tiles'], cand['head']
    ccf = ch.casefold()
    for s in board:
        st, sh = s['tiles'], s['head']
        scf = sh.casefold()
        if set(ct) & set(st):                              # 1 same tile twice
            return 'tile'
        if any(w in s['members'] for w in ct) \
           or any(w in cand['members'] for w in st):       # 2 a tile in both pools
            return 'pool'
        if any(w in cloud.get(sh, ()) for w in ct) \
           or any(w in cloud.get(ch, ()) for w in st):     # 3 tile in the other
            return 'cloud'                                 #   theme's cloud
        if ccf == scf or ccf in s['members'] or scf in cand['members'] \
           or ccf in cloud.get(sh, ()) or scf in cloud.get(ch, ()) \
           or ccf in set(st) or scf in set(ct) \
           or len(cloud.get(ch, set()) & cloud.get(sh, set())) >= SHARED_MAX:
            return 'name'                                  # 4 names too close
    owner = {w: g['head'] for g in board + [cand] for w in g['tiles']}
    hits = {}                                              # 5 phantom pool: some
    for w, h in owner.items():                             #   categories pool
        for p in pool_of.get(w, ()):                       #   holds 3+ tiles
            hits.setdefault(p, []).append(h)               #   across 2+ groups
    for p, heads in hits.items():
        if len(heads) >= 3 and len(set(heads)) >= 2 and ch in heads:
            return 'phantom: ' + pools[p]
    return None

# ---- greedy assembly: read the queue, seat 4, close the board --------------
avail, boards, seated = groups[:TOP], [], []
while True:
    board, used = [], set()
    for i, cand in enumerate(avail):
        if any(len(set(cand['tiles']) & t) >= 3 for t in seated):
            used.add(i); continue            # near-twin of a seated group
        if fails(cand, board): continue      # rejected here; later boards may
        board.append(cand); used.add(i)      # still take it
        if len(board) == 4: break
    if len(board) < 4: break                 # queue exhausted
    avail = [g for k, g in enumerate(avail) if k not in used]
    seated += [set(g['tiles']) for g in board]
    boards.append(sorted(board, key=lambda g: g['mr']))   # easiest group first

# ---- write -----------------------------------------------------------------
rng, rows, recs = random.Random(20260808), [], []
for n, board in enumerate(boards, 1):
    tiles = [w for g in board for w in g['tiles']]
    rng.shuffle(tiles)
    recs.append(dict(board=n, tiles=tiles,
                     groups=[dict(name=g['head'], sense=g['sense'],
                                  words=g['tiles'], mean_rank=int(round(g['mr'])))
                             for g in board]))
    for pos, g in enumerate(board, 1):
        rows.append((n, pos, g['head'], g['sense'], '|'.join(g['tiles']),
                     int(round(g['mr']))))
cols = 'board_no position name sense tiles mean_rank'.split()
pd.DataFrame(rows, columns=cols).to_csv('boards.tsv', sep='\t', index=False)
json.dump(recs, open('boards.json', 'w'), indent=1)
print(f'{len(boards):,} boards')
