Drop7 Research
approaches/lifetime-objective/entombed-discs/patterns.pyPython114 lines · 5.5 KB
#!/usr/bin/env python3
"""Human-observed 'danger patterns' checked against the corpus the same way the
entombed disc was: prevalence in depth-4 play, occupancy-matched lifetime, and
held-out partial correlation with log remaining lifetime AFTER the eighteen
frozen leaf features, occupancy and the rise clock.  Diagnostic only.

Usage: patterns.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

B = 7


def neighbours(mask: np.ndarray) -> np.ndarray:
    """Count of 4-neighbours of each cell for which mask is true."""
    n = np.zeros(mask.shape, dtype=np.int8)
    n[:, 1:, :] += mask[:, :-1, :]
    n[:, :-1, :] += mask[:, 1:, :]
    n[:, :, 1:] += mask[:, :, :-1]
    n[:, :, :-1] += mask[:, :, 1:]
    return n


def pattern_features(boards: np.ndarray) -> dict[str, np.ndarray]:
    n = boards.shape[0]
    g = boards.reshape(n, B, B)
    solid, cracked = g == 8, g == 9
    cover = solid | cracked
    occupied = g != 0
    adj_solid = neighbours(solid)
    adj_cover = neighbours(cover)
    adj_occ = neighbours(occupied)
    height = occupied.sum(axis=1)
    col_height = np.broadcast_to(height[:, None, :], (n, B, B))
    out = {}
    for v in (1, 2, 3, 4):
        cell = g == v
        out[f"v{v}_adj_solid"] = ((cell & (adj_solid > 0))).sum(axis=(1, 2)).astype(np.float32)
        out[f"v{v}_adj_cover"] = ((cell & (adj_cover > 0))).sum(axis=(1, 2)).astype(np.float32)
    ones = g == 1
    # a 1 with any occupied neighbour in its row, or a column taller than 1, cannot clear now
    left = np.zeros_like(occupied); left[:, :, 1:] = occupied[:, :, :-1]
    right = np.zeros_like(occupied); right[:, :, :-1] = occupied[:, :, 1:]
    out["ones_blocked"] = (ones & ((left | right) | (col_height > 1))).sum(axis=(1, 2)).astype(np.float32)
    out["ones_under_cover"] = (ones & (np.roll(cover, 1, axis=1) & (np.arange(B)[None, :, None] > 0))).sum(axis=(1, 2)).astype(np.float32)
    out["numbered_adj_solid_total"] = (((g >= 1) & (g <= 7)) & (adj_solid > 0)).sum(axis=(1, 2)).astype(np.float32)
    out["solid_count"] = solid.sum(axis=(1, 2)).astype(np.float32)
    out["isolated_numbered"] = (((g >= 1) & (g <= 7)) & (adj_occ == 0)).sum(axis=(1, 2)).astype(np.float32)
    return out


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)
    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: dict[str, list] = {}
    for start in range(0, len(rec), 250_000):
        for k, v in pattern_features(boards[start:start + 250_000]).items():
            feats.setdefault(k, []).append(v)
    feats = {k: np.concatenate(v) for k, v in feats.items()}
    mtd = np.asarray(rec["movesToDeath"]).astype(np.float64)
    censored = np.asarray(rec["censoredGame"]) == 1
    occ = np.asarray(rec["occupiedCells"]).astype(np.float64)
    depth = np.asarray(rec["behaviorDepth"])
    keep = ~censored
    train_mask, _, test_mask = ds.split_by_origin(rec)
    y = np.log1p(mtd)
    base = np.column_stack([leaf[:, :18], occ, np.asarray(rec["movesRemaining"]).astype(np.float64), np.ones(len(rec))])
    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
    out = {"format": "drop7-pattern-diagnostic-v1", "records": int(len(rec)), "patterns": {}}
    d4 = depth == 4
    for k, z in feats.items():
        zf = z.astype(np.float64)
        beta_z, *_ = np.linalg.lstsq(Xs[tr], zf[tr], rcond=None)
        res_z = zf[te] - Xs[te] @ beta_z
        partial = float(np.corrcoef(res_y, res_z)[0, 1]) if res_z.std() > 0 else None
        raw = float(np.corrcoef(zf[keep], y[keep])[0, 1])
        matched = {}
        for lo in (16, 20, 24, 28, 32):
            m = (occ >= lo) & (occ < lo + 4) & keep
            w, wo = m & (z > 0), m & (z == 0)
            if w.sum() > 500 and wo.sum() > 500:
                matched[f"occ{lo}-{lo+3}"] = [round(float(mtd[w].mean()), 1), round(float(mtd[wo].mean()), 1), int(w.sum()), int(wo.sum())]
        near = d4 & keep & (mtd <= 5); far = d4 & keep & (mtd > 40)
        out["patterns"][k] = {"meanPerState": float(zf.mean()), "fracStatesDepth4": float((z[d4] > 0).mean()),
                              "fracDepth4Within5OfDeath": float((z[near] > 0).mean()), "fracDepth4MoreThan40FromDeath": float((z[far] > 0).mean()),
                              "rawCorrelationLogLifetime": raw, "heldOutPartialCorrelationBeyondLeaf": partial,
                              "occupancyMatchedMeanMovesToDeath[with,without,n_with,n_without]": matched}
    Path(out_path).write_text(json.dumps(out, indent=2) + "\n")
    for k, v in out["patterns"].items():
        print(f"{k:26} d4 prevalence {v['fracStatesDepth4']:.3f}  near-death {v['fracDepth4Within5OfDeath']:.3f}  far {v['fracDepth4MoreThan40FromDeath']:.3f}  raw r {v['rawCorrelationLogLifetime']:+.3f}  partial r beyond leaf {v['heldOutPartialCorrelationBeyondLeaf']:+.4f}  matched {v['occupancyMatchedMeanMovesToDeath[with,without,n_with,n_without]'].get('occ24-27')}")


if __name__ == "__main__":
    main()