Sonapse-LAB / synapse /experience.py
Kai9987kai's picture
feat: integrate frontier learning architectures, dormant lifecycle & 1000-neuron workbench
6158a59
Raw History Blame Contribute Delete
24.1 kB
"""Small learned experience model and conservative one-step action experiments.
Bootstrap RLS members learn *executed* transitions. Covariance leverage and
ensemble disagreement are coverage heuristics, not calibrated error bounds.
This CPU model is an experimental alternative to the standalone neural demos.
"""
from __future__ import annotations
from copy import deepcopy
import numpy as np
def _vector(value, size, name):
array = np.asarray(value, dtype=np.float64)
if array.shape != (size,) or not np.isfinite(array).all():
raise ValueError(f'{name} must be finite shape ({size},)')
if np.max(np.abs(array), initial=0) > 1e6:
raise ValueError(f'{name} exceeds numeric bound')
return array
def encode_experience_state(obs, features=None):
"""Summarize visible rays, motion, cues and actual graph/donor activity.
No world coordinates, task labels, hidden targets or future outcomes enter.
The summary is deliberately lossy and has no Markov-state guarantee.
"""
obs = _vector(obs, 155, 'observation')
features = np.zeros(64) if features is None else _vector(features, 64, 'features')
depth = obs[:33]
red, green, _ = obs[33:132].reshape(3, 33)
summary = [float(part.mean()) for signal in (depth, depth * green, depth * red)
for part in np.array_split(signal, 3)]
summary.extend([obs[136], obs[137], obs[149] - obs[150],
obs[151] - obs[152], obs[154],
features[:32].mean(), features[32:48].mean()])
return np.clip(np.asarray(summary, dtype=np.float64), -2, 2)
class TDValueModel:
"""Online TD(0) value estimator over encoded state coordinates.
Eliminates finite-horizon truncation bias in multi-step planning (TD-MPC / Hansen et al.).
"""
def __init__(self, state_dim=16, gamma=0.95, lr=0.05, seed=7):
self.state_dim = state_dim
self.gamma = float(gamma)
self.lr = float(lr)
self.dim = 1 + 2 * state_dim
self.weights = np.zeros(self.dim, dtype=np.float64)
self.samples = 0
self.td_error_ema = 0.0
def _features(self, state):
s = _vector(state, self.state_dim, 'state')
return np.concatenate([[1.0], s, np.clip(s ** 2, -100, 100)])
def predict(self, state):
phi = self._features(state)
val = float(phi @ self.weights)
return float(np.clip(val, -20.0, 20.0))
def update(self, state, reward, next_state, done=False):
phi = self._features(state)
v_current = float(phi @ self.weights)
v_next = 0.0 if done else self.predict(next_state)
target = float(reward) + self.gamma * v_next
td_error = target - v_current
self.weights += self.lr * td_error * phi
norm = float(np.linalg.norm(self.weights))
if norm > 100.0:
self.weights *= 100.0 / norm
self.samples += 1
rate = 1.0 if self.samples == 1 else 0.05
self.td_error_ema += rate * (td_error ** 2 - self.td_error_ema)
return {'td_error': float(td_error), 'value_estimate': v_current}
def export_state(self):
return {'schema': 1, 'state_dim': self.state_dim, 'gamma': self.gamma,
'lr': self.lr, 'weights': self.weights.copy(),
'samples': self.samples, 'td_error_ema': float(self.td_error_ema)}
def import_state(self, state):
if not isinstance(state, dict) or state.get('schema') != 1:
raise ValueError('invalid TDValueModel schema')
self.state_dim = int(state['state_dim'])
self.gamma = float(state['gamma'])
self.lr = float(state['lr'])
self.dim = 1 + 2 * self.state_dim
self.weights = np.asarray(state['weights'], dtype=np.float64).copy()
self.samples = int(state['samples'])
self.td_error_ema = float(state.get('td_error_ema', 0.0))
class ExperienceWorldModel:
"""Bounded-size bootstrap linear models with state/action interactions.
The feature basis contains bias, state, action, state×action and action².
RLS updates a regularized least-squares model with forgetting and a stabilizing
covariance trace cap; Poisson(1) weights provide deterministic bootstrap members.
Next-state deltas are learned alongside immediate rewards. The original
``plan`` method remains a one-step control; ExperienceMPC separately permits
short bounded rollouts.
"""
def __init__(self, state_dim=16, action_dim=5, ensemble_size=3, seed=7,
ridge=0.25, forgetting=0.995, min_samples=24,
action_radius=0.35, uncertainty_penalty=0.25, context_dim=0):
for name, value, maximum in [('state_dim', state_dim, 64),
('action_dim', action_dim, 16),
('ensemble_size', ensemble_size, 8)]:
if type(value) is not int or not 1 <= value <= maximum:
raise ValueError(f'{name} outside supported bounds')
if type(seed) is not int or not 0 <= seed < 2**31:
raise ValueError('seed must be a nonnegative 31-bit integer')
if type(context_dim) is not int or not 0 <= context_dim < state_dim:
raise ValueError('context_dim must leave at least one predicted state dimension')
if (not np.isfinite([ridge, forgetting, action_radius, uncertainty_penalty]).all()
or not 0.001 <= ridge <= 100 or not 0.9 <= forgetting <= 1
or not 0 < action_radius <= 0.35 or not 0 <= uncertainty_penalty <= 10
or type(min_samples) is not int or not 1 <= min_samples <= 10000):
raise ValueError('invalid experience-model hyperparameters')
self.state_dim = state_dim; self.action_dim = action_dim
self.ensemble_size = ensemble_size
self.context_dim = context_dim
self.predicted_dim = state_dim - context_dim
self.ridge = float(ridge); self.forgetting = float(forgetting)
self.min_samples = min_samples; self.action_radius = float(action_radius)
self.uncertainty_penalty = float(uncertainty_penalty)
self.dimension = 1 + state_dim + 2 * action_dim + state_dim * action_dim
self.weights = np.zeros((ensemble_size, self.dimension, self.predicted_dim + 1))
self.covariance = np.repeat((np.eye(self.dimension) / ridge)[None], ensemble_size, axis=0)
self.rng = np.random.default_rng(seed)
self.value_model = TDValueModel(state_dim=self.state_dim, seed=seed)
self.samples = 0; self.member_updates = np.zeros(ensemble_size, dtype=np.int64)
self.reward_error_ema = 0.0; self.delta_error_ema = 0.0
self.delta_baseline_error_ema = 0.0
self.reward_mean = 0.0; self.baseline_error_ema = 0.0
self.reward_squared_error = 0.0; self.delta_squared_error = 0.0
self.delta_baseline_squared_error = 0.0
self.terminals = 0
def _actions(self, actions):
actions = np.asarray(actions, dtype=np.float64)
if actions.ndim == 1:
actions = actions[None, :]
if (actions.ndim != 2 or actions.shape[1] != self.action_dim
or not 1 <= len(actions) <= 256 or not np.isfinite(actions).all()
or np.max(np.abs(actions), initial=0) > 1.000001):
raise ValueError('actions must be finite bounded [N, action_dim], N in 1..256')
return np.clip(actions, -1, 1)
def _features(self, state, actions):
state = _vector(state, self.state_dim, 'state')
if np.max(np.abs(state), initial=0) > 10:
raise ValueError('state must be encoded within [-10,10]')
actions = self._actions(actions)
states = np.broadcast_to(state, (len(actions), self.state_dim))
interactions = (states[:, :, None] * actions[:, None, :]).reshape(len(actions), -1)
return np.concatenate([np.ones((len(actions), 1)), states, actions,
interactions, actions ** 2], axis=1)
def predict(self, state, actions):
"""Pure read; returns one reward/delta/coverage per candidate action."""
phi = self._features(state, actions)
predictions = np.einsum('nf,efk->enk', phi, self.weights, optimize=True)
leverage = np.einsum('nf,efg,ng->en', phi, self.covariance, phi, optimize=True)
mean = predictions.mean(axis=0)
delta = np.zeros((len(phi), self.state_dim))
delta[:, :self.predicted_dim] = mean[:, :-1]
return {'reward': mean[:, -1].copy(), 'delta': delta,
'disagreement': predictions[:, :, -1].std(axis=0),
'coverage': np.sqrt(np.maximum(leverage.mean(axis=0), 0))}
def predict_members(self, state, actions):
"""Pure member predictions, with shapes [ensemble, candidates, ...].
``state`` may be one encoded state or one separate state per ensemble
member. The latter keeps a member's own dynamics trajectory intact in
multi-step imagination. Conditioning coordinates always have zero delta.
Coverage is covariance leverage, not a calibrated confidence interval.
"""
states = np.asarray(state, dtype=np.float64)
actions = self._actions(actions)
if states.shape == (self.state_dim,):
phi = np.broadcast_to(self._features(states, actions),
(self.ensemble_size, len(actions), self.dimension))
elif states.shape == (self.ensemble_size, self.state_dim):
phi = np.stack([self._features(member_state, actions) for member_state in states])
else:
raise ValueError('state must have shape [state_dim] or [ensemble_size, state_dim]')
predictions = np.einsum('enf,efk->enk', phi, self.weights, optimize=True)
leverage = np.einsum('enf,efg,eng->en', phi, self.covariance, phi, optimize=True)
delta = np.zeros((self.ensemble_size, len(actions), self.state_dim))
delta[:, :, :self.predicted_dim] = predictions[:, :, :-1]
return {'reward': predictions[:, :, -1].copy(), 'delta': delta,
'coverage': np.sqrt(np.maximum(leverage, 0))}
def update(self, state, action, next_state, reward, done=False):
"""Learn only after an executed action; errors are measured before fitting."""
state = _vector(state, self.state_dim, 'state')
next_state = _vector(next_state, self.state_dim, 'next state')
if np.max(np.abs(next_state), initial=0) > 10:
raise ValueError('next state must be encoded within [-10,10]')
action = _vector(action, self.action_dim, 'action')
if not np.isscalar(reward) or not np.isfinite(reward) or abs(reward) > 1e6:
raise ValueError('reward must be a finite bounded scalar')
if type(done) not in (bool, np.bool_):
raise ValueError('done must be boolean')
phi = self._features(state, action)[0]
prediction = self.predict(state, action)
error = float(reward - prediction['reward'][0])
baseline_error = float(reward - self.reward_mean)
delta = (next_state - state)[:self.predicted_dim]
delta_error = float(np.mean((delta - prediction['delta'][0, :self.predicted_dim]) ** 2))
delta_baseline_error = float(np.mean(delta ** 2))
target = np.concatenate([delta, [float(reward)]])
# All validation precedes RNG consumption or learned-state mutation.
counts = self.rng.poisson(1.0, size=self.ensemble_size)
for member, count in enumerate(counts):
covariance = self.covariance[member]
if count == 0:
covariance = covariance / self.forgetting
else:
projected = covariance @ phi
denominator = self.forgetting / count + phi @ projected
gain = projected / denominator
residual = target - phi @ self.weights[member]
self.weights[member] += np.outer(gain, residual)
covariance = (covariance - np.outer(gain, projected)) / self.forgetting
self.member_updates[member] += 1
# Unexcited directions would otherwise grow exponentially in live runs.
trace = float(np.trace(covariance))
if trace > 10000:
covariance *= 10000 / trace
# Symmetrize numerical roundoff; no unbounded experience buffer.
self.covariance[member] = (covariance + covariance.T) * 0.5
self.samples += 1; self.terminals += int(done)
rate = 1.0 if self.samples == 1 else 0.05
self.reward_error_ema += rate * (error ** 2 - self.reward_error_ema)
self.baseline_error_ema += rate * (baseline_error ** 2 - self.baseline_error_ema)
self.reward_mean += rate * (float(reward) - self.reward_mean)
self.delta_error_ema += rate * (delta_error - self.delta_error_ema)
self.delta_baseline_error_ema += rate * (delta_baseline_error - self.delta_baseline_error_ema)
self.reward_squared_error += error ** 2
self.delta_squared_error += delta_error
self.delta_baseline_squared_error += delta_baseline_error
self.value_model.update(state, float(reward), next_state, done=bool(done))
return {'prediction_error': error, 'reward_squared_error': error ** 2,
'delta_squared_error': delta_error, 'delta_baseline_squared_error': delta_baseline_error,
'samples': self.samples,
'member_updates': int(counts.astype(bool).sum())}
def plan(self, state, baseline, memory=None):
"""Rank local candidates; abstain without support or covered predicted gain."""
baseline = _vector(baseline, self.action_dim, 'baseline')
self._actions(baseline)
_vector(state, self.state_dim, 'state')
diagnostics = {'used': False, 'reason': 'insufficient support', 'samples': self.samples,
'candidate_count': 1, 'predicted_gain': 0.0,
'mean_disagreement': 0.0, 'max_action_change': 0.0,
'memory_used': False}
if self.samples < self.min_samples or np.min(self.member_updates) < 4:
return baseline.astype(np.float32).copy(), diagnostics
# Measured before-update errors must improve on a running reward average.
# This is a support-data heuristic, not a significance or safety test.
if not self._prediction_gate():
diagnostics['reason'] = 'prediction has not beaten reward baseline'
return baseline.astype(np.float32).copy(), diagnostics
candidates = [baseline.copy()]
for index in range(self.action_dim):
for direction in (-1, 1):
candidate = baseline.copy()
candidate[index] = np.clip(candidate[index] + direction * self.action_radius, -1, 1)
candidates.append(candidate)
actions = np.asarray(candidates)
prediction = self.predict(state, actions)
reward = prediction['reward'].copy()
if memory is not None:
recalled = memory.retrieve(state, actions)
# Memory needs local coverage and remains a correction, not a reward oracle.
confidence = np.clip(np.asarray(recalled['confidence'], dtype=float), 0, 1) * 0.5
reward = (1 - confidence) * reward + confidence * recalled['reward']
diagnostics['memory_used'] = bool(np.max(confidence) > 0.05)
scale = max(np.sqrt(max(self.reward_error_ema, 0)), 0.001)
uncertainty = prediction['disagreement'] + scale * prediction['coverage']
score = reward - self.uncertainty_penalty * uncertainty
# Extremely unsupported alternatives are excluded even when the ensemble agrees.
score[prediction['coverage'] > max(2.0, 2 * prediction['coverage'][0])] = -np.inf
score[0] = reward[0] - self.uncertainty_penalty * uncertainty[0]
phi = self._features(state, actions)
difference = phi - phi[0]
advantage_coverage = np.sqrt(np.maximum(np.einsum(
'nf,efg,ng->en', difference, self.covariance, difference, optimize=True).mean(axis=0), 0))
# Penalize uncertainty of the *change* rather than assuming cancellation
# of two uncertain reward predictions. The multiplier is an engineering
# margin, not a calibrated two-sigma bound.
plausible_gain = reward - reward[0]
required_gain = 2 * (prediction['disagreement'] + prediction['disagreement'][0]
+ scale * advantage_coverage)
score[(plausible_gain <= required_gain) & (np.arange(len(actions)) != 0)] = -np.inf
best = int(np.argmax(score))
gain = float(score[best] - score[0])
if gain <= max(0.0001, scale * 0.01):
best = 0
action = actions[best].astype(np.float32)
diagnostics.update(used=best != 0, reason='covered predicted gain' if best else 'no covered gain',
candidate_count=len(actions), predicted_gain=max(gain, 0),
mean_disagreement=float(prediction['disagreement'].mean()),
max_action_change=float(np.max(np.abs(actions[best] - baseline))))
return action, diagnostics
def _prediction_gate(self):
return bool(self.samples >= self.min_samples
and self.baseline_error_ema > 1e-12
and self.reward_error_ema < 0.95 * self.baseline_error_ema)
def _dynamics_gate(self):
"""Prequential delta error must beat holding the current state fixed."""
return bool(self.samples >= self.min_samples
and self.delta_baseline_error_ema > 1e-12
and self.delta_error_ema < 0.95 * self.delta_baseline_error_ema)
def summary(self):
return {'samples': self.samples, 'ensemble_size': self.ensemble_size,
'predicted_state_dimensions': self.predicted_dim, 'conditioning_dimensions': self.context_dim,
'member_updates': self.member_updates.tolist(), 'terminals': self.terminals,
'prequential_reward_rmse': float(np.sqrt(self.reward_squared_error / max(1, self.samples))),
'recent_reward_rmse': float(np.sqrt(max(self.reward_error_ema, 0))),
'reward_baseline_rmse': float(np.sqrt(max(self.baseline_error_ema, 0))),
'prediction_gate_passed': self._prediction_gate(),
'prequential_delta_rmse': float(np.sqrt(self.delta_squared_error / max(1, self.samples))),
'recent_delta_rmse': float(np.sqrt(max(self.delta_error_ema, 0))),
'delta_no_change_baseline_rmse': float(np.sqrt(max(self.delta_baseline_error_ema, 0))),
'prequential_delta_no_change_baseline_rmse': float(np.sqrt(self.delta_baseline_squared_error / max(1, self.samples))),
'dynamics_gate_passed': self._dynamics_gate(),
'value_samples': self.value_model.samples,
'value_td_error_rmse': float(np.sqrt(max(self.value_model.td_error_ema, 0.0))),
'learned_bytes': int(self.weights.nbytes + self.covariance.nbytes),
'covariance_trace_cap': 10000,
'scope': 'bootstrap RLS; heuristic coverage; immediate reward and state deltas; conditioning dynamics excluded; online TD value estimation; experimental'}
def export_state(self):
return deepcopy({'schema': 1, 'state_dim': self.state_dim, 'action_dim': self.action_dim,
'ensemble_size': self.ensemble_size, 'ridge': self.ridge,
'context_dim': self.context_dim,
'forgetting': self.forgetting, 'min_samples': self.min_samples,
'action_radius': self.action_radius, 'uncertainty_penalty': self.uncertainty_penalty,
'weights': self.weights, 'covariance': self.covariance,
'member_updates': self.member_updates, 'rng': self.rng.bit_generator.state,
'samples': self.samples, 'terminals': self.terminals,
'reward_error_ema': self.reward_error_ema, 'delta_error_ema': self.delta_error_ema,
'reward_mean': self.reward_mean, 'baseline_error_ema': self.baseline_error_ema,
'reward_squared_error': self.reward_squared_error,
'delta_squared_error': self.delta_squared_error,
'delta_baseline_error_ema': self.delta_baseline_error_ema,
'delta_baseline_squared_error': self.delta_baseline_squared_error,
'value_model': self.value_model.export_state()})
def import_state(self, state):
"""Validate fully before replacing the resident model."""
if not isinstance(state, dict) or state.get('schema') != 1:
raise ValueError('invalid experience state schema')
candidate = ExperienceWorldModel(context_dim=state.get('context_dim', 0), **{key: state[key] for key in (
'state_dim', 'action_dim', 'ensemble_size', 'ridge', 'forgetting',
'min_samples', 'action_radius', 'uncertainty_penalty')})
if (candidate.state_dim, candidate.action_dim, candidate.ensemble_size, candidate.context_dim) != (
self.state_dim, self.action_dim, self.ensemble_size, self.context_dim):
raise ValueError('experience state dimensions mismatch')
for key in ('weights', 'covariance', 'member_updates'):
value = np.asarray(state[key])
if value.shape != getattr(candidate, key).shape or not np.isfinite(value).all():
raise ValueError(f'invalid experience {key}')
if key == 'member_updates' and (not np.issubdtype(value.dtype, np.integer) or np.any(value < 0)):
raise ValueError('experience member updates must be nonnegative integers')
setattr(candidate, key, value.astype(getattr(candidate, key).dtype, copy=True))
if (np.any(candidate.member_updates < 0)
or not np.allclose(candidate.covariance, candidate.covariance.transpose(0, 2, 1), atol=1e-8)
or np.min(np.linalg.eigvalsh(candidate.covariance)) <= 0):
raise ValueError('invalid experience covariance or update counts')
for key in ('samples', 'terminals'):
if type(state[key]) is not int or state[key] < 0:
raise ValueError(f'invalid experience {key}')
setattr(candidate, key, state[key])
if candidate.terminals > candidate.samples or np.any(candidate.member_updates > candidate.samples):
raise ValueError('experience counters exceed observed samples')
for key in ('reward_error_ema', 'delta_error_ema', 'reward_squared_error', 'delta_squared_error'):
if not np.isscalar(state[key]) or not np.isfinite(state[key]) or state[key] < 0:
raise ValueError(f'invalid experience {key}')
setattr(candidate, key, float(state[key]))
mean = state.get('reward_mean', 0.0)
baseline_error = state.get('baseline_error_ema', 0.0)
if not np.isscalar(mean) or not np.isfinite(mean) or not np.isscalar(baseline_error) or not np.isfinite(baseline_error) or baseline_error < 0:
raise ValueError('invalid reward baseline state')
candidate.reward_mean = float(mean); candidate.baseline_error_ema = float(baseline_error)
for key in ('delta_baseline_error_ema', 'delta_baseline_squared_error'):
value = state.get(key, 0.0)
if not np.isscalar(value) or not np.isfinite(value) or value < 0:
raise ValueError(f'invalid experience {key}')
setattr(candidate, key, float(value))
if 'value_model' in state:
candidate.value_model.import_state(state['value_model'])
candidate.rng.bit_generator.state = deepcopy(state['rng'])
self.__dict__.update(candidate.__dict__)