Drop7 Research
approaches/lifetime-objective/flow-ceiling/compare.pyPython141 lines · 5.2 KB
#!/usr/bin/env python3
"""Side-by-side comparison of `flow-run` cohorts for finding-07.

Prints one row per cohort (flow rates, lifetime, occupancy) and one
occupancy-band table with a column per cohort.  Per-move clear counts are
reconstructed from the recorded occupancy trace by disc conservation, exactly as
in `analyze.py`, and every game is checked against its own recorded total.

Usage: compare.py <label>=<games.jsonl> ...
"""
import json
import sys

sys.path.insert(0, __file__.rsplit("/", 1)[0])
from analyze import reconstruct_per_move, load, mean, median  # noqa: E402

BANDS = [(0, 9), (10, 14), (15, 19), (20, 24), (25, 29), (30, 34), (35, 39),
         (40, 44), (45, 49)]
SKIP = 25  # moves excluded as "opening" when reporting the steady-state rate


def cohort(path):
    games = load(path)
    has_reveals = "moveRevealed" in games[0]
    moves = sum(g["moves"] for g in games)
    out = {
        "n": len(games),
        "moves": moves,
        "meanMoves": mean([g["moves"] for g in games]),
        "medMoves": median([g["moves"] for g in games]),
        "censored": sum(1 for g in games if g["censored"]),
        "meanScore": mean([g["score"] for g in games]),
        "clears": sum(g["cleared"] for g in games) / moves,
        "reveals": sum(g["revealed"] for g in games) / moves,
        "slope": mean([slope(g["cycleOccupancy"], 1) for g in games]),
        "occ": mean([o for g in games for o in g["moveOccupancy"]]),
    }
    late_c = late_m = 0
    bands = {b: [0, 0] for b in BANDS}
    for g in games:
        rows = reconstruct_per_move(g)
        assert rows is not None, path
        for i, (before, cleared, _r) in enumerate(rows):
            if cleared is None:
                continue
            if i >= SKIP:
                late_c += cleared
                late_m += 1
            for b in BANDS:
                if b[0] <= before <= b[1]:
                    bands[b][0] += 1
                    bands[b][1] += cleared
                    break
    out["steady"] = late_c / late_m if late_m else 0.0
    out["bands"] = bands

    # Reveals per move by occupancy band, and covered cells available to be
    # revealed, when the run recorded them.  Reveals cannot be reconstructed
    # from occupancy the way clears can: opening a cover changes a cell's kind,
    # not the cell count.
    rev = {b: [0, 0, 0] for b in BANDS}  # [moves, reveals, covered-before]
    if has_reveals:
        for g in games:
            occ = g["moveOccupancy"]
            cov = g["moveCovered"]
            revealed = g["moveRevealed"]
            before_o, before_c = 7, 7
            for i in range(len(occ)):
                for b in BANDS:
                    if b[0] <= before_o <= b[1]:
                        rev[b][0] += 1
                        rev[b][1] += revealed[i]
                        rev[b][2] += before_c
                        break
                before_o, before_c = occ[i], cov[i]
    out["revealBands"] = rev
    out["hasReveals"] = has_reveals
    return out


def slope(values, skip):
    n = len(values) - skip
    if n < 3:
        return 0.0
    xs = list(range(n))
    ys = values[skip:]
    sx, sy = sum(xs), sum(ys)
    sxy = sum(x * y for x, y in zip(xs, ys))
    sxx = sum(x * x for x in xs)
    d = n * sxx - sx * sx
    return (n * sxy - sx * sy) / d if d else 0.0


def main():
    items = []
    for arg in sys.argv[1:]:
        label, path = arg.split("=", 1)
        items.append((label, cohort(path)))
    print(f"{'cohort':<26}{'n':>3}{'moves':>7}{'cens':>5}{'meanMv':>8}"
          f"{'meanScore':>11}{'clr/mv':>8}{'rev/mv':>8}{'steady':>8}"
          f"{'slope':>8}{'meanOcc':>9}")
    for label, c in items:
        print(f"{label:<26}{c['n']:>3}{c['moves']:>7}{c['censored']:>5}"
              f"{c['meanMoves']:>8.2f}{c['meanScore']:>11.0f}{c['clears']:>8.4f}"
              f"{c['reveals']:>8.4f}{c['steady']:>8.4f}{c['slope']:>+8.3f}"
              f"{c['occ']:>9.2f}")
    print("\nclears/move by occupied cells before the move  (n moves in italics)")
    header = f"{'band':<8}" + "".join(f"{lab:>22}" for lab, _ in items)
    print(header)
    for b in BANDS:
        row = f"{b[0]}-{b[1]:<4}"
        for _lab, c in items:
            count, total = c["bands"][b]
            row += (f"{total / count:>15.2f} (n={count:<4})" if count
                    else f"{'—':>22}")
        print(row)

    if any(c["hasReveals"] for _l, c in items):
        print("\nreveals/move by occupied cells before the move, and mean "
              "covered cells available")
        print(f"{'band':<8}" + "".join(f"{lab:>26}" for lab, c in items
                                       if c["hasReveals"]))
        for b in BANDS:
            row = f"{b[0]}-{b[1]:<4}"
            any_row = False
            for _lab, c in items:
                if not c["hasReveals"]:
                    continue
                moves, reveals, covered = c["revealBands"][b]
                if moves:
                    any_row = True
                    row += (f"{reveals / moves:>12.2f} /cov"
                            f"{covered / moves:>6.1f} (n={moves:<4})")
                else:
                    row += f"{'—':>26}"
            if any_row:
                print(row)


if __name__ == "__main__":
    main()