Drop7 Research
approaches/lifetime-objective/entombed-discs/analyze.pyPython220 lines · 11.6 KB
#!/usr/bin/env python3
"""Entombed-disc analysis on the existing training corpus (no seed is opened).

A numbered disc of value n is ENTOMBED when its column already holds more than
n discs (a gravity-packed column's vertical run is its height, so the disc can
never clear vertically until the column shrinks) AND the contiguous horizontal
run through it already exceeds n.  It can then only clear after neighbours are
removed.  The frozen leaf prices this for 1s and 2s (dead_low_numbers,
low_number_height_risk, high_low_numbers) and not for 3s and above.

Three questions, answered from runs/RUN-A51D-corpus/all.states and the frozen
leaf features dumped by leafdump.cpp:

  1. prevalence: how often the behaviour policies (depth 1-4) leave an
     entombed disc of value >= 3 on the board, by move and at death;
  2. lead time: in complete games, how many moves before death the first
     persistent entombed >= 3 disc appears (the window an emergency mode
     would have);
  3. incremental signal: held-out R^2 for log1p(moves to death) of the 18
     frozen leaf features + occupancy + rise clock, versus the same plus the
     entombed features, on a whole-origin split (dataset.py's split).

Usage: analyze.py <corpus.states> <leaf.f32> <out.json>
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

import numpy as np

ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "approaches/lifetime-objective/afterstate-net"))
import dataset as ds  # noqa: E402

LEAF_NAMES = [
    "open_columns", "height_load", "solid_cells", "cracked_cells", "numbered_cells", "high_low_numbers",
    "direct_potential", "latent_chain_potential", "cracked_exposure", "solid_exposure", "adjacent_ones",
    "triple_twos", "dead_low_numbers", "covered_height_risk", "low_number_height_risk", "danger_height_squared",
    "rise_pressure", "next_disc_vertical_options",
]
B = 7


def entombed_features(boards: np.ndarray) -> dict[str, np.ndarray]:
    """boards: (N,49) uint8 row-major from the top.  Vectorised."""
    n = boards.shape[0]
    grid = boards.reshape(n, B, B)
    occupied = grid != 0
    numbered = (grid >= 1) & (grid <= 7)
    cover = (grid == 8) | (grid == 9)
    height = occupied.sum(axis=1)  # (N,7) per column; packed columns
    # elevation of row r (1 = bottom): B - r
    elevation = np.broadcast_to((B - np.arange(B))[None, :, None], (n, B, B))
    # horizontal run length through each cell: left-extent + right-extent - 1
    left = np.zeros((n, B, B), dtype=np.int16)
    right = np.zeros((n, B, B), dtype=np.int16)
    for c in range(B):
        left[:, :, c] = np.where(occupied[:, :, c], (left[:, :, c - 1] if c else 0) + 1, 0)
    for c in range(B - 1, -1, -1):
        right[:, :, c] = np.where(occupied[:, :, c], (right[:, :, c + 1] if c < B - 1 else 0) + 1, 0)
    hrun = left + right - 1
    value = grid.astype(np.int16)
    col_height = np.broadcast_to(height[:, None, :], (n, B, B))
    entombed = numbered & (col_height > value) & (hrun > value)
    # covers directly beneath a cell in the same column (rows below = larger row index)
    covers_below = np.cumsum(cover[:, ::-1, :], axis=1)[:, ::-1, :] - cover
    top_of_column = occupied & ~np.roll(occupied, 1, axis=1)
    top_of_column[:, 0, :] = occupied[:, 0, :]
    ge3 = entombed & (value >= 3)
    le2 = entombed & (value <= 2)
    return {
        "ent_count_ge3": ge3.sum(axis=(1, 2)).astype(np.float32),
        "ent_mass_ge3": (ge3 * elevation ** 2).sum(axis=(1, 2)).astype(np.float32),
        "ent_covers_ge3": (ge3 * covers_below).sum(axis=(1, 2)).astype(np.float32),
        "ent_top_ge3": (ge3 & top_of_column).sum(axis=(1, 2)).astype(np.float32),
        "ent_count_le2": le2.sum(axis=(1, 2)).astype(np.float32),
        "ent_count_34": (entombed & ((value == 3) | (value == 4))).sum(axis=(1, 2)).astype(np.float32),
    }


def ols_r2(xtr, ytr, xte, yte) -> tuple[float, float]:
    beta, *_ = np.linalg.lstsq(xtr, ytr, rcond=None)
    pred = xte @ beta
    ss_res = float(((yte - pred) ** 2).sum())
    ss_tot = float(((yte - yte.mean()) ** 2).sum())
    r = float(np.corrcoef(pred, yte)[0, 1])
    return 1 - ss_res / ss_tot, r


def main() -> None:
    corpus, leaf_path, out_path = sys.argv[1:4]
    records = ds.load_states(corpus)
    leaf = np.memmap(leaf_path, dtype=np.float32, mode="r").reshape(-1, 19)
    assert leaf.shape[0] == len(records), (leaf.shape, len(records))
    valid = np.isfinite(leaf[:, 18]) & (np.asarray(records["explored"]) == 0)
    idx = np.flatnonzero(valid)
    rec = records[idx]
    leaf = np.asarray(leaf[idx])
    boards = np.asarray(rec["board"])
    feats = {}
    for start in range(0, len(rec), 250_000):  # chunked to bound memory
        part = entombed_features(boards[start:start + 250_000])
        for k, v in part.items():
            feats.setdefault(k, []).append(v)
    feats = {k: np.concatenate(v) for k, v in feats.items()}
    depth = np.asarray(rec["behaviorDepth"])
    mtd = np.asarray(rec["movesToDeath"]).astype(np.float64)
    censored = np.asarray(rec["censoredGame"]) == 1
    occ = np.asarray(rec["occupiedCells"]).astype(np.float64)
    out: dict = {"format": "drop7-entombed-analysis-v1", "records": int(len(rec)), "excludedExplored": int((np.asarray(records["explored"]) != 0).sum()),
                 "invalidLeafRecords": int((~np.isfinite(np.asarray(np.memmap(leaf_path, dtype=np.float32, mode="r").reshape(-1, 19)[:, 18]))).sum())}

    # 1. prevalence by behaviour depth and by moves-to-death bucket
    prev = {}
    for d in (1, 2, 3, 4):
        m = depth == d
        buckets = {}
        for lo, hi, name in ((0, 5, "death<=5"), (6, 15, "death6-15"), (16, 40, "death16-40"), (41, 10_000, "death>40")):
            mm = m & (mtd >= lo) & (mtd <= hi) & ~censored
            if mm.sum() == 0:
                continue
            buckets[name] = {"states": int(mm.sum()), "fracWithEntombedGe3": float((feats["ent_count_ge3"][mm] > 0).mean()),
                             "meanEntombedGe3": float(feats["ent_count_ge3"][mm].mean()), "meanOccupancy": float(occ[mm].mean()),
                             "fracWithEntombed34": float((feats["ent_count_34"][mm] > 0).mean())}
        prev[f"depth{d}"] = {"states": int(m.sum()), "fracWithEntombedGe3": float((feats["ent_count_ge3"][m] > 0).mean()),
                             "fracWithEntombedLe2": float((feats["ent_count_le2"][m] > 0).mean()), "byMovesToDeath": buckets}
    out["prevalence"] = prev

    # occupancy-matched comparison (is it just a proxy for a full board?)
    matched = {}
    for lo in range(8, 40, 4):
        m = (occ >= lo) & (occ < lo + 4) & ~censored
        with_e, without = m & (feats["ent_count_ge3"] > 0), m & (feats["ent_count_ge3"] == 0)
        if with_e.sum() > 500 and without.sum() > 500:
            matched[f"occ{lo}-{lo+3}"] = {"with": int(with_e.sum()), "without": int(without.sum()),
                                        "meanMovesToDeathWith": float(mtd[with_e].mean()), "meanMovesToDeathWithout": float(mtd[without].mean()),
                                        "medianWith": float(np.median(mtd[with_e])), "medianWithout": float(np.median(mtd[without]))}
    out["occupancyMatched"] = matched

    # 2. lead time in complete games (depth 3 and 4 behaviour)
    lead = {}
    for d in (3, 4):
        m = (depth == d) & ~censored
        seeds = np.asarray(rec["gameSeed"])[m]
        moves = np.asarray(rec["moveIndex"])[m]
        ent = feats["ent_count_ge3"][m] > 0
        mt = mtd[m]
        order = np.lexsort((moves, seeds))
        seeds, moves, ent, mt = seeds[order], moves[order], ent[order], mt[order]
        starts = np.flatnonzero(np.r_[True, seeds[1:] != seeds[:-1]])
        ends = np.r_[starts[1:], len(seeds)]
        leads, at_death, lengths, games = [], 0, [], 0
        for s, e in zip(starts, ends):
            games += 1
            lengths.append(int(mt[s]) + int(moves[s]))
            g_ent = ent[s:e]
            if not g_ent[-1]:
                continue
            at_death += 1
            # first index of the final run of True
            k = e - s - 1
            while k > 0 and g_ent[k - 1]:
                k -= 1
            leads.append(float(mt[s + k]))
        leads = np.array(leads)
        lead[f"depth{d}"] = {"completeGames": games, "gamesWithEntombedGe3AtDeath": at_death, "fracAtDeath": at_death / max(games, 1),
                             "leadTimeMoves": {"median": float(np.median(leads)) if len(leads) else None, "q25": float(np.quantile(leads, 0.25)) if len(leads) else None,
                                               "q75": float(np.quantile(leads, 0.75)) if len(leads) else None, "mean": float(leads.mean()) if len(leads) else None,
                                               "fracAtLeast10": float((leads >= 10).mean()) if len(leads) else None, "fracAtLeast20": float((leads >= 20).mean()) if len(leads) else None},
                             "meanGameLength": float(np.mean(lengths))}
    out["leadTime"] = lead

    # 3. incremental held-out signal, whole-origin split
    train_mask, val_mask, test_mask = ds.split_by_origin(rec)
    keep = ~censored
    y = np.log1p(mtd)
    base = np.column_stack([leaf[:, :18], occ, np.asarray(rec["movesRemaining"]).astype(np.float64), np.ones(len(rec))])
    base_scalar = np.column_stack([leaf[:, 18], occ, np.asarray(rec["movesRemaining"]).astype(np.float64), np.ones(len(rec))])
    ent_cols = np.column_stack([feats[k] for k in ("ent_count_ge3", "ent_mass_ge3", "ent_covers_ge3", "ent_top_ge3", "ent_count_le2")]).astype(np.float64)
    full = np.column_stack([base, ent_cols])
    # standardise columns for numerical sanity (lstsq on raw scales is fine but keep it clean)
    def fit(name, X, sub=None):
        tr = train_mask & keep if sub is None else train_mask & keep & sub
        te = test_mask & keep if sub is None else test_mask & keep & sub
        mu, sd = X[tr].mean(axis=0), X[tr].std(axis=0) + 1e-9
        Xs = (X - mu) / sd
        Xs[:, -1] = 1.0 if X.shape[1] == base.shape[1] or X.shape[1] == full.shape[1] or True else Xs[:, -1]
        r2, r = ols_r2(Xs[tr], y[tr], Xs[te], y[te])
        return {"heldOutR2": r2, "heldOutPearson": r, "train": int(tr.sum()), "test": int(te.sum())}
    signal = {
        "leafScalar+occupancy": fit("scalar", base_scalar),
        "leafFeatures18+occupancy": fit("base", base),
        "leafFeatures18+occupancy+entombed": fit("full", full),
        "entombedOnly+occupancy": fit("ent", np.column_stack([ent_cols, occ, np.ones(len(rec))])),
        "depth4only": {"leafFeatures18+occupancy": fit("base4", base, depth == 4), "leafFeatures18+occupancy+entombed": fit("full4", full, depth == 4)},
        "depth3only": {"leafFeatures18+occupancy": fit("base3", base, depth == 3), "leafFeatures18+occupancy+entombed": fit("full3", full, depth == 3)},
    }
    # univariate: partial correlation of ent_count_ge3 with y after the 18 features + occupancy, on held-out
    tr, te = train_mask & keep, test_mask & keep
    mu, sd = base[tr].mean(axis=0), base[tr].std(axis=0) + 1e-9
    Xs = (base - mu) / sd; Xs[:, -1] = 1.0
    beta_y, *_ = np.linalg.lstsq(Xs[tr], y[tr], rcond=None)
    res_y = y[te] - Xs[te] @ beta_y
    partial = {}
    for k in ("ent_count_ge3", "ent_mass_ge3", "ent_covers_ge3", "ent_top_ge3", "ent_count_34"):
        z = feats[k].astype(np.float64)
        beta_z, *_ = np.linalg.lstsq(Xs[tr], z[tr], rcond=None)
        res_z = z[te] - Xs[te] @ beta_z
        partial[k] = float(np.corrcoef(res_y, res_z)[0, 1]) if res_z.std() > 0 else None
    signal["partialCorrelationHeldOut"] = partial
    out["incrementalSignal"] = signal
    out["featureMeans"] = {k: float(v.mean()) for k, v in feats.items()}
    Path(out_path).write_text(json.dumps(out, indent=2) + "\n")
    print(json.dumps({k: out[k] for k in ("records", "leadTime", "incrementalSignal")}, indent=2))


if __name__ == "__main__":
    main()