609 lines
24 KiB
Python
609 lines
24 KiB
Python
"""regime_classifier_injector.py — 3-channel lattice regime classifier.
|
||
|
||
DIRECTIVE FROM CLAUDE DESKTOP — Option B (2026-06-08)
|
||
After 6h of 8-tension encoding confirming the predictive-feature path is dead,
|
||
abandon "lattice as predictor". Use the lattice as a BINARY REGIME CLASSIFIER
|
||
on top of trade_count (the strongest validated raw signal).
|
||
|
||
INPUTS — three channels, injected per minute (no 4hr aggregation, no levels/vels):
|
||
1. trade_count z-score (rolling 500min one-sided) → CENTER (512, 512)
|
||
2. taker_buy_usd z-score (rolling 500min one-sided) → (400, 512)
|
||
3. taker_sell_usd z-score (rolling 500min one-sided) → (624, 512)
|
||
|
||
Strength = z / 3 capped ±1.0. Sigma = 32. No exotic geometry: just three
|
||
gaussian blobs along x, with buy at left and sell at right so an imbalance
|
||
creates a directional asymmetry in the stress field.
|
||
|
||
Why these three:
|
||
- trade_count: validated +1.0 Spearman rank stability IS/OOS.
|
||
- taker_buy + taker_sell SEPARATELY (not signed_flow): signed_flow shows
|
||
zero predictive value; the components separately carry volume information
|
||
the lattice can integrate.
|
||
|
||
OUTPUTS:
|
||
Same 9 telemetry channels as before (asymmetry, coherence, stress_xx, ...).
|
||
Downstream analyzer uses arm A free-running quantiles to set p10/p90
|
||
thresholds for HIGH/LOW/NEUTRAL regime labels.
|
||
|
||
SUCCESS CRITERION (downstream test, not in this script):
|
||
|fwd_60| volatility ratio for (HIGH TENSION ∩ top-decile trade_count) minutes
|
||
vs (top-decile trade_count alone). Current best is 2.42x. Beat that or the
|
||
lattice is redundant.
|
||
|
||
OUTPUT FILES:
|
||
/mnt/d/Resonance_Engine/traj/regime_<RUN_ID>/
|
||
meta.json
|
||
arm_A_no_inject.parquet
|
||
arm_T_3ch.parquet
|
||
progress.log
|
||
"""
|
||
from __future__ import annotations
|
||
import argparse, glob, json, queue, threading, time, urllib.request, urllib.error
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
import zmq
|
||
|
||
# ───────────────────────── config ─────────────────────────
|
||
DATA_ROOT = "/mnt/d/PaperTrader/research/hl_data/minutes"
|
||
COIN = "BTC"
|
||
|
||
DEFAULT_DAYS_GLOB = "20260415*"
|
||
|
||
# z-score rolling window (one-sided, past-only via shift(1))
|
||
Z_WIN_MIN = 500
|
||
Z_MIN_PERIODS = 100
|
||
|
||
# strength = z / Z_DENOM capped to STR_CAP
|
||
Z_DENOM = 3.0
|
||
STR_CAP = 1.0
|
||
|
||
# Lattice spatial layout (1024×1024)
|
||
CENTER = (512.0, 512.0)
|
||
TAKER_BUY_XY = (400.0, 512.0) # left of center
|
||
TAKER_SELL_XY = (624.0, 512.0) # right of center
|
||
SIGMA = 32.0
|
||
|
||
# Timing
|
||
PER_MINUTE_MS = 100 # 10× realtime
|
||
WAIT_AFTER_INJECT_MS = 60 # only 3 injects, less settling time needed
|
||
COOLDOWN_BETWEEN_ARMS_S = 1200 # 20 min
|
||
|
||
# ZMQ
|
||
TEL_ADDR = "tcp://127.0.0.1:5556"
|
||
CMD_ADDR = "tcp://127.0.0.1:5557"
|
||
|
||
CHANNELS = ["asymmetry", "coherence", "stress_xx", "stress_yy", "stress_xy",
|
||
"vorticity_mean", "vel_mean", "vel_max", "vel_var"]
|
||
|
||
# Fractonaut HTTP API (read-only observer, port 28822 — note: 28821 in directive
|
||
# was wrong; actual code uses 28822)
|
||
FRACTO_URL = "http://127.0.0.1:28822/ask"
|
||
FRACTO_TIMEOUT_S = 180 # Ollama can take 30-60s for gemma3:4b responses
|
||
FRACTO_QUEUE_MAX = 64 # cap pending queries — drop new ones if full
|
||
|
||
RUN_ID = time.strftime("%Y%m%dT%H%M%S")
|
||
OUT_DIR = Path(f"/mnt/d/Resonance_Engine/traj/regime_{RUN_ID}")
|
||
PROGRESS = OUT_DIR / "progress.log"
|
||
FRACTO_LOG = OUT_DIR / "fractonaut_observations.jsonl"
|
||
|
||
|
||
def log(msg: str) -> None:
|
||
line = f"[{time.strftime('%Y-%m-%dT%H:%M:%S')}] {msg}"
|
||
print(line, flush=True)
|
||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
with PROGRESS.open("a") as f:
|
||
f.write(line + "\n")
|
||
|
||
|
||
# ───────────────────────── Fractonaut querier ─────────────────────────
|
||
class FractoQuerier:
|
||
"""Fire-and-forget background querier. Submit() returns immediately;
|
||
a worker thread serializes calls to /ask (Ollama only handles one
|
||
request at a time anyway) and appends responses to fractonaut_observations.jsonl.
|
||
"""
|
||
|
||
def __init__(self, url: str = FRACTO_URL):
|
||
self.url = url
|
||
self.q: queue.Queue = queue.Queue(maxsize=FRACTO_QUEUE_MAX)
|
||
self.dropped = 0
|
||
self.submitted = 0
|
||
self.responded = 0
|
||
self.errors = 0
|
||
self._stop = threading.Event()
|
||
self._t = threading.Thread(target=self._worker, daemon=True)
|
||
self._t.start()
|
||
|
||
def submit(self, question: str, ctx: dict) -> None:
|
||
"""Non-blocking. Drops the query if the queue is full."""
|
||
item = {
|
||
"submit_wall": time.time(),
|
||
"question": question,
|
||
"context": ctx,
|
||
}
|
||
try:
|
||
self.q.put_nowait(item)
|
||
self.submitted += 1
|
||
except queue.Full:
|
||
self.dropped += 1
|
||
|
||
def _worker(self) -> None:
|
||
while not self._stop.is_set():
|
||
try:
|
||
item = self.q.get(timeout=1.0)
|
||
except queue.Empty:
|
||
continue
|
||
self._call(item)
|
||
self.q.task_done()
|
||
|
||
def _call(self, item: dict) -> None:
|
||
body = json.dumps({"question": item["question"]}).encode()
|
||
req = urllib.request.Request(
|
||
self.url, data=body,
|
||
headers={"Content-Type": "application/json"}, method="POST",
|
||
)
|
||
t0 = time.time()
|
||
resp_text = None
|
||
err = None
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=FRACTO_TIMEOUT_S) as r:
|
||
resp_json = json.loads(r.read())
|
||
resp_text = resp_json.get("response", "")
|
||
self.responded += 1
|
||
except Exception as e:
|
||
err = str(e)
|
||
self.errors += 1
|
||
elapsed = time.time() - t0
|
||
|
||
entry = {
|
||
"submit_wall": item["submit_wall"],
|
||
"response_wall": time.time(),
|
||
"elapsed_s": elapsed,
|
||
"context": item["context"],
|
||
"question": item["question"],
|
||
"response": resp_text,
|
||
"error": err,
|
||
}
|
||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
with FRACTO_LOG.open("a") as f:
|
||
f.write(json.dumps(entry) + "\n")
|
||
|
||
def drain(self, max_wait_s: float = 600.0) -> None:
|
||
"""Block until queue is empty or timeout."""
|
||
deadline = time.time() + max_wait_s
|
||
while time.time() < deadline:
|
||
if self.q.empty():
|
||
# let in-flight call finish
|
||
time.sleep(1)
|
||
if self.q.empty():
|
||
return
|
||
time.sleep(2)
|
||
|
||
def stop(self) -> None:
|
||
self._stop.set()
|
||
self._t.join(timeout=5)
|
||
|
||
def stats(self) -> str:
|
||
return (f"fracto submitted={self.submitted} responded={self.responded} "
|
||
f"dropped={self.dropped} errors={self.errors} pending={self.q.qsize()}")
|
||
|
||
|
||
# ───────────────────────── ZMQ telemetry ─────────────────────────
|
||
class LatestTel:
|
||
def __init__(self, addr: str):
|
||
self.ctx = zmq.Context.instance()
|
||
self.sock = self.ctx.socket(zmq.SUB)
|
||
self.sock.connect(addr)
|
||
self.sock.setsockopt(zmq.SUBSCRIBE, b"")
|
||
self.sock.setsockopt(zmq.RCVHWM, 2000)
|
||
self.latest: dict | None = None
|
||
self.latest_wall: float = 0.0
|
||
self.n_seen = 0
|
||
self._stop = threading.Event()
|
||
self._t = threading.Thread(target=self._run, daemon=True)
|
||
self._t.start()
|
||
|
||
def _run(self):
|
||
while not self._stop.is_set():
|
||
if self.sock.poll(100):
|
||
try:
|
||
raw = self.sock.recv_string(zmq.NOBLOCK)
|
||
self.latest = json.loads(raw)
|
||
self.latest_wall = time.time()
|
||
self.n_seen += 1
|
||
except Exception:
|
||
pass
|
||
|
||
def snapshot(self) -> tuple[dict | None, float]:
|
||
return self.latest, self.latest_wall
|
||
|
||
def stop(self):
|
||
self._stop.set()
|
||
self._t.join(timeout=2)
|
||
try:
|
||
self.sock.close(0)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ───────────────────────── data loading ─────────────────────────
|
||
def load_btc(days_glob: str) -> pd.DataFrame:
|
||
day_dirs = sorted(glob.glob(f"{DATA_ROOT}/{days_glob}"))
|
||
if not day_dirs:
|
||
raise RuntimeError(f"no day dirs matching {days_glob} in {DATA_ROOT}")
|
||
log(f"loading {len(day_dirs)} day dirs ({day_dirs[0].split('/')[-1]} .. {day_dirs[-1].split('/')[-1]})")
|
||
dfs = []
|
||
for d in day_dirs:
|
||
for f in sorted(glob.glob(f"{d}/*.parquet")):
|
||
dfs.append(pd.read_parquet(f))
|
||
df = pd.concat(dfs, ignore_index=True)
|
||
df = df[df.coin == COIN].sort_values("minute").drop_duplicates("minute").reset_index(drop=True)
|
||
log(f"loaded {len(df)} unique minutes of {COIN}")
|
||
return df
|
||
|
||
|
||
def compute_zscores(df: pd.DataFrame) -> pd.DataFrame:
|
||
"""Rolling 500-min one-sided z-scores for trade_count, taker_buy_usd,
|
||
taker_sell_usd. Past-only (shift(1) before rolling)."""
|
||
out = df.copy()
|
||
for col in ["trade_count", "taker_buy_usd", "taker_sell_usd"]:
|
||
s = out[col].astype(float).shift(1)
|
||
mu = s.rolling(Z_WIN_MIN, min_periods=Z_MIN_PERIODS).mean()
|
||
sd = s.rolling(Z_WIN_MIN, min_periods=Z_MIN_PERIODS).std()
|
||
out[f"{col}_z"] = (out[col].astype(float) - mu) / sd.replace(0, np.nan)
|
||
return out
|
||
|
||
|
||
# ───────────────────────── injection ─────────────────────────
|
||
def _send_inject(pub: zmq.Socket, x: float, y: float,
|
||
strength: float, sigma: float = SIGMA) -> None:
|
||
payload = {"cmd": "inject_density",
|
||
"x": float(x), "y": float(y),
|
||
"sigma": float(sigma),
|
||
"strength": float(strength)}
|
||
pub.send_string(json.dumps(payload))
|
||
|
||
|
||
def _z_to_strength(z: float) -> float | None:
|
||
if not np.isfinite(z):
|
||
return None
|
||
s = float(np.clip(z / Z_DENOM, -STR_CAP, +STR_CAP))
|
||
if abs(s) < 1e-6:
|
||
return None
|
||
return s
|
||
|
||
|
||
def encode_trade_count(pub: zmq.Socket, z: float) -> None:
|
||
s = _z_to_strength(z)
|
||
if s is None: return
|
||
cx, cy = CENTER
|
||
_send_inject(pub, cx, cy, s)
|
||
|
||
|
||
def encode_taker_buy(pub: zmq.Socket, z: float) -> None:
|
||
s = _z_to_strength(z)
|
||
if s is None: return
|
||
x, y = TAKER_BUY_XY
|
||
_send_inject(pub, x, y, s)
|
||
|
||
|
||
def encode_taker_sell(pub: zmq.Socket, z: float) -> None:
|
||
s = _z_to_strength(z)
|
||
if s is None: return
|
||
x, y = TAKER_SELL_XY
|
||
_send_inject(pub, x, y, s)
|
||
|
||
|
||
# ───────────────────────── per-arm runner ─────────────────────────
|
||
def classify_regime(asym: float, coh: float,
|
||
asym_p10: float | None, asym_p90: float | None,
|
||
coh_p10: float | None, coh_p90: float | None) -> str:
|
||
"""Return one of HIGH / LOW / NEUTRAL. Returns NEUTRAL if thresholds
|
||
not yet known or telemetry invalid."""
|
||
if any(v is None or not np.isfinite(v) for v in
|
||
(asym, coh, asym_p10, asym_p90, coh_p10, coh_p90)):
|
||
return "NEUTRAL"
|
||
if asym > asym_p90 or coh < coh_p10:
|
||
return "HIGH"
|
||
if asym < asym_p10 and coh > coh_p90:
|
||
return "LOW"
|
||
return "NEUTRAL"
|
||
|
||
def snapshot_row(tel: LatestTel, minute: int, arm: str,
|
||
row: pd.Series) -> dict:
|
||
snap, snap_wall = tel.snapshot()
|
||
rec = {
|
||
"minute": int(minute),
|
||
"arm": arm,
|
||
"snap_wall": snap_wall,
|
||
"snap_age_ms": (time.time() - snap_wall) * 1000 if snap_wall else None,
|
||
# raw values for downstream analysis
|
||
"trade_count": float(row.get("trade_count", np.nan)),
|
||
"taker_buy_usd": float(row.get("taker_buy_usd", np.nan)),
|
||
"taker_sell_usd": float(row.get("taker_sell_usd", np.nan)),
|
||
"mid_price": float(row.get("mid_price", np.nan)),
|
||
# z-scores actually injected
|
||
"trade_count_z": row.get("trade_count_z"),
|
||
"taker_buy_usd_z": row.get("taker_buy_usd_z"),
|
||
"taker_sell_usd_z": row.get("taker_sell_usd_z"),
|
||
}
|
||
if snap is None:
|
||
for ch in CHANNELS:
|
||
rec[ch] = None
|
||
else:
|
||
for ch in CHANNELS:
|
||
rec[ch] = snap.get(ch)
|
||
return rec
|
||
|
||
|
||
def run_arm(tel: LatestTel, pub: zmq.Socket | None, arm: str,
|
||
df: pd.DataFrame,
|
||
fracto: FractoQuerier | None = None,
|
||
thresholds: dict | None = None) -> pd.DataFrame:
|
||
"""Run one arm.
|
||
|
||
If `fracto` and `thresholds` are supplied, regime labels (HIGH/LOW/NEUTRAL)
|
||
are computed each minute, transitions are detected, and a Fractonaut /ask
|
||
query is fired (non-blocking) on every transition.
|
||
"""
|
||
log(f"\n=== arm {arm}: starting ({len(df)} minutes) ===")
|
||
if arm == "A":
|
||
log(" arm A: NO INJECTIONS — sampling state at cadence only")
|
||
elif arm == "T":
|
||
log(" arm T: 3 Z-SCORE INJECTIONS per minute (trade_count center, buy left, sell right)")
|
||
if fracto is not None and thresholds is not None:
|
||
log(f" fractonaut transitions ENABLED thresholds={thresholds}")
|
||
|
||
records: list[dict] = []
|
||
t_arm_start = time.time()
|
||
last_status = t_arm_start
|
||
n_minutes = len(df)
|
||
step_s = PER_MINUTE_MS / 1000.0
|
||
wait_inject_s = WAIT_AFTER_INJECT_MS / 1000.0
|
||
|
||
prev_regime = "NEUTRAL"
|
||
n_transitions = 0
|
||
|
||
for i in range(n_minutes):
|
||
t_tick = time.time()
|
||
row = df.iloc[i]
|
||
|
||
if arm == "T" and pub is not None:
|
||
encode_trade_count(pub, row.get("trade_count_z"))
|
||
encode_taker_buy (pub, row.get("taker_buy_usd_z"))
|
||
encode_taker_sell (pub, row.get("taker_sell_usd_z"))
|
||
time.sleep(wait_inject_s)
|
||
else:
|
||
time.sleep(wait_inject_s)
|
||
|
||
rec = snapshot_row(tel, int(row.minute), arm, row)
|
||
|
||
# Regime classification & transition detection (arm T only — arm A is the
|
||
# threshold source, we don't ask Fractonaut about its own baseline)
|
||
if arm == "T" and fracto is not None and thresholds is not None:
|
||
asym = rec.get("asymmetry")
|
||
coh = rec.get("coherence")
|
||
label = classify_regime(
|
||
asym, coh,
|
||
thresholds.get("asym_p10"), thresholds.get("asym_p90"),
|
||
thresholds.get("coh_p10"), thresholds.get("coh_p90"),
|
||
)
|
||
rec["regime"] = label
|
||
if label != prev_regime and label in ("HIGH", "LOW"):
|
||
n_transitions += 1
|
||
tc_z = row.get("trade_count_z", float("nan"))
|
||
buy_z = row.get("taker_buy_usd_z", float("nan"))
|
||
sell_z = row.get("taker_sell_usd_z", float("nan"))
|
||
question = (
|
||
f"Field just entered {label} TENSION state "
|
||
f"(from {prev_regime}). "
|
||
f"asymmetry={asym:.1f} coherence={coh:.4f} "
|
||
f"trade_count_z={tc_z:.2f} buy_z={buy_z:.2f} sell_z={sell_z:.2f}. "
|
||
f"Have you seen this configuration before? "
|
||
f"What pattern does this resemble and what followed last time?"
|
||
)
|
||
fracto.submit(question, {
|
||
"event": "regime_transition",
|
||
"minute": int(row.minute),
|
||
"from": prev_regime, "to": label,
|
||
"asymmetry": float(asym) if asym is not None else None,
|
||
"coherence": float(coh) if coh is not None else None,
|
||
"trade_count_z": float(tc_z) if np.isfinite(tc_z) else None,
|
||
"taker_buy_usd_z": float(buy_z) if np.isfinite(buy_z) else None,
|
||
"taker_sell_usd_z": float(sell_z) if np.isfinite(sell_z) else None,
|
||
"stress_xx": rec.get("stress_xx"),
|
||
"stress_yy": rec.get("stress_yy"),
|
||
})
|
||
elif label != prev_regime and label == "NEUTRAL" and prev_regime in ("HIGH", "LOW"):
|
||
n_transitions += 1
|
||
question = (
|
||
f"Field just exited {prev_regime} TENSION back to NEUTRAL. "
|
||
f"asymmetry={asym:.1f} coherence={coh:.4f}. "
|
||
f"How did the configuration evolve while in {prev_regime}? "
|
||
f"Did anything notable change before the return to baseline?"
|
||
)
|
||
fracto.submit(question, {
|
||
"event": "regime_transition",
|
||
"minute": int(row.minute),
|
||
"from": prev_regime, "to": label,
|
||
"asymmetry": float(asym) if asym is not None else None,
|
||
"coherence": float(coh) if coh is not None else None,
|
||
})
|
||
prev_regime = label
|
||
|
||
records.append(rec)
|
||
|
||
spent = time.time() - t_tick
|
||
remaining = step_s - spent
|
||
if remaining > 0:
|
||
time.sleep(remaining)
|
||
|
||
if time.time() - last_status > 60:
|
||
elapsed = time.time() - t_arm_start
|
||
pct = (i + 1) / n_minutes * 100
|
||
rate_per_s = (i + 1) / elapsed if elapsed > 0 else 0
|
||
eta_s = (n_minutes - i - 1) / rate_per_s if rate_per_s > 0 else 0
|
||
cur_field = tel.latest or {}
|
||
asym = cur_field.get('asymmetry', float('nan'))
|
||
extras = ""
|
||
if fracto is not None and arm == "T":
|
||
extras = f" trans={n_transitions} " + fracto.stats()
|
||
log(f" arm {arm} {i+1}/{n_minutes} ({pct:.1f}%) "
|
||
f"rate={rate_per_s:.1f}min/s ETA={eta_s/60:.1f}min "
|
||
f"asym={asym:.3f} "
|
||
f"latest_tel_age={(time.time()-tel.latest_wall)*1000 if tel.latest_wall else -1:.0f}ms "
|
||
f"tel_seen={tel.n_seen}{extras}")
|
||
last_status = time.time()
|
||
|
||
log(f" arm {arm} COMPLETE records={len(records)} total={(time.time()-t_arm_start)/60:.1f}min")
|
||
if fracto is not None and arm == "T":
|
||
log(f" arm T transitions={n_transitions} {fracto.stats()}")
|
||
return pd.DataFrame(records)
|
||
|
||
|
||
# ───────────────────────── main ─────────────────────────
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--days-glob", default=DEFAULT_DAYS_GLOB,
|
||
help="day-dir glob under hl_data/minutes/")
|
||
ap.add_argument("--max-minutes", type=int, default=None,
|
||
help="cap minutes (shakedown)")
|
||
ap.add_argument("--arms", default="A,T",
|
||
help="comma-separated arms to run (default A,T)")
|
||
ap.add_argument("--cooldown-s", type=int, default=COOLDOWN_BETWEEN_ARMS_S,
|
||
help="seconds between arms (only after arm T)")
|
||
args = ap.parse_args()
|
||
|
||
log(f"=== regime_classifier_injector run_id={RUN_ID} ===")
|
||
log(f"out_dir={OUT_DIR}")
|
||
log(f"args: days_glob={args.days_glob} max_minutes={args.max_minutes} "
|
||
f"arms={args.arms}")
|
||
|
||
df = load_btc(args.days_glob)
|
||
if args.max_minutes:
|
||
df = df.head(args.max_minutes).reset_index(drop=True)
|
||
log(f"capped to first {args.max_minutes} minutes")
|
||
|
||
df = compute_zscores(df)
|
||
log("z-scores computed (past-only 500min rolling):")
|
||
for col in ["trade_count_z", "taker_buy_usd_z", "taker_sell_usd_z"]:
|
||
s = df[col]
|
||
finite = int(np.isfinite(s.astype(float)).sum())
|
||
log(f" {col:<22} n_finite={finite:>5}/{len(s)} "
|
||
f"mean={s.mean():+.4f} std={s.std():+.4f} "
|
||
f"min={s.min():+.4f} max={s.max():+.4f}")
|
||
|
||
meta = {
|
||
"run_id": RUN_ID,
|
||
"coin": COIN,
|
||
"data_root": DATA_ROOT,
|
||
"days_glob": args.days_glob,
|
||
"n_minutes": int(len(df)),
|
||
"minute_range": [int(df.minute.min()), int(df.minute.max())],
|
||
"z_win_min": Z_WIN_MIN,
|
||
"z_denom": Z_DENOM,
|
||
"str_cap": STR_CAP,
|
||
"spatial": {
|
||
"center": list(CENTER),
|
||
"taker_buy_xy": list(TAKER_BUY_XY),
|
||
"taker_sell_xy": list(TAKER_SELL_XY),
|
||
"sigma": SIGMA,
|
||
},
|
||
"timing": {
|
||
"per_minute_ms": PER_MINUTE_MS,
|
||
"wait_after_inject_ms": WAIT_AFTER_INJECT_MS,
|
||
"cooldown_between_arms_s": COOLDOWN_BETWEEN_ARMS_S,
|
||
},
|
||
"arms": args.arms.split(","),
|
||
}
|
||
(OUT_DIR / "meta.json").write_text(json.dumps(meta, indent=2))
|
||
|
||
tel = LatestTel(TEL_ADDR)
|
||
ctx = zmq.Context.instance()
|
||
pub = ctx.socket(zmq.PUB)
|
||
pub.connect(CMD_ADDR)
|
||
log(f"ZMQ connected: SUB {TEL_ADDR}, PUB {CMD_ADDR}")
|
||
log("warming up SUB socket (3s) ...")
|
||
time.sleep(3)
|
||
log(f" initial tel_seen={tel.n_seen} latest_age_ms="
|
||
f"{(time.time()-tel.latest_wall)*1000 if tel.latest_wall else -1:.0f}")
|
||
|
||
# Fractonaut querier (fire-and-forget). It is OK if the HTTP service is down
|
||
# — the worker will just log errors; the run keeps going.
|
||
fracto = FractoQuerier()
|
||
log(f"FractoQuerier ready; HTTP target = {FRACTO_URL}")
|
||
|
||
arm_A_telemetry: pd.DataFrame | None = None
|
||
thresholds: dict | None = None
|
||
|
||
try:
|
||
arms_to_run = [a.strip().upper() for a in args.arms.split(",")]
|
||
for ai, arm in enumerate(arms_to_run):
|
||
if arm not in ("A", "T"):
|
||
log(f"skipping unknown arm: {arm}")
|
||
continue
|
||
if ai > 0 and arms_to_run[ai - 1] == "T":
|
||
log(f"--- cooldown {args.cooldown_s}s ---")
|
||
time.sleep(args.cooldown_s)
|
||
|
||
if arm == "T" and arm_A_telemetry is not None:
|
||
# Derive thresholds from arm A telemetry distribution
|
||
arm_A_clean = arm_A_telemetry.dropna(subset=["asymmetry", "coherence"])
|
||
thresholds = {
|
||
"asym_p10": float(arm_A_clean["asymmetry"].quantile(0.10)),
|
||
"asym_p90": float(arm_A_clean["asymmetry"].quantile(0.90)),
|
||
"coh_p10": float(arm_A_clean["coherence"].quantile(0.10)),
|
||
"coh_p90": float(arm_A_clean["coherence"].quantile(0.90)),
|
||
}
|
||
log(f"derived thresholds from arm A: {thresholds}")
|
||
# START-OF-T summary query
|
||
fracto.submit(
|
||
("Arm A free-running baseline complete. "
|
||
"Arm T market injection beginning now. "
|
||
"Three channels: trade_count at centre (512,512), "
|
||
"taker_buy at x=400, taker_sell at x=624, each as "
|
||
"rolling-500min z-score capped ±1. "
|
||
"Summarise what you observed during arm A. "
|
||
"What is the baseline field character?"),
|
||
{"event": "arm_T_start", "thresholds": thresholds,
|
||
"arm_A_rows": int(len(arm_A_clean))},
|
||
)
|
||
|
||
arm_df = run_arm(
|
||
tel,
|
||
pub if arm == "T" else None,
|
||
arm, df,
|
||
fracto=fracto if arm == "T" else None,
|
||
thresholds=thresholds if arm == "T" else None,
|
||
)
|
||
outf = OUT_DIR / f"arm_{arm}_{'3ch' if arm=='T' else 'no_inject'}.parquet"
|
||
arm_df.to_parquet(outf)
|
||
log(f" saved {outf.name} ({len(arm_df)} rows)")
|
||
if arm == "A":
|
||
arm_A_telemetry = arm_df
|
||
elif arm == "T":
|
||
# END-OF-T summary query
|
||
fracto.submit(
|
||
("Arm T complete. Injection has been running for the full "
|
||
"April dataset. What patterns did you observe during the "
|
||
"injection period that were absent or different from the "
|
||
"arm A baseline? What field configurations appeared "
|
||
"repeatedly at HIGH TENSION moments?"),
|
||
{"event": "arm_T_end", "arm_T_rows": int(len(arm_df))},
|
||
)
|
||
log(f"\n=== ALL ARMS COMPLETE ===")
|
||
log(f"output: {OUT_DIR}")
|
||
log(f"draining FractoQuerier (timeout 600s) ... {fracto.stats()}")
|
||
fracto.drain(max_wait_s=600)
|
||
log(f"fracto drain complete {fracto.stats()}")
|
||
finally:
|
||
fracto.stop()
|
||
tel.stop()
|
||
pub.close(0)
|
||
ctx.term()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|