"""Strict-local temporal routing variants for the PAIR observability pilot. Both variants keep the frozen Local-ARCA policy body. A causal local history encoder can only change Local-ARCA's action-query router. PAIR-Belief differs from History-only solely by a training-time cosine target from the validated synchronized-action complementarity teacher. """ from __future__ import annotations import torch import torch.nn as nn import torch.nn.functional as F from stereo_decoder_variants import StereoARCA class StereoHistoryARCA(StereoARCA): def __init__(self, *args, history=32, history_stride=4, role_dim=64, **kwargs): state_dim = int(args[0] if args else kwargs["state_dim"]) super().__init__(*args, **kwargs) d = self.query.shape[-1] self.history_length = int(history) self.history_stride = int(history_stride) self.history_input = nn.Sequential( nn.LayerNorm(d + state_dim), nn.Linear(d + state_dim, d), nn.GELU(), ) self.history_gru = nn.GRU(d, d, num_layers=2, batch_first=True, dropout=.1) self.route_history = nn.Linear(d, d, bias=False) self.belief_head = nn.Sequential(nn.LayerNorm(d), nn.Linear(d, role_dim)) nn.init.zeros_(self.route_history.weight) self._online_history = None self.last_belief = None def reset_history(self): self._online_history = None def _encode_history(self, history): encoded = self.history_input(history) _, hidden = self.history_gru(encoded) summary = hidden[-1] self.last_belief = F.normalize(self.belief_head(summary), dim=-1) return summary def _online_window(self, observation, qpos): current = torch.cat((observation.mean(1).detach(), qpos.detach()), -1) if self._online_history is None or len(self._online_history) != len(current): self._online_history = [[] for _ in range(len(current))] windows = [] for index, row in enumerate(current): self._online_history[index].append(row) maximum = 1 + (self.history_length - 1) * self.history_stride self._online_history[index] = self._online_history[index][-maximum:] values = self._online_history[index][::-self.history_stride][::-1] pad = [torch.zeros_like(row) for _ in range(self.history_length - len(values))] windows.append(torch.stack(pad + values)) return torch.stack(windows) def _route_with_history(self, state, observation, history_summary, batch): q = self.query.expand(batch, -1, -1) context = (self.route_state(state) + self.route_observation(observation.mean(1)) + self.route_history(history_summary)) features = self.route_mlp(q + context.unsqueeze(1)) logits = torch.matmul(features, self.role_prototypes.t()) / features.shape[-1] ** .5 values, ids = logits.topk(2, dim=-1) gates = torch.zeros_like(logits).scatter_(-1, ids, values.softmax(-1).to(logits.dtype)) importance = logits.softmax(-1).mean((0, 1)) load = gates.gt(0).to(logits.dtype).mean((0, 1)) / 2.0 return gates, self.roles_n * (importance * load).sum() def forward(self, image, depth_mm, qpos, actions=None, history=None): x = self._rgbd_tokens(image, depth_mm) state_vec = self.state(qpos) if history is None: history = self._online_window(x, qpos) summary = self._encode_history(history.to(dtype=x.dtype)) gates, aux = self._route_with_history(state_vec, x, summary, image.shape[0]) if actions is not None: h = self.posterior(self.action(actions) + self.pos) mu, logvar = self.latent(h.mean(1)).chunk(2, -1) logvar = logvar.clamp(-10., 5.) z = mu + torch.randn_like(mu) * torch.exp(.5 * logvar) else: mu = logvar = None z = torch.zeros((image.shape[0], self.z_proj.in_features), device=image.device) memory = torch.cat((state_vec.unsqueeze(1), self.z_proj(z).unsqueeze(1), x), dim=1) decoded = self.decoder(self.query.expand(image.shape[0], -1, -1), memory, x, gates) return self.out(decoded), mu, logvar, aux, self.last_belief