| """LogLens learnability-law + mechanism — TURNKEY replication harness. |
| |
| A stranger, a different rig, a different scene reproduces (or kills) our result |
| with ONE command. Self-contained except train_composite.py + triclock/ (copy |
| those two from this repo). No bash, relative paths, CPU or CUDA. |
| |
| python replicate.py --bank <folder-of-photos | scene.npy> [--full] [--seeds 2] |
| |
| What it does, and the PASS/FAIL bars (our pre-registered thresholds): |
| 1. R vs feat-R across a size grid ....... PASS if Spearman >= 0.90 (P1: the |
| untrained net preserves the pixel signal/clutter ratio into features) |
| 2. R predicts learnability (blind) ...... PASS if Spearman >= 0.85 (the LAW) |
| 3. Ignition signature .................... PASS if the learnable cell's loss |
| drops below log(K)=1.386 while a floored cell stays pinned there |
| (escape = signal amplifies; collapse = uniform-guess basin) |
| |
| Send back: results_replicate.json + your scene hash. Disagreement is a RESULT. |
| """ |
| import argparse, glob, hashlib, json, math, os, subprocess, sys, time |
| import numpy as np |
|
|
| try: |
| import cv2 |
| except ImportError: |
| sys.exit("pip install opencv-python") |
| import torch |
| from scipy.stats import spearmanr |
| sys.path.insert(0, ".") |
| from triclock.model import LogLensNet |
|
|
| LOGK = math.log(4) |
|
|
| |
| HORIZON_MS = 86_400_000 |
| def _synth_ts(n): |
| """Spread n frames over ~4x the age horizon, so episodes can sample memory |
| slots at ages up to 24h. (1-second spacing silently breaks sampling.)""" |
| step = max(1, (4 * HORIZON_MS) // max(n, 1)) |
| return (np.arange(n, dtype=np.int64) * step) |
| def build_bank(src, n=3000): |
| """Always materialise a matched (frames64, ts64) pair under simreal/ so the |
| loader never sees a bank without its timestamp file.""" |
| os.makedirs("simreal", exist_ok=True) |
| if src.endswith(".npy"): |
| F = np.asarray(np.load(src, mmap_mode="r")[:n]) |
| np.save("simreal/frames64.npy", F) |
| np.save("simreal/ts64.npy", _synth_ts(len(F))) |
| return F, "simreal/frames64.npy" |
| files = sorted(glob.glob(os.path.join(src, "*.jpg")) + glob.glob(os.path.join(src, "*.png"))) |
| if len(files) < 200: |
| sys.exit(f"need >=200 images in {src}, found {len(files)}") |
| idx = np.linspace(0, len(files) - 1, min(n, len(files))).astype(int) |
| frames = [] |
| for i in idx: |
| img = cv2.imread(files[i]) |
| if img is None: continue |
| h, w = img.shape[:2]; s = min(h, w) |
| img = img[(h-s)//2:(h-s)//2+s, (w-s)//2:(w-s)//2+s] |
| img = cv2.cvtColor(cv2.resize(img, (64, 64), cv2.INTER_AREA), cv2.COLOR_BGR2RGB) |
| frames.append(img.astype(np.uint8)) |
| F = np.stack(frames) |
| os.makedirs("simreal", exist_ok=True) |
| np.save("simreal/frames64.npy", F) |
| np.save("simreal/ts64.npy", _synth_ts(len(F))) |
| return F, "simreal/frames64.npy" |
|
|
| |
| def pool(img): return cv2.resize(img, (8, 8), cv2.INTER_AREA).ravel() |
| def draw(img, pos, c, size, ct=1.0): |
| img = img.copy(); x, y = pos |
| img[y:y+size, x:x+size] = c*ct + img[y:y+size, x:x+size].mean(axis=(0, 1))*(1-ct); return img |
|
|
| def ratios(F, size, seed): |
| torch.manual_seed(seed); enc = LogLensNet().enc.eval() |
| rng = np.random.default_rng(seed) |
| @torch.no_grad() |
| def E(imgs): |
| x = torch.from_numpy(np.stack(imgs)).permute(0, 3, 1, 2).float() |
| return enc(x).numpy() |
| sp, dp, sf, df = [], [], [], [] |
| for _ in range(60): |
| f0 = np.asarray(F[rng.integers(len(F))], np.float32) / 255.0 |
| frames = [f0] * 9 |
| pos = (int(rng.integers(2, 60-size)), int(rng.integers(2, 60-size))) |
| c = rng.uniform(.15, .95, 3).astype(np.float32) |
| A, B = draw(f0, pos, c, size), draw(f0, pos, 1-c, size) |
| sp.append(np.sum((pool(A)-pool(B))**2)/4) |
| Pp = np.array([pool(f) for f in frames]) |
| prp = [pool(np.roll(np.roll(f0, int(rng.integers(64)), 0), int(rng.integers(64)), 1)) for _ in range(6)] |
| dp.append(Pp.var(0).sum()+np.array(prp).var(0).sum()) |
| a, b = E([A, B]); sf.append(np.sum((a-b)**2)/4) |
| Pf = E(frames); prf = E([np.roll(np.roll(f0, int(rng.integers(64)), 0), int(rng.integers(64)), 1) for _ in range(6)]) |
| df.append(Pf.var(0).sum()+prf.var(0).sum()) |
| return float(np.mean(sp)/(np.mean(dp)+1e-9)), float(np.mean(sf)/(np.mean(df)+1e-9)) |
|
|
| |
| def train_cell(bank, size, seed, steps): |
| out = "results_replicate" |
| subprocess.run([sys.executable, "-m", "simreal.train_composite", "--steps", str(steps), |
| "--bank", bank, "--objscale", str(size), "--static-bg", "--seed", str(seed), |
| "--out", out], check=False) |
| hits = glob.glob(f"{out}/*_o{size}_*_s{seed}.json") |
| return json.load(open(hits[-1]))["heldout_mean"] if hits else None |
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--bank", required=True, help="folder of photos OR a .npy bank") |
| ap.add_argument("--full", action="store_true", help="20k steps (default 8k quick)") |
| ap.add_argument("--seeds", type=int, default=2, help="seeds per cell (>=2 recommended: the wall is seed-bimodal)") |
| a = ap.parse_args() |
| steps = 20000 if a.full else 8000 |
| SIZES = [5, 8, 12, 16] |
| print(f"[replicate] device={'cuda' if torch.cuda.is_available() else 'cpu'} steps={steps} seeds={a.seeds}") |
| F, bankpath = build_bank(a.bank) |
| scene_hash = hashlib.md5(np.asarray(F[:500]).tobytes()).hexdigest()[:12] |
| print(f"[replicate] scene bank {len(F)} frames hash={scene_hash}") |
|
|
| |
| R, featR = {}, {} |
| for s in SIZES: |
| r, f = ratios(F, s, 0); R[s], featR[s] = r, f |
| print(f" size {s:2d}px R={r:.4f} feat-R={f:.4f}") |
| p1 = spearmanr([R[s] for s in SIZES], [featR[s] for s in SIZES])[0] |
|
|
| |
| acc = {} |
| for s in SIZES: |
| outs = [train_cell(bankpath, s, sd, steps) for sd in range(a.seeds)] |
| outs = [o for o in outs if o is not None] |
| acc[s] = float(np.mean(outs)); |
| print(f" size {s:2d}px R={R[s]:.4f} mean_acc={acc[s]:.3f} (best {max(outs):.3f} of {len(outs)})") |
| p2 = spearmanr([R[s] for s in SIZES], [acc[s] for s in SIZES])[0] |
|
|
| |
| learn_s = max(SIZES, key=lambda s: R[s]); floor_s = min(SIZES, key=lambda s: R[s]) |
| ignition = acc[learn_s] >= 0.55 and acc[floor_s] < 0.55 |
|
|
| res = {"scene_hash": scene_hash, "steps": steps, "seeds": a.seeds, |
| "R": R, "featR": featR, "acc": acc, |
| "P1_R_vs_featR": p1, "P2_law_R_vs_acc": p2, "ignition": ignition} |
| json.dump(res, open("results_replicate.json", "w"), indent=2) |
|
|
| def mark(ok): return "PASS" if ok else "FAIL" |
| print("\n================ REPLICATION SCORECARD ================") |
| print(f" P1 R <-> feat-R Spearman={p1:+.3f} (>=0.90) {mark(p1>=0.90)}") |
| print(f" P2 LAW R <-> acc Spearman={p2:+.3f} (>=0.85) {mark(p2>=0.85)}") |
| print(f" P3 ignition (learn escapes, floor pinned at logK) {mark(ignition)}") |
| print("======================================================") |
| print("Compare to EXPECTED_RESULTS.md. Send results_replicate.json + scene_hash back.") |
| print("A disagreement is a RESULT, not a failure — that is the point of an independent rig.") |
|
|
| if __name__ == "__main__": |
| main() |
|
|