""" WaveSystemGraphParser v4.7b protected real+synthetic core-locked: core-attached support topology graph parser for ICWDS =================================================================== Purpose ------- This model is the next-stage frontend after WaveSystemParser v3.x. It does not ask a CNN to draw wave-system masks from scratch. Instead: E(f,theta) -> physical peak/basin proposals generated outside the model -> node/edge graph reasoning over proposals -> learned merge / keep / slot assembly -> light CNN boundary refinement This treats watershed-like basins as over-segmentation proposals, not labels. The learnable part decides which candidates are physical wave systems, which should be merged, and which should be sent to the downstream self-pruning VAE. v4.5 is designed for a double-layer physical teacher: peak-core proposals define system identity/count, while valley-constrained support proposals recover the full energetic wave-system footprint. Stripe-like bands can be attached as tails but are not allowed to become independent systems without a peak core. All operations are lightweight and Colab-friendly. No Transformer blocks. """ import math from dataclasses import dataclass, asdict from typing import Optional, Dict import torch import torch.nn as nn import torch.nn.functional as F @dataclass class GraphParserV47Config: n_freqs: int = 47 n_dirs: int = 72 n_slots: int = 6 bg_index: int = 6 p_max: int = 18 prop_feat_dim: int = 22 width: int = 32 depth: int = 4 node_dim: int = 48 edge_dim: int = 64 pair_feat_dim: int = 8 use_coord: bool = True use_physics: bool = True rank_temp: float = 0.12 count_min: float = 1.0 count_max: float = 6.0 bg_prior_bias: float = 0.38 bg_energy_suppress: float = 14.5 bg_energy_gamma: float = 0.70 proposal_logit_gain: float = 5.25 def to_dict(self): return asdict(self) class DepthwiseSeparable(nn.Module): def __init__(self, ci: int, co: int): super().__init__() self.dw = nn.Conv2d(ci, ci, 3, padding=1, groups=ci, bias=False) self.pw = nn.Conv2d(ci, co, 1, bias=False) self.norm = nn.GroupNorm(min(8, co), co) self.act = nn.GELU() def forward(self, x): return self.act(self.norm(self.pw(self.dw(x)))) class PhysicsAwareModule(nn.Module): def __init__(self, ch: int, n_dirs: int): super().__init__() self.n_dirs = n_dirs dirs = torch.linspace(0, 2 * math.pi, n_dirs + 1)[:n_dirs] self.register_buffer("cos_d", torch.cos(dirs).view(1, 1, 1, n_dirs)) self.register_buffer("sin_d", torch.sin(dirs).view(1, 1, 1, n_dirs)) self.fuse = nn.Conv2d(ch + 3, ch, 1, bias=False) self.norm = nn.GroupNorm(min(8, ch), ch) self.act = nn.GELU() def _phys_features(self, E): eps = 1e-8 En = torch.nan_to_num(E, nan=0.0, posinf=1.0, neginf=0.0).clamp_min(0) En = En / (En.amax(dim=(2, 3), keepdim=True) + eps) nf = En.shape[2] rev_cumsum = torch.flip(torch.cumsum(torch.flip(En, dims=[2]), dim=2), dims=[2]) col_sum = En.sum(dim=2, keepdim=True) + eps hf_tail = rev_cumsum / col_sum cx = (En * self.cos_d).sum(dim=3, keepdim=True) cy = (En * self.sin_d).sum(dim=3, keepdim=True) row_sum = En.sum(dim=3, keepdim=True) + eps dir_conc = torch.sqrt(cx ** 2 + cy ** 2) / row_sum dir_conc = dir_conc.expand(-1, -1, -1, self.n_dirs) fcoord = torch.linspace(0, 1, nf, device=E.device, dtype=E.dtype).view(1, 1, nf, 1) fc = (En * fcoord).sum(dim=2, keepdim=True) / col_sum spread = torch.sqrt(((En * (fcoord - fc) ** 2).sum(dim=2, keepdim=True)) / col_sum) spread = spread.expand(-1, -1, nf, -1) return torch.cat([hf_tail, dir_conc, spread], dim=1) def forward(self, h, E): return self.act(self.norm(self.fuse(torch.cat([h, self._phys_features(E)], dim=1)))) class WaveSystemGraphParserV47(nn.Module): """Proposal graph parser. forward inputs -------------- x: [B,1,47,72], normalized to [-1,1] prop_masks: [B,P,47,72], binary/soft physical basin proposals prop_feats: [B,P,F], proposal features produced by the training script prop_valid: [B,P], 1 if proposal exists outputs include final probability masks, node slot assignments, edge logits, count prediction, and diagnostics. """ def __init__(self, cfg: Optional[GraphParserV47Config] = None): super().__init__() self.cfg = cfg or GraphParserV47Config() c = self.cfg in_ch = 1 + (3 if c.use_coord else 0) self.stem = nn.Conv2d(in_ch, c.width, 3, padding=1) self.stem_norm = nn.GroupNorm(min(8, c.width), c.width) self.physics = PhysicsAwareModule(c.width, c.n_dirs) if c.use_physics else None self.blocks = nn.ModuleList([DepthwiseSeparable(c.width, c.width) for _ in range(c.depth)]) self.global_pool = nn.AdaptiveAvgPool2d(1) self.residual_head = nn.Conv2d(c.width, c.n_slots + 1, 1) nn.init.zeros_(self.residual_head.weight) nn.init.zeros_(self.residual_head.bias) self.node_mlp = nn.Sequential( nn.Linear(c.width + c.prop_feat_dim, c.node_dim), nn.GELU(), nn.Linear(c.node_dim, c.node_dim), nn.GELU(), ) self.node_keep = nn.Linear(c.node_dim, 1) self.node_slot = nn.Linear(c.node_dim, c.n_slots) self.count_head = nn.Sequential( nn.Linear(c.width + c.node_dim, c.width), nn.GELU(), nn.Linear(c.width, c.n_slots) ) # Edge head uses node_i, node_j, absolute difference, product, plus handcrafted physical pair features. self.edge_head = nn.Sequential( nn.Linear(4 * c.node_dim + c.pair_feat_dim, c.edge_dim), nn.GELU(), nn.Linear(c.edge_dim, c.edge_dim), nn.GELU(), nn.Linear(c.edge_dim, 1) ) self._coord_cache = None @staticmethod def _clean_tensor(x, fill=0.0, lo=-30.0, hi=30.0): return torch.nan_to_num(x, nan=fill, posinf=hi, neginf=lo).clamp(lo, hi) @staticmethod def _safe_softmax(logits, dim): logits = torch.nan_to_num(logits, nan=0.0, posinf=30.0, neginf=-30.0).clamp(-30.0, 30.0) logits = logits - logits.max(dim=dim, keepdim=True).values.detach() p = torch.softmax(logits, dim=dim) return torch.nan_to_num(p, nan=0.0, posinf=1.0, neginf=0.0) def _coord_channels(self, B, device, dtype): c = self.cfg if self._coord_cache is None: nf, nd = c.n_freqs, c.n_dirs fcoord = torch.linspace(0, 1, nf).view(1, 1, nf, 1).expand(1, 1, nf, nd) ang = torch.linspace(0, 2 * math.pi, nd + 1)[:nd].view(1, 1, 1, nd).expand(1, 1, nf, nd) self._coord_cache = torch.cat([fcoord, torch.sin(ang), torch.cos(ang)], dim=1) return self._coord_cache.to(device=device, dtype=dtype).expand(B, -1, -1, -1) def _rank_prune_presence(self, slot_mass, count_soft): c = self.cfg B, K = slot_mass.shape _, order = torch.sort(slot_mass, dim=1, descending=True) rank_pos = torch.arange(1, K + 1, device=slot_mass.device, dtype=slot_mass.dtype).view(1, K) cs = count_soft.clamp(c.count_min, c.count_max).view(B, 1) gate_sorted = torch.sigmoid((cs + 0.5 - rank_pos) / c.rank_temp) gate = torch.zeros_like(gate_sorted).scatter(1, order, gate_sorted) return gate.clamp(0, 1) def _node_pool(self, h, prop_masks, prop_valid): # h: [B,C,H,W], prop_masks [B,P,H,W] B, C, H, W = h.shape P = prop_masks.shape[1] denom = prop_masks.flatten(2).sum(dim=2).clamp_min(1.0) # [B,P] pooled = torch.einsum("bchw,bphw->bpc", h, prop_masks) / denom[:, :, None] pooled = pooled * prop_valid[:, :, None] return pooled def _edge_logits(self, node, prop_feats, prop_valid): B, P, D = node.shape ni = node[:, :, None, :].expand(B, P, P, D) nj = node[:, None, :, :].expand(B, P, P, D) # Pair physical features from proposal features: distance in f/theta and mass contrast. # feat layout is defined in training script: mass, peak, area, mu_f, sin_t, cos_t, ... fi = prop_feats[:, :, 3][:, :, None] fj = prop_feats[:, :, 3][:, None, :] d_f = (fi - fj).abs() si = prop_feats[:, :, 4][:, :, None]; ci = prop_feats[:, :, 5][:, :, None] sj = prop_feats[:, :, 4][:, None, :]; cj = prop_feats[:, :, 5][:, None, :] dot = (si * sj + ci * cj).clamp(-1.0 + 1e-5, 1.0 - 1e-5) d_t = torch.acos(dot) / math.pi mi = prop_feats[:, :, 0][:, :, None] mj = prop_feats[:, :, 0][:, None, :] d_m = (mi - mj).abs() sf_i = prop_feats[:, :, 6][:, :, None]; sf_j = prop_feats[:, :, 6][:, None, :] st_i = prop_feats[:, :, 7][:, :, None]; st_j = prop_feats[:, :, 7][:, None, :] d_sf = (sf_i - sf_j).abs() d_st = (st_i - st_j).abs() prom_i = prop_feats[:, :, 12][:, :, None]; prom_j = prop_feats[:, :, 12][:, None, :] prom_min = torch.minimum(prom_i, prom_j) stripe_i = prop_feats[:, :, 13][:, :, None]; stripe_j = prop_feats[:, :, 13][:, None, :] stripe_max = torch.maximum(stripe_i, stripe_j) qual_i = prop_feats[:, :, 11][:, :, None]; qual_j = prop_feats[:, :, 11][:, None, :] qual_min = torch.minimum(qual_i, qual_j) pair_phys = torch.stack([d_f, d_t, d_m, d_sf, d_st, prom_min, stripe_max, qual_min], dim=-1) inp = torch.cat([ni, nj, (ni - nj).abs(), ni * nj, pair_phys], dim=-1) e = self.edge_head(inp).squeeze(-1) valid_pair = (prop_valid[:, :, None] * prop_valid[:, None, :]).bool() eye = torch.eye(P, device=node.device, dtype=torch.bool).view(1, P, P) e = e.masked_fill(~valid_pair | eye, 0.0) return e, valid_pair & (~eye) def forward(self, x, prop_masks, prop_feats, prop_valid, prior_w=2.5, residual_w=0.0): c = self.cfg B, _, H, W = x.shape prop_masks = torch.nan_to_num(prop_masks.float(), nan=0.0, posinf=0.0, neginf=0.0).clamp(0, 1) prop_feats = torch.nan_to_num(prop_feats.float(), nan=0.0, posinf=5.0, neginf=-5.0).clamp(-5.0, 5.0) prop_valid = prop_valid.float().clamp(0, 1) h_in = torch.cat([x, self._coord_channels(B, x.device, x.dtype)], dim=1) if c.use_coord else x h = self._clean_tensor(F.gelu(self.stem_norm(self.stem(h_in))), lo=-20.0, hi=20.0) E01 = torch.nan_to_num((x + 1.0) * 0.5, nan=0.0, posinf=1.0, neginf=0.0).clamp(0, 1) if self.physics is not None: h = self._clean_tensor(h + self.physics(h, E01), lo=-20.0, hi=20.0) for blk in self.blocks: h = self._clean_tensor(h + blk(h), lo=-20.0, hi=20.0) global_feat = self.global_pool(h).flatten(1) node_pool = self._node_pool(h, prop_masks, prop_valid) node_in = torch.cat([node_pool, prop_feats], dim=-1) node = self._clean_tensor(self.node_mlp(node_in), lo=-20.0, hi=20.0) * prop_valid[:, :, None] node_keep_logit = self._clean_tensor(self.node_keep(node).squeeze(-1), lo=-20.0, hi=20.0).masked_fill(prop_valid <= 0, -20.0) node_keep = torch.sigmoid(node_keep_logit) * prop_valid node_slot_logits = self._clean_tensor(self.node_slot(node), lo=-20.0, hi=20.0).masked_fill(prop_valid[:, :, None] <= 0, -20.0) node_slot = self._safe_softmax(node_slot_logits, dim=-1) * prop_valid[:, :, None] node_context = (node * node_keep[:, :, None]).sum(dim=1) / node_keep.sum(dim=1, keepdim=True).clamp_min(1.0) count_logits = self._clean_tensor(self.count_head(torch.cat([global_feat, node_context], dim=-1)), lo=-20.0, hi=20.0) count_probs = torch.sigmoid(count_logits) count_soft = count_probs.sum(dim=1).clamp(c.count_min, c.count_max) # Proposal graph edge logits. edge_logits, edge_valid = self._edge_logits(node, prop_feats, prop_valid) edge_logits = self._clean_tensor(edge_logits, lo=-20.0, hi=20.0) # Slot priors from proposal assembly. assign = node_slot * node_keep[:, :, None] prior_signal = torch.einsum("bpk,bphw->bkhw", assign, prop_masks) # Normalize each slot prior but preserve zero slots. prior_signal = prior_signal / prior_signal.amax(dim=(2, 3), keepdim=True).clamp_min(1e-6) prior_signal = torch.nan_to_num(prior_signal, nan=0.0, posinf=1.0, neginf=0.0).clamp(0.0, 1.0) denom = E01.sum(dim=(2, 3)).clamp_min(1e-6) slot_mass = (prior_signal * E01).sum(dim=(2, 3)) / denom presence = self._rank_prune_presence(slot_mass, count_soft) prior_signal = prior_signal * presence[:, :, None, None] En = E01 / E01.amax(dim=(2, 3), keepdim=True).clamp_min(1e-6) bg_prior_logit = c.bg_prior_bias - c.bg_energy_suppress * En.pow(c.bg_energy_gamma) residual_logits = self.residual_head(h) signal_logits = prior_w * (c.proposal_logit_gain * torch.log(prior_signal.clamp_min(1e-6))) + residual_w * residual_logits[:, :c.n_slots] bg_logits = prior_w * bg_prior_logit + residual_w * residual_logits[:, c.n_slots:c.n_slots + 1] logits = self._clean_tensor(torch.cat([signal_logits, bg_logits], dim=1), lo=-60.0, hi=60.0) prob_raw = self._safe_softmax(logits, dim=1) prob = prob_raw / prob_raw.sum(dim=1, keepdim=True).clamp_min(1e-6) prior_logits = self._clean_tensor(torch.cat([c.proposal_logit_gain * torch.log(prior_signal.clamp_min(1e-6)), bg_prior_logit], dim=1), lo=-60.0, hi=60.0) prior_prob = self._safe_softmax(prior_logits, dim=1) return { "logits": logits, "prob": prob, "prob_raw": prob_raw, "prior_signal": prior_signal, "prior_logits": prior_logits, "prior_prob": prior_prob, "residual_logits": residual_logits, "node": node, "node_keep_logit": node_keep_logit, "node_keep": node_keep, "node_slot_logits": node_slot_logits, "node_slot": node_slot, "edge_logits": edge_logits, "edge_valid": edge_valid, "count_logits": count_logits, "count_probs": count_probs, "count_soft": count_soft, "slot_mass": slot_mass, "presence": presence, } @torch.no_grad() def prior_foreground_metrics(self, out, E01, high_quantile=0.80): E = E01[:, 0] if E01.dim() == 4 else E01 flat = E.flatten(1) thr = torch.quantile(flat, high_quantile, dim=1).view(-1, 1, 1) mask_hi = E >= thr denom = mask_hi.float().sum().clamp_min(1.0) prior_prob = out.get("prior_prob", torch.softmax(out["prior_logits"], dim=1)) fg_prior = prior_prob[:, :self.cfg.n_slots].sum(dim=1) bg_prior = prior_prob[:, self.cfg.bg_index] return {"prior_fg_hi": (fg_prior * mask_hi).sum() / denom, "prior_bg_hi": (bg_prior * mask_hi).sum() / denom} @torch.no_grad() def dominance_metrics(self, out, E01, high_quantile=0.80): final = out["prob"].argmax(dim=1) prior = out["prior_logits"].argmax(dim=1) resid = out["residual_logits"].argmax(dim=1) E = E01[:, 0] if E01.dim() == 4 else E01 flat = E.flatten(1) thr = torch.quantile(flat, high_quantile, dim=1).view(-1, 1, 1) mask = E >= thr denom = mask.float().sum().clamp_min(1.0) return {"prior_agree": ((final == prior) & mask).float().sum() / denom, "residual_agree": ((final == resid) & mask).float().sum() / denom} def graph_regularizers(self, out, prop_masks, prop_valid, E01): # Slot compactness and smoothness proxies for final masks. c = self.cfg prob = out["prob"][:, :c.n_slots] E = E01[:, 0] if E01.dim() == 4 else E01 nf, nd = c.n_freqs, c.n_dirs f = torch.linspace(0, 1, nf, device=E.device, dtype=E.dtype).view(1, 1, nf, 1) theta = torch.linspace(0, 2 * math.pi, nd + 1, device=E.device, dtype=E.dtype)[:nd].view(1, 1, 1, nd) w = prob * E[:, None] mass = w.sum(dim=(2, 3)).clamp_min(1e-8) mu_f = (w * f).sum(dim=(2, 3)) / mass cx = (w * torch.cos(theta)).sum(dim=(2, 3)) / mass cy = (w * torch.sin(theta)).sum(dim=(2, 3)) / mass mu_t = torch.atan2(cy, cx) df2 = (f - mu_f[:, :, None, None]) ** 2 dt = torch.atan2(torch.sin(theta - mu_t[:, :, None, None]), torch.cos(theta - mu_t[:, :, None, None])) / math.pi radius = ((w * (df2 + dt ** 2)).sum(dim=(2, 3)) / mass).mean() tv = (prob[:, :, 1:, :] - prob[:, :, :-1, :]).abs().mean() + (prob[:, :, :, 1:] - prob[:, :, :, :-1]).abs().mean() # Encourage node slot assignments to be confident only for valid proposals. ns = out["node_slot"].clamp_min(1e-8) ent = -(ns * ns.log()).sum(dim=-1) ent = (ent * prop_valid).sum() / prop_valid.sum().clamp_min(1.0) return {"slot_radius": radius, "slot_tv": tv, "node_slot_entropy": ent} def num_params(self): return sum(p.numel() for p in self.parameters()) # Backward-compatible alias WaveSystemGraphParserV4 = WaveSystemGraphParserV47 # v4.8 stable aliases: keep the proven v47b architecture/checkpoint compatibility. # The v4.8 fixes are implemented in the training objective and deterministic # energy/noise-aware post-processing, not by changing tensor shapes. GraphParserV48Config = GraphParserV47Config WaveSystemGraphParserV48 = WaveSystemGraphParserV47