Files
2026-07-16 11:57:36 +07:00

165 lines
7.2 KiB
Python

#!/usr/bin/env python3
# capture_and_predict.py
# PASSIVE PREDICT-TEST on the live 5561 coarse stream.
# Read-only: subscribes to the observer daemon, sends NOTHING.
# Captures N frames, then asks the ONE honest question:
# Can ANY predictor beat persistence ("next = current") on the live field?
# Pre-committed, bootstrapped, no rescue. Writes results + a data file for visualization.
#
# Per KHRAGIXX_SOURCE_OF_TRUTH.md:
# - short window (leak-safe)
# - persistence + linear baselines, pre-committed
# - bootstrap distribution, not single sample
# - a clean null is a real result; do not rescue
import sys, io, json, time, struct
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
import numpy as np
try:
import zmq
except ImportError:
print("pip install pyzmq --break-system-packages"); sys.exit(1)
TILES, CH = 32, 6
NVALS = TILES * TILES * CH
HDR = 16
FRAME_BYTES = HDR + NVALS * 4
N_CAPTURE = 500 # ~50s at 100Hz. Short => leak-safe.
PORT = "tcp://localhost:5561"
CH_NAMES = ["rho", "ux", "uy", "sxx", "syy", "sxy"]
OUT_DATA = "predict_capture.npz"
OUT_REPORT = "predict_report.txt"
def capture(n):
ctx = zmq.Context()
s = ctx.socket(zmq.SUB)
s.setsockopt(zmq.RCVTIMEO, 5000)
s.setsockopt_string(zmq.SUBSCRIBE, "")
s.connect(PORT)
frames, cycles = [], []
print(f"Capturing {n} frames from {PORT} (read-only)...", flush=True)
t0 = time.time()
while len(frames) < n:
try:
buf = s.recv()
except zmq.Again:
print(f" timeout after {len(frames)} frames — is the daemon streaming 5561?"); break
if len(buf) != FRAME_BYTES: continue
if buf[:4] != b"KGCF": continue
cyc = struct.unpack_from("<I", buf, 4)[0]
vals = np.frombuffer(buf, dtype=np.float32, count=NVALS, offset=HDR).copy()
frames.append(vals.reshape(TILES*TILES, CH))
cycles.append(cyc)
if len(frames) % 100 == 0:
print(f" {len(frames)}/{n} cycle={cyc}", flush=True)
s.close(); ctx.term()
dt = time.time() - t0
print(f"Captured {len(frames)} frames in {dt:.1f}s ({len(frames)/max(dt,1e-9):.1f} fps)", flush=True)
return np.array(frames), np.array(cycles)
def zscore_per_channel(F):
# F: (T, tiles, CH). Normalize each channel so no single channel dominates the error metric.
mu = F.mean(axis=(0,1), keepdims=True)
sd = F.std(axis=(0,1), keepdims=True); sd[sd < 1e-9] = 1.0
return (F - mu) / sd
def frame_err(pred, truth):
# RMSE over all tiles*channels of one frame-step, per step -> array of length T-1 (or T-k)
d = pred - truth
return np.sqrt(np.mean(d*d, axis=(1,2)))
def main():
F, C = capture(N_CAPTURE)
if len(F) < 50:
print("Too few frames; aborting."); sys.exit(1)
# cycle sanity: should advance ~10/frame
dcyc = np.diff(C)
print(f"cycle step: median={np.median(dcyc):.0f} (expect ~10), min={dcyc.min()}, max={dcyc.max()}", flush=True)
Fz = zscore_per_channel(F.astype(np.float64)) # (T, 1024, 6)
T = len(Fz)
# ---- PRE-COMMITTED PREDICTORS (declared before scoring) ----
# 1) PERSISTENCE: pred[t] = frame[t-1] (the baseline to beat)
# 2) LINEAR: pred[t] = 2*frame[t-1] - frame[t-2] (constant-velocity extrapolation)
# 3) MEAN: pred[t] = running mean of all prior frames (dumb global)
# Score = mean per-step RMSE over t = 2..T-1 (aligned window for all three).
truth = Fz[2:] # frames 2..T-1
persist = Fz[1:-1] # frame t-1
linear = 2*Fz[1:-1] - Fz[0:-2] # 2*(t-1) - (t-2)
# running mean predictor
cummean = np.cumsum(Fz, axis=0) / np.arange(1, T+1)[:,None,None]
meanpred = cummean[1:-1] # mean of frames 0..t-1 approx (uses through t-1)
e_persist = frame_err(persist, truth)
e_linear = frame_err(linear, truth)
e_mean = frame_err(meanpred, truth)
P = e_persist.mean(); L = e_linear.mean(); M = e_mean.mean()
# ---- BOOTSTRAP: is linear reliably better (lower err) than persistence? ----
# Pre-committed: linear "beats" persistence only if the 95% bootstrap CI of
# (persist_err - linear_err) is entirely > 0 (i.e. linear strictly lower error),
# AND the median improvement exceeds 2% of persistence error (not noise-width).
rng = np.random.default_rng(42)
diffs = e_persist - e_linear # >0 means linear better
n = len(diffs)
boot = np.array([rng.choice(diffs, n, replace=True).mean() for _ in range(2000)])
lo, hi = np.percentile(boot, [2.5, 97.5])
improve_frac = (P - L) / P
linear_beats = (lo > 0) and (improve_frac > 0.02)
# How predictable is the field at all? (persistence error relative to frame-to-frame scale)
# scale = typical magnitude of a z-scored frame's per-step change baseline
field_change = frame_err(Fz[1:], Fz[:-1]).mean() # avg step-to-step change
lines = []
def out(s): print(s, flush=True); lines.append(s)
out("="*64)
out("PASSIVE PREDICT-TEST — live 5561 coarse field")
out("="*64)
out(f"frames used: {T} (cycles {C[0]} -> {C[-1]}, ~{(C[-1]-C[0])} cycles)")
out(f"channels: {CH_NAMES}")
out("")
out("Per-step RMSE (z-scored field, lower = better predictor):")
out(f" PERSISTENCE (next=current): {P:.4f} <- baseline to beat")
out(f" LINEAR (const-velocity extrap): {L:.4f}")
out(f" RUNNING MEAN (dumb global): {M:.4f}")
out("")
out("PRE-COMMITTED TEST: does LINEAR reliably beat PERSISTENCE?")
out(f" improvement: {improve_frac*100:+.2f}% (need > +2.00%)")
out(f" bootstrap 95% CI of (persist_err - linear_err): [{lo:+.4f}, {hi:+.4f}] (need lo > 0)")
out(f" VERDICT: {'LINEAR BEATS PERSISTENCE — field carries short-horizon dynamics' if linear_beats else 'NULL — no predictor beats persistence at threshold'}")
out("")
# Interpretation guardrails (from the doc, so the next reader doesn't over-read)
if linear_beats:
out("READ: The field's next state is better predicted by its trajectory than by")
out("assuming it stays still. That means the live fast field carries structured")
out("short-horizon motion the 9 global scalars could not show. This is the FIRST")
out("positive signal that the live organism has learnable dynamics. It is NOT yet")
out("memory (that needs the poke/history test). It IS a green light to proceed.")
else:
out("READ: Nothing beats 'it stays the same' at the committed threshold. The live")
out("coarse field, at this resolution and horizon, is either near-static frame to")
out("frame or its motion is not linearly predictable. A clean null. Do not rescue.")
out("Next: try shorter tiles (finer grid) or the stress channels alone before")
out("concluding the substrate has no fast structure.")
with open(OUT_REPORT, "w") as f:
f.write("\n".join(lines) + "\n")
# Save for visualization: raw frames (un-normalized), cycles, and per-step errors
np.savez(OUT_DATA,
frames=F.astype(np.float32), cycles=C,
e_persist=e_persist, e_linear=e_linear,
ch_names=np.array(CH_NAMES))
print(f"\nWrote {OUT_REPORT} and {OUT_DATA}", flush=True)
print("DONE.", flush=True)
if __name__ == "__main__":
main()