Files
DOLPHIN/nautilus_dolphin/dvae/titan_sensor.py
hjnormey 01c19662cb initial: import DOLPHIN baseline 2026-04-21 from dolphinng5_predict working tree
Includes core prod + GREEN/BLUE subsystems:
- prod/ (BLUE harness, configs, scripts, docs)
- nautilus_dolphin/ (GREEN Nautilus-native impl + dvae/ preserved)
- adaptive_exit/ (AEM engine + models/bucket_assignments.pkl)
- Observability/ (EsoF advisor, TUI, dashboards)
- external_factors/ (EsoF producer)
- mc_forewarning_qlabs_fork/ (MC regime/envelope)

Excludes runtime caches, logs, backups, and reproducible artifacts per .gitignore.
2026-04-21 16:58:38 +02:00

234 lines
9.8 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
TitanSensor — Non-invasive latent state extractor (Stage 1, read-only).
Architecture notes
------------------
The LSTM weights (W_ih, W_hh) are fixed random projections used as the
encoder basis during training. W_mu/W_logvar/W_dec/W_out were trained to
map the output of THESE specific W_ih/W_hh matrices. Loading the correct
W_ih/W_hh is therefore mandatory for valid reconstruction errors.
Models saved before 2026-03-15 did not persist W_ih/W_hh — those fall back
to legacy seed=42 re-init (meaningless recon_err ~10^14) and are flagged.
T5 oracle-leakage fix
---------------------
Training included T5 dims [111:261] = spectral path coefficients derived from
FUTURE prices [t+1, t+51]. At inference we zero those dims unconditionally.
Normalisation (GD-v2 models, training >= 2026-03-15)
------------------------------------------------------
GD-v2 models store per-feature z-score stats (norm_mean, norm_std) computed
from the raw training corpus. encode() applies v = (v - norm_mean)/norm_std
before the LSTM step so that training and inference see the same distribution.
Models without norm_mean (legacy) skip this step — recon_err is in raw-feature
space and remains valid for relative comparison within that model.
"""
import json
import numpy as np
class TitanSensor:
INPUT_DIM = 261
HIDDEN_DIM = 128
LATENT_DIM = 32
T5_START = 111 # dims [111:261] = spectral (oracle) → always zero
def __init__(self, model_path: str, lstm_seed: int = 42):
with open(model_path) as f:
m = json.load(f)
def _arr(key, default_shape):
return np.array(m[key]) if key in m else np.zeros(default_shape)
self.W_mu = _arr('W_mu', (self.HIDDEN_DIM, self.LATENT_DIM))
self.W_logvar = _arr('W_logvar', (self.HIDDEN_DIM, self.LATENT_DIM))
self.W_dec = _arr('W_dec', (self.LATENT_DIM, self.HIDDEN_DIM))
self.W_out = _arr('W_out', (self.HIDDEN_DIM, self.INPUT_DIM))
self.b_mu = _arr('b_mu', (self.LATENT_DIM,))
self.b_logvar = _arr('b_logvar', (self.LATENT_DIM,))
self.b_dec = _arr('b_dec', (self.HIDDEN_DIM,))
self.b_out = _arr('b_out', (self.INPUT_DIM,))
# LSTM weights: load from JSON if present (fixed since 2026-03-15).
# Legacy models (no W_ih in JSON) fall back to seed=42 re-init —
# recon_err will be ~10^14 for those; a warning is emitted.
if 'W_ih' in m:
self.W_ih = np.array(m['W_ih'], dtype=np.float64)
self.W_hh = np.array(m['W_hh'], dtype=np.float64)
self.b_h = np.array(m['b_h'], dtype=np.float64)
self.lstm_weights_valid = True
else:
import warnings
warnings.warn(
f"TitanSensor: model at '{model_path}' does not contain LSTM weights "
"(W_ih/W_hh/b_h). Falling back to seed=42 re-init. "
"recon_err will be ~10^14 (meaningless). Retrain with fixed save_model().",
RuntimeWarning, stacklevel=2,
)
rng = np.random.RandomState(lstm_seed)
s = 0.1
self.W_ih = rng.randn(self.INPUT_DIM, self.HIDDEN_DIM * 4) * s
self.W_hh = rng.randn(self.HIDDEN_DIM, self.HIDDEN_DIM * 4) * s
self.b_h = np.zeros(self.HIDDEN_DIM * 4)
self.lstm_weights_valid = False
self.latent_names = {int(k): v for k, v in m.get('latent_names', {}).items()}
# Per-feature normalisation stats (present in GD-v2 models trained 2026-03-15+).
# If absent (legacy models): no normalization applied — recon_err will be in
# raw-feature space and is still valid for relative comparison within that model.
if 'norm_mean' in m:
self.norm_mean = np.array(m['norm_mean'], dtype=np.float64)
self.norm_std = np.array(m['norm_std'], dtype=np.float64)
else:
self.norm_mean = None
self.norm_std = None
# ------------------------------------------------------------------
def _sigmoid(self, x):
return 1.0 / (1.0 + np.exp(-np.clip(x, -500, 500)))
def _lstm_step(self, x, h, c):
g = x @ self.W_ih + h @ self.W_hh + self.b_h
i_, f_, g_, o_ = np.split(g, 4, axis=-1)
i_ = self._sigmoid(i_); f_ = self._sigmoid(f_); o_ = self._sigmoid(o_)
g_ = np.tanh(g_)
c2 = f_ * c + i_ * g_
h2 = o_ * np.tanh(c2)
return h2, c2
# ------------------------------------------------------------------
def encode(self, x: np.ndarray):
"""
Encode one 261-dim feature row.
Returns
-------
z_mu : np.ndarray (32,) — latent mean
recon_err : float — MSE on T0-T4 reconstruction (in normalised space)
z_logvar : np.ndarray (32,) — per-dim log-variance
"""
v = np.array(x, dtype=np.float64).ravel()
v = np.resize(v, self.INPUT_DIM) # pad/truncate
# Stage-1 zero: T3 (ExF, 78-102), T4 (esoteric, 103-110) and T5 (oracle, 111+)
# are always zero in the training corpus — enforce this here so that
# callers not using build_feature_vector() don't blow up normalization.
v[78:] = 0.0
v[self.T5_START:] = 0.0 # T5 oracle fix (redundant but explicit)
v = np.nan_to_num(v, nan=0.0, posinf=0.0, neginf=0.0)
# Apply stored per-feature normalisation (GD-v2 models only).
# This reproduces the same transform applied during training so that
# the LSTM sees the same input distribution and recon_err is O(1).
if self.norm_mean is not None:
v = (v - self.norm_mean) / self.norm_std
v[78:] = 0.0 # re-zero Stage-1 dims after norm
# Clip to [-50, 50] for safety — training post-norm max was ~37
v = np.clip(v, -50.0, 50.0)
h = np.zeros((1, self.HIDDEN_DIM))
c = np.zeros((1, self.HIDDEN_DIM))
h, c = self._lstm_step(v.reshape(1, -1), h, c)
z_mu = (h @ self.W_mu + self.b_mu)[0]
z_logvar = (h @ self.W_logvar + self.b_logvar)[0]
h_dec = np.tanh(z_mu @ self.W_dec + self.b_dec)
recon = h_dec @ self.W_out + self.b_out
recon_err = float(np.mean((recon[:self.T5_START] - v[:self.T5_START]) ** 2))
return z_mu, recon_err, z_logvar
# ------------------------------------------------------------------
# Feature builder — constructs a 261-dim vector from a parquet row.
# T3 (ExF, 78-102), T4 (esoteric, 103-110), T5 (111-260) are zeroed.
# ------------------------------------------------------------------
_ASSET_CACHE = {} # parquet-path → ordered asset list
def build_feature_vector(df, ri: int, assets: list) -> np.ndarray:
"""
Build 261-dim T0-T4 feature vector (T5 always zero).
T0 (0-7) : time encoding + rolling breadth
T1 (8-27) : eigenvalue velocity features (4 windows × 5 dims)
T2 (28-77) : per-asset return z-scores (up to 50 assets, rolling 50 bars)
T3 (78-102): ExF macro — zeroed (Stage 1)
T4 (103-110): esoteric — zeroed (Stage 1)
T5 (111-260): spectral — zeroed (oracle fix)
"""
x = np.zeros(261, dtype=np.float64)
row = df.iloc[ri]
# --- T0: time encoding ---
try:
ts = row['timestamp']
if hasattr(ts, 'hour'):
h = ts.hour + ts.minute / 60.0
d = ts.dayofweek
else:
import pandas as pd
ts = pd.Timestamp(ts)
h = ts.hour + ts.minute / 60.0
d = ts.dayofweek
x[0] = np.sin(2 * np.pi * h / 24)
x[1] = np.cos(2 * np.pi * h / 24)
x[2] = np.sin(2 * np.pi * d / 7)
x[3] = np.cos(2 * np.pi * d / 7)
except Exception:
pass
x[4] = 1.0 # has_eigen always true for NG3
# rolling BTC breadth (T0 dims 5-7)
if ri >= 20 and 'BTCUSDT' in df.columns:
diffs = np.diff(df['BTCUSDT'].values[ri - 20:ri + 1])
x[5] = float(np.mean(diffs > 0))
x[6] = float(np.mean(diffs < 0))
x[7] = float(np.mean(diffs == 0))
# --- T1: eigenvalue velocity features ---
def _g(col):
v = row.get(col, 0.0)
return float(v) if v is not None and v == v else 0.0
v50 = _g('v50_lambda_max_velocity')
v150 = _g('v150_lambda_max_velocity')
v300 = _g('v300_lambda_max_velocity')
v750 = _g('v750_lambda_max_velocity')
i50 = _g('instability_50')
i150 = _g('instability_150')
vd = _g('vel_div')
# window-0 (8-12): v50 group
x[8] = v50; x[9] = i50; x[10] = v50 - v150
x[11] = v50 - v300; x[12] = v50 - v750
# window-1 (13-17): v150 group
x[13] = v150; x[14] = i150; x[15] = v150 - v300
x[16] = v150 - v750; x[17] = abs(v150)
# window-2 (18-22): v300 group
x[18] = v300; x[19] = abs(v300); x[20] = v300 - v750
x[21] = v50 * v300; x[22] = i50 - v750 # proxy_B equivalent
# window-3 (23-27): v750 + composite
x[23] = v750; x[24] = abs(v750)
x[25] = v50 / (abs(v750) + 1e-8)
x[26] = i50 - i150; x[27] = vd
# --- T2: per-asset return z-scores ---
if ri >= 50:
prices_cache = {a: df[a].values for a in assets if a in df.columns}
for j, asset in enumerate(assets[:50]):
if asset not in prices_cache:
continue
seg = prices_cache[asset][ri - 50:ri + 1]
if np.any(seg <= 0) or np.any(~np.isfinite(seg)):
continue
rets = np.diff(seg) / seg[:-1]
std = np.std(rets)
if std > 0:
x[28 + j] = float(np.clip((rets[-1] - np.mean(rets)) / std, -5.0, 5.0))
return x