R-08 / geodesic_stages_model.py
SentinelHub's picture
Remove non-public benchmark references and correct tables
9c04ad8 verified
Raw History Blame Contribute Delete
72.4 kB
# ============================================================================
# LEGAL NOTICE & WATERMARK: R08 NON-COMMERCIAL RESEARCH & ETHICAL LICENSE v2.0
# Copyright (c) 2026 Thomas Seri (Sentinel). All Rights Reserved.
# Exclusive Jurisdiction: Court of Milan (Sezione Specializzata Impresa), Italy.
# Clean-room reverse engineering, distillation, commercial or military use
# is strictly prohibited and constitutes a breach of contract.
# ============================================================================
from __future__ import annotations
__author__ = "Thomas Seri (Sentinel)"
__license__ = "R08-Non-Commercial-Ethical-License-v2.0"
__jurisdiction__ = "Court of Milan, Italy"
"""
Cascade a 3 stadi in SERIE (non stream paralleli).
Architettura (allineata alla timeline pezzi, grain più fine):
pezzi_ids → emb_v1 (+pos) → CausalAttn_1 → mesh_walk_1 (=h1)
→ LN(proj_12(h1)) + LN(emb_v2)[*] → fuse_norm2 → mesh_walk_2 (=h2)
→ LN(proj_23(h2)) + LN(emb_v3)[*] → fuse_norm3 → mesh_walk_3 (=h3)
→ Drop0.1(n1(h1))+n2(h2)+n3(h3) → CausalAttn_2 → final_norm → lm_head (tied weight + bias)
[*] v8.1 ``fuse_mask_pad``: su pad/unk V2/V3 l'addendo emb è azzerato (× active);
``mask_pad_stages`` già saltava solo walk/piramide.
Le sequenze parole/unioni sono espanse sulla lunghezza pezzi (pad dove non
c'è parola/unione). Obiettivo LM: next-token sui pezzi (unità di decode).
Router v6 (``router_v6=True``, default spento = v5 invariato), per stadio:
- ingresso: faccia = argmax(coseni + face_bias) in training e in eval;
face_bias (buffer, niente gradiente) segue il Loss-Free Balancing
(``update_face_bias`` dopo ogni step); peso straight-through
w = p_f / sg(p_f) con p = softmax(coseni · exp(logit_scale)), logit_scale
appresa (init ln 10, clamp ≤ ln 100);
- ogni salto: x + w · ff(norm(x)), il peso non tocca mai il residuo;
- porte: come v5 (Gumbel-ST in training, argmax in eval);
- uscita: decisione dura a soglia nel forward; il peso del salto successivo
è w_porta · p_stay / sg(p_stay), p_stay = 1 − probs[:, uscita], così il
logit di uscita riceve gradiente dal contributo dell'esperto che segue.
``hash_routing=True``: come v6 ma faccia d'ingresso fissa per id del token
(pezzo / parola / unione nei tre stadi, ``hash_faces``), senza to_sphere,
logit_scale né face_bias.
``no_walk=True`` (solo v6/hash, ``max_hops=1``): controllo del cammino, ogni
token usa solo il blocco della faccia d'ingresso in ogni stadio; dopo quel
blocco escono tutti e le porte non vengono valutate (niente rumore Gumbel).
``walk_to_center=True`` (v10 Spoke-to-Hub, solo hash, ``H_max∈{2,3}``):
ingresso Zipf/hash invariato (periferia = ID); dopo ogni blocco il token
segue ``parent_face[f]`` verso un **anello polare hub** di ``k_core≈15–20``
facce (non una sola faccia). Gate (Argon):
hop0 residuo pieno (come v8.1, ``g=None``);
hop≥1 ``g = (c + (1−c)·σ(Linear(x))) / √H_max`` con ``c=center_mix``
(default 0.25, formula liscia). Bias gate init 0. Niente Gumbel;
pad/unk = 0 hop (mask-pad). Train==eval path.
La piramide (se attiva) resta sulla faccia d'ingresso Zipf.
``sector_mix=True`` (v11, implica ``walk_to_center``): stesso albero parent→hub,
ma hop≥1 non è più spoke sottile solo-parent: ``x ← x + g · mean_{f∈settore(entry)}(Δ_f)``
dove il settore è lo **spicchio azimutale** di ``K≈sector_frac·F`` facce
(default 1/4) intorno all'ingresso Zipf. Hop0 resta solo faccia entry a peso 1.0.
Niente layer attention, niente Gumbel, Zipf classico (no multi-slot).
``sheet_mix=True`` (v13 Sheet-Mix / v14 pezzi-only, implica ``sector_mix`` path): stesso hop0/hop≥1
e ``_apply_sector_mean``, ma il buffer ``sector_faces`` è ``(F, K_eff=12)`` =
3 fogli complementari (cerchi massimi a 0°/90°/±45°, esclusa l'orientazione
``spiral_rank % 4``) × ``m=4`` facce equidistanti per foglio (ponte polo-a-polo).
Iso-param rispetto a v11 (solo indici; weight sharing invariato).
**Storico (v13–v18, default):** hop≥1 = media Δ sui blocchi delle K_eff facce dei fogli
(cross-face). **``sheet_entry_only=True`` (v19):** hop≥1 applica solo il blocco della
faccia d'ingresso Zipf (niente media su altre facce); i fogli non toccano altri esperti.
Il cammino parent→hub resta per lo stop; piramide invariata sull'entry.
``pyramid_center=True`` (solo hash, «sfera a spicchi piramidali»): dopo il
cammino di ogni stadio il token scende nella piramide della sua faccia
d'ingresso, un passaggio d → d/2 con proiezione condivisa e guadagno/spostamento
per faccia, e arriva al centro, un MLP denso condiviso dallo stadio; l'uscita
del centro si somma al ramo della faccia (``PyramidCenter``).
"""
import math
from collections import deque
from pathlib import Path
from typing import List, Optional, Tuple, Union
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from fast_walk import (
_build_paths,
fast_walk_token,
fast_walk_token_v6,
grouped_block_forward,
stack_bank,
)
from geodesic_network import (
CausalAttnBlock,
PairedFaceBank,
WalkConfig,
_default_n_heads,
)
from mesh_topology import GeodesicMesh, build_geodesic_mesh, icosahedron_vertices
DEFAULT_K_CORE = 18 # anello polare hub @ F=180 (~10%); clamp a [15, 20] tipico
V10_HOPS_DEFAULT = 2 # H_max=2; CLI può alzare a 3
DEFAULT_CENTER_MIX = 0.25 # hop≥1: g=(c+(1-c)σ(Wx))/√H; hop0=pieno
DEFAULT_SECTOR_FRAC = 0.25 # v11: fetta azimutale ~1/4 di F (~45 @ F=180)
DEFAULT_EDGE_NB_TAU = 4.0 # v12: softmax(τ · z_polo) sui 3 lati
DEFAULT_SHEET_M = 4 # v13: facce per foglio complementare (K_eff = 3·m = 12)
def build_face_edge_ids(faces: np.ndarray) -> Tuple[np.ndarray, int]:
"""``face_edges[f, i]`` = id unico del lato i; ``E = 3F/2`` su mesh chiusa."""
fcount = int(faces.shape[0])
edge_to_id: dict = {}
face_edges = np.zeros((fcount, 3), dtype=np.int64)
next_id = 0
for fi, (a, b, c) in enumerate(faces):
for li, (u, v) in enumerate(((int(a), int(b)), (int(b), int(c)), (int(c), int(a)))):
key = (u, v) if u < v else (v, u)
eid = edge_to_id.get(key)
if eid is None:
eid = next_id
edge_to_id[key] = eid
next_id += 1
face_edges[fi, li] = eid
return face_edges, next_id
def build_edge_neighbor_weights(
adjacency: np.ndarray,
face_centroids: np.ndarray,
tau: float = DEFAULT_EDGE_NB_TAU,
) -> np.ndarray:
"""Pesi (F, 3) sui vicini di lato: softmax(τ · z_polo[nb]), verso il Polo Nord."""
z = face_centroids[:, 2].astype(np.float64)
scores = z[adjacency] * float(tau) # (F, 3)
scores = scores - scores.max(axis=1, keepdims=True)
e = np.exp(scores)
return (e / e.sum(axis=1, keepdims=True)).astype(np.float32)
def build_spoke_hub_parent(
adjacency: np.ndarray,
face_centroids: np.ndarray,
k_core: int = DEFAULT_K_CORE,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Albero Spoke→Hub: ``parent[f]`` verso l'anello polare di ``k_core`` facce.
Hub = top-``k_core`` facce per proiezione sull'asse polare icosaedrico
(vertice 0, stesso di ``compass_spiral``). BFS multi-sorgente: ogni faccia
non-hub punta al vicino più vicino all'anello; sulle hub ``parent[h]=h``.
Ritorna ``(parent_face (F,), is_hub (F,) bool, hub_ids (K,))``.
"""
n = int(adjacency.shape[0])
k = int(max(1, min(int(k_core), n)))
pole = icosahedron_vertices()[0]
u3 = pole / np.linalg.norm(pole)
z = face_centroids.astype(np.float64) @ u3
hub_ids = np.argsort(-z)[:k].astype(np.int64)
is_hub = np.zeros(n, dtype=bool)
is_hub[hub_ids] = True
parent = np.arange(n, dtype=np.int64)
dist = np.full(n, -1, dtype=np.int32)
q: deque = deque()
for h in hub_ids.tolist():
dist[h] = 0
parent[h] = h
q.append(h)
while q:
f = q.popleft()
for nb in adjacency[f].tolist():
nb = int(nb)
if dist[nb] < 0:
dist[nb] = int(dist[f]) + 1
parent[nb] = f
q.append(nb)
if int((dist < 0).sum()) > 0:
raise RuntimeError("spoke-hub: grafo facce non connesso")
return parent, is_hub, hub_ids
def build_azimuth_sectors(
face_centroids: np.ndarray,
sector_frac: float = DEFAULT_SECTOR_FRAC,
) -> np.ndarray:
"""Settore azimutale per faccia: spicchio verso il polo (asse icosaedrico 0).
Per ogni faccia ``e``, le ``K = max(1, round(sector_frac · F))`` facce con
minore distanza circolare in azimut intorno al polo (stesso vertice 0 di
``build_spoke_hub_parent`` / ``compass_spiral``). Con ``sector_frac=0.25`` e
F=180 → K≈45 (~1/4 di sfera in longitudine, tutte le latitudini).
Ritorna ``sector_faces (F, K)`` int64; ``sector_faces[e, 0] == e``.
"""
if not (0.0 < float(sector_frac) <= 1.0):
raise ValueError("sector_frac deve essere in (0, 1]")
cents = face_centroids.astype(np.float64)
n = int(cents.shape[0])
k = int(max(1, min(n, round(float(sector_frac) * n))))
pole = icosahedron_vertices()[0]
u3 = pole / np.linalg.norm(pole)
# base ortonormale nel piano equatoriale
ref = np.array([1.0, 0.0, 0.0], dtype=np.float64)
if abs(float(ref @ u3)) > 0.9:
ref = np.array([0.0, 1.0, 0.0], dtype=np.float64)
e1 = ref - (ref @ u3) * u3
e1 = e1 / np.linalg.norm(e1)
e2 = np.cross(u3, e1)
xy = cents - np.outer(cents @ u3, u3)
x = xy @ e1
y = xy @ e2
az = np.arctan2(y, x) # (-π, π]; poli (xy≈0) → 0
# distanza circolare |Δφ| ∈ [0, π]
dphi = np.abs(az[:, None] - az[None, :])
dphi = np.minimum(dphi, 2.0 * np.pi - dphi)
# self sempre in slot 0 (al polo più facce condividono φ≈0 → argsort non basta)
np.fill_diagonal(dphi, -1.0)
order = np.argsort(dphi, axis=1, kind="mergesort")[:, :k].astype(np.int64)
return order
def build_complementary_sheets(
face_centroids: np.ndarray,
spiral_rank: Optional[np.ndarray] = None,
m_per_sheet: int = DEFAULT_SHEET_M,
) -> np.ndarray:
"""v13 Sheet-Mix: 3 fogli complementari × m facce su cerchi massimi → (F, 3m).
Per ogni faccia ``f`` costruisce la base tangente ``(t_par, t_mer)`` rispetto
al polo icosaedrico (vertice 0). Le 4 normali ai cerchi massimi a
0° / 90° / +45° / −45° sono::
N_V = t_par (meridiano, polo↔polo)
N_H = t_mer (ortogonale in f)
N_+ = (t_par+t_mer)/√2
N_- = (t_par-t_mer)/√2
L'orientazione propria è ``spiral_rank[f] % 4`` (se assente → 0); si usano
i **3 fogli complementari**. Su ciascun foglio si campionano ``m`` facce
equidistanti sul cerchio massimo (θ = 2πk/m), incluso il ponte
fronte/retro e polo-a-polo. Drop-in su ``sector_faces`` / ``_apply_sector_mean``.
"""
m = int(m_per_sheet)
if m < 1:
raise ValueError("m_per_sheet deve essere >= 1")
cents = face_centroids.astype(np.float64)
n = int(cents.shape[0])
# normalizza (centroidi mesh già unitari, ma per sicurezza)
norms = np.linalg.norm(cents, axis=1, keepdims=True)
cents = cents / np.maximum(norms, 1e-12)
pole = icosahedron_vertices()[0]
u3 = pole / np.linalg.norm(pole)
# asse equatoriale fisso per i poli (stesso schema di build_azimuth_sectors)
ref = np.array([1.0, 0.0, 0.0], dtype=np.float64)
if abs(float(ref @ u3)) > 0.9:
ref = np.array([0.0, 1.0, 0.0], dtype=np.float64)
e1 = ref - (ref @ u3) * u3
e1 = e1 / np.linalg.norm(e1)
if spiral_rank is None:
orient = np.zeros(n, dtype=np.int64)
else:
sr = np.asarray(spiral_rank, dtype=np.int64).reshape(-1)
if sr.shape[0] != n:
raise ValueError(f"spiral_rank len={sr.shape[0]} ≠ F={n}")
orient = np.mod(sr, 4).astype(np.int64)
out = np.empty((n, 3 * m), dtype=np.int64)
# precompute all pairwise cosines for nearest-face lookup
# (F=180 → F² cheap)
for f in range(n):
c = cents[f]
# t_par ∝ u3 × c (parallelo); ai poli |u3×c|→0 → e1
t_par = np.cross(u3, c)
tp_n = float(np.linalg.norm(t_par))
if tp_n < 1e-8:
t_par = e1.copy()
else:
t_par = t_par / tp_n
t_mer = np.cross(c, t_par)
tm_n = float(np.linalg.norm(t_mer))
if tm_n < 1e-8:
t_mer = np.cross(t_par, u3)
t_mer = t_mer / max(float(np.linalg.norm(t_mer)), 1e-12)
else:
t_mer = t_mer / tm_n
normals = (
t_par, # 0 Verticale
t_mer, # 1 Orizzontale
(t_par + t_mer) / math.sqrt(2.0), # 2 D+
(t_par - t_mer) / math.sqrt(2.0), # 3 D-
)
own = int(orient[f])
comps = [s for s in range(4) if s != own]
col = 0
for s in comps:
N = normals[s]
# base ortonormale nel piano del cerchio massimo (⊥ N), con ea ≈ c
ea = c - (c @ N) * N
ea_n = float(np.linalg.norm(ea))
if ea_n < 1e-8:
# c ∥ N (non dovrebbe succedere: N costruita ⊥ c)
ea = t_par if s != 0 else t_mer
ea = ea - (ea @ N) * N
ea = ea / max(float(np.linalg.norm(ea)), 1e-12)
else:
ea = ea / ea_n
eb = np.cross(N, ea)
eb = eb / max(float(np.linalg.norm(eb)), 1e-12)
for k in range(m):
theta = 2.0 * math.pi * float(k) / float(m)
target = math.cos(theta) * ea + math.sin(theta) * eb
# faccia col centroide più allineato a target
scores = cents @ target
out[f, col] = int(np.argmax(scores))
col += 1
# slot 0 = entry (coerente con build_azimuth_sectors)
out[f, 0] = f
return out
V6_LOGIT_SCALE_INIT = math.log(10.0)
V6_LOGIT_SCALE_MAX = math.log(100.0)
V6_BIAS_RATE = 1e-3
_HASH_SALTS = (0x9E3779B9, 0x85EBCA6B, 0xC2B2AE35)
PYRAMID_SEED_OFFSET = 7919 # seme del generatore delle piramidi = seme torch + offset
def _lowbias32(x: int) -> int:
x &= 0xFFFFFFFF
x ^= x >> 16
x = (x * 0x7FEB352D) & 0xFFFFFFFF
x ^= x >> 15
x = (x * 0x846CA68B) & 0xFFFFFFFF
x ^= x >> 16
return x
def hash_faces(vocab_size: int, n_faces: int, stage: int) -> torch.Tensor:
"""Faccia d'ingresso fissa per id (--hash-routing): lowbias32(id XOR sale dello stadio) mod n_faces."""
salt = _HASH_SALTS[stage - 1]
return torch.tensor([_lowbias32(i ^ salt) % n_faces for i in range(vocab_size)], dtype=torch.long)
class PyramidCenter(nn.Module):
"""Piramidi sotto le facce di uno stadio e centro condiviso (--pyramid-center).
Discesa dalla faccia f: z = (1 + face_gain[f]) ⊙ down(norm(x)) + face_shift[f], d → d_narrow;
la proiezione propria della faccia è diag(1 + face_gain[f]) · down. Centro, uguale per tutti
i token: c = up(GELU(inner(z))), d_narrow → d_hidden → d. Uscita x + c; up parte da zero, quindi
all'inizio l'uscita è x.
"""
def __init__(
self, d_model: int, n_faces: int, d_narrow: int, d_hidden: int, generator: Optional[torch.Generator] = None
):
super().__init__()
self.norm = nn.LayerNorm(d_model)
self.down = nn.Linear(d_model, d_narrow, bias=False)
self.face_gain = nn.Parameter(torch.zeros(n_faces, d_narrow))
self.face_shift = nn.Parameter(torch.zeros(n_faces, d_narrow))
self.center = nn.Sequential(nn.Linear(d_narrow, d_hidden), nn.GELU(), nn.Linear(d_hidden, d_model))
self.reset_parameters(generator)
def reset_parameters(self, generator: Optional[torch.Generator] = None) -> None:
"""Init di nn.Linear per down e per il primo strato del centro; ultima proiezione, guadagni e spostamenti a zero."""
for lin in (self.down, self.center[0]):
nn.init.kaiming_uniform_(lin.weight, a=math.sqrt(5), generator=generator)
if lin.bias is not None:
bound = 1.0 / math.sqrt(lin.in_features)
nn.init.uniform_(lin.bias, -bound, bound, generator=generator)
for p in (self.center[2].weight, self.center[2].bias, self.face_gain, self.face_shift):
nn.init.zeros_(p)
self.norm.reset_parameters()
def forward(self, x: torch.Tensor, face: torch.Tensor) -> torch.Tensor:
z = self.down(self.norm(x))
z = z * (1.0 + F.embedding(face, self.face_gain)) + F.embedding(face, self.face_shift)
return x + self.center(z)
class HexPyramidCenter(nn.Module):
"""v9: guadagno/spostamento per **vertice** della mesh; media sui 3 vertici della faccia.
Stessa discesa d → d_narrow e centro MLP condiviso di ``PyramidCenter``, ma i parametri
locali vivono sui V vertici (esagoni/pentagoni duali) invece che sulle F facce: facce
adiacenti condividono i vertici e quindi parte della piramide.
"""
def __init__(
self,
d_model: int,
n_vertices: int,
face_vertices: torch.Tensor,
d_narrow: int,
d_hidden: int,
generator: Optional[torch.Generator] = None,
):
super().__init__()
if face_vertices.ndim != 2 or face_vertices.shape[1] != 3:
raise ValueError(f"face_vertices deve essere (F, 3), got {tuple(face_vertices.shape)}")
self.norm = nn.LayerNorm(d_model)
self.down = nn.Linear(d_model, d_narrow, bias=False)
self.vertex_gain = nn.Parameter(torch.zeros(n_vertices, d_narrow))
self.vertex_shift = nn.Parameter(torch.zeros(n_vertices, d_narrow))
self.center = nn.Sequential(nn.Linear(d_narrow, d_hidden), nn.GELU(), nn.Linear(d_hidden, d_model))
self.register_buffer("face_vertices", face_vertices.long().contiguous())
self.reset_parameters(generator)
def reset_parameters(self, generator: Optional[torch.Generator] = None) -> None:
for lin in (self.down, self.center[0]):
nn.init.kaiming_uniform_(lin.weight, a=math.sqrt(5), generator=generator)
if lin.bias is not None:
bound = 1.0 / math.sqrt(lin.in_features)
nn.init.uniform_(lin.bias, -bound, bound, generator=generator)
for p in (self.center[2].weight, self.center[2].bias, self.vertex_gain, self.vertex_shift):
nn.init.zeros_(p)
self.norm.reset_parameters()
def forward(self, x: torch.Tensor, face: torch.Tensor) -> torch.Tensor:
z = self.down(self.norm(x))
verts = self.face_vertices[face] # (..., 3)
g = F.embedding(verts, self.vertex_gain).mean(dim=-2)
s = F.embedding(verts, self.vertex_shift).mean(dim=-2)
z = z * (1.0 + g) + s
return x + self.center(z)
class SharedEdgeVertexPyramid(nn.Module):
"""v12: face_gain specifico per faccia + lati (E) e vertici (V) condivisi + 1 centro MLP.
``z`` modulato da ``1 + face + 0.5·mean(edge) + 0.5·mean(vertex)`` (edge/vert init 0).
"""
def __init__(
self,
d_model: int,
n_faces: int,
n_edges: int,
n_vertices: int,
face_edges: torch.Tensor,
face_vertices: torch.Tensor,
d_narrow: int,
d_hidden: int,
generator: Optional[torch.Generator] = None,
):
super().__init__()
if face_edges.ndim != 2 or face_edges.shape[1] != 3:
raise ValueError(f"face_edges deve essere (F, 3), got {tuple(face_edges.shape)}")
if face_vertices.ndim != 2 or face_vertices.shape[1] != 3:
raise ValueError(f"face_vertices deve essere (F, 3), got {tuple(face_vertices.shape)}")
self.norm = nn.LayerNorm(d_model)
self.down = nn.Linear(d_model, d_narrow, bias=False)
self.face_gain = nn.Parameter(torch.zeros(n_faces, d_narrow))
self.face_shift = nn.Parameter(torch.zeros(n_faces, d_narrow))
self.edge_gain = nn.Parameter(torch.zeros(n_edges, d_narrow))
self.edge_shift = nn.Parameter(torch.zeros(n_edges, d_narrow))
self.vertex_gain = nn.Parameter(torch.zeros(n_vertices, d_narrow))
self.vertex_shift = nn.Parameter(torch.zeros(n_vertices, d_narrow))
self.center = nn.Sequential(nn.Linear(d_narrow, d_hidden), nn.GELU(), nn.Linear(d_hidden, d_model))
self.register_buffer("face_edges", face_edges.long().contiguous())
self.register_buffer("face_vertices", face_vertices.long().contiguous())
self.reset_parameters(generator)
def reset_parameters(self, generator: Optional[torch.Generator] = None) -> None:
for lin in (self.down, self.center[0]):
nn.init.kaiming_uniform_(lin.weight, a=math.sqrt(5), generator=generator)
if lin.bias is not None:
bound = 1.0 / math.sqrt(lin.in_features)
nn.init.uniform_(lin.bias, -bound, bound, generator=generator)
for p in (
self.center[2].weight,
self.center[2].bias,
self.face_gain,
self.face_shift,
self.edge_gain,
self.edge_shift,
self.vertex_gain,
self.vertex_shift,
):
nn.init.zeros_(p)
self.norm.reset_parameters()
def forward(self, x: torch.Tensor, face: torch.Tensor) -> torch.Tensor:
z = self.down(self.norm(x))
f = face.long()
eg = F.embedding(self.face_edges[f], self.edge_gain).mean(dim=-2)
es = F.embedding(self.face_edges[f], self.edge_shift).mean(dim=-2)
vg = F.embedding(self.face_vertices[f], self.vertex_gain).mean(dim=-2)
vs = F.embedding(self.face_vertices[f], self.vertex_shift).mean(dim=-2)
g = F.embedding(f, self.face_gain) + 0.5 * eg + 0.5 * vg
s = F.embedding(f, self.face_shift) + 0.5 * es + 0.5 * vs
z = z * (1.0 + g) + s
return x + self.center(z)
def pyramid_kwargs(blob: dict) -> dict:
"""Argomenti di CascadeMeshModel per la piramide, dai metadati di un checkpoint (assenti = spenta)."""
if not blob.get("pyramid_center") and not blob.get("hex_pyramid") and not blob.get("shared_edges_verts"):
return {}
cfg = blob.get("pyramid_config") or {}
out: dict = {
"pyramid_center": True,
"pyramid_narrow": cfg.get("d_stretto"),
"pyramid_hidden": cfg.get("d_centro"),
}
if blob.get("hex_pyramid"):
out["hex_pyramid"] = True
if blob.get("shared_edges_verts"):
out["shared_edges_verts"] = True
return out
def v8_kwargs(blob: dict) -> dict:
"""Argomenti v8.x di CascadeMeshModel dai metadati checkpoint (assenti = default v7).
Lo zipf-hash è già nei buffer ``bank.hash_face`` dello state_dict: non serve
``zipf_hash_path`` in eval.
``fuse_mask_pad`` (v8.1): solo se presente nel blob — i ckpt v8.0 senza chiave
restano senza mask sull'addendo emb in fuse (come in training).
"""
out: dict = {}
if blob.get("d_hidden") is not None:
out["d_hidden"] = int(blob["d_hidden"])
de = blob.get("d_emb_v23")
if de is not None and int(de) > 0:
out["d_emb_v23"] = int(de)
if blob.get("inter_stage_attn"):
out["inter_stage_attn"] = True
if blob.get("mask_pad_stages"):
out["mask_pad_stages"] = True
out["pad_id_v2"] = int(blob.get("pad_id_v2", 3))
out["unk_id_v2"] = int(blob.get("unk_id_v2", 4))
out["pad_id_v3"] = int(blob.get("pad_id_v3", 3))
out["unk_id_v3"] = int(blob.get("unk_id_v3", 4))
if blob.get("fuse_mask_pad"):
out["fuse_mask_pad"] = True
if blob.get("multi_slot_v1"):
out["multi_slot_v1"] = True
return out
def _make_emb(vocab: int, d_emb: int, d_model: int) -> nn.Module:
"""Embedding pieno a ``d_model``, oppure ``Embedding(d_emb)→Linear→d_model``."""
if d_emb >= d_model:
emb = nn.Embedding(vocab, d_model)
nn.init.normal_(emb.weight, mean=0.0, std=0.02)
return emb
emb = nn.Embedding(vocab, d_emb)
proj = nn.Linear(d_emb, d_model, bias=False)
nn.init.normal_(emb.weight, mean=0.0, std=0.02)
nn.init.normal_(proj.weight, mean=0.0, std=0.02)
return nn.Sequential(emb, proj)
class CascadeMeshModel(nn.Module):
"""Tre emb + tre mesh in catena (default), oppure solo-V1 (v1_only=True)."""
def __init__(
self,
vocab_pezzi: int,
vocab_parole: int = 0,
vocab_unioni: int = 0,
d_model: int = 256,
frequency: int = 2,
walk: Optional[WalkConfig] = None,
d_hidden: Optional[int] = None,
n_attn_layers: int = 2,
n_heads: Optional[int] = None,
max_seq_len: int = 512,
attn_dropout: float = 0.0,
res_h1_dropout: float = 0.1,
v1_only: bool = False,
compass_spiral: bool = False,
fast_walk: bool = False,
router_v6: bool = False,
hash_routing: bool = False,
no_walk: bool = False,
walk_to_center: bool = False,
k_core: int = DEFAULT_K_CORE,
center_mix: float = DEFAULT_CENTER_MIX,
sector_mix: bool = False,
sector_frac: float = DEFAULT_SECTOR_FRAC,
sheet_mix: bool = False,
sheet_m: int = DEFAULT_SHEET_M,
sheet_entry_only: bool = False,
edge_neighbor_mix: bool = False,
edge_nb_tau: float = DEFAULT_EDGE_NB_TAU,
pyramid_center: bool = False,
pyramid_narrow: Optional[int] = None,
pyramid_hidden: Optional[int] = None,
hex_pyramid: bool = False,
shared_edges_verts: bool = False,
multi_slot_v1: bool = False,
d_emb_v23: Optional[int] = None,
inter_stage_attn: bool = False,
mask_pad_stages: bool = False,
fuse_mask_pad: bool = False,
zipf_hash_path: Optional[Union[str, Path]] = None,
pad_id_v2: int = 3,
unk_id_v2: int = 4,
pad_id_v3: int = 3,
unk_id_v3: int = 4,
):
super().__init__()
self.compass_spiral = bool(compass_spiral)
# walk_token vettorizzato (fast_walk.py): stessi risultati del loop per faccia, senza sync per faccia
self.fast_walk = bool(fast_walk)
self.mesh: GeodesicMesh = build_geodesic_mesh(
frequency, compass_spiral=self.compass_spiral
)
self.walk = walk or WalkConfig()
self.d_model = int(d_model)
self.vocab_pezzi = int(vocab_pezzi)
self.vocab_parole = int(vocab_parole)
self.vocab_unioni = int(vocab_unioni)
self.v1_only = bool(v1_only)
self.n_attn_layers = int(n_attn_layers)
self.max_seq_len = int(max_seq_len)
self.d_hidden = int(d_hidden) if d_hidden is not None else None
self.d_emb_v23 = int(d_emb_v23) if d_emb_v23 is not None else None
self.inter_stage_attn = bool(inter_stage_attn)
self.mask_pad_stages = bool(mask_pad_stages)
# v8.1: azzera anche l'addendo emb in fuse su pad/unk (0 nuovi param).
# Richiede mask_pad_stages; se True senza mask_pad viene ignorato in forward.
self.fuse_mask_pad = bool(fuse_mask_pad) and bool(mask_pad_stages)
self.pad_id_v2 = int(pad_id_v2)
self.unk_id_v2 = int(unk_id_v2)
self.pad_id_v3 = int(pad_id_v3)
self.unk_id_v3 = int(unk_id_v3)
heads = int(n_heads) if n_heads is not None else _default_n_heads(d_model)
# attn backbone sempre 4·d; d_hidden slimma solo le FFN delle facce (v8)
attn_ff = 4 * d_model
self.emb_v1 = nn.Embedding(vocab_pezzi, d_model)
nn.init.normal_(self.emb_v1.weight, mean=0.0, std=0.02)
if not self.v1_only:
if vocab_parole <= 0 or vocab_unioni <= 0:
raise ValueError(
"Cascade 3-stadi richiede vocab_parole > 0 e vocab_unioni > 0 "
"(oppure v1_only=True)"
)
de = self.d_emb_v23 if self.d_emb_v23 is not None else d_model
self.emb_v2 = _make_emb(vocab_parole, de, d_model)
self.emb_v3 = _make_emb(vocab_unioni, de, d_model)
self.pos_emb = nn.Embedding(self.max_seq_len, d_model)
nn.init.normal_(self.pos_emb.weight, mean=0.0, std=0.02)
self.emb_scale = d_model**-0.5
self.attn_blocks = nn.ModuleList(
[
CausalAttnBlock(d_model, heads, d_ff=attn_ff, dropout=attn_dropout)
for _ in range(self.n_attn_layers)
]
)
self.attn_inter2: Optional[CausalAttnBlock] = None
if self.inter_stage_attn and not self.v1_only:
self.attn_inter2 = CausalAttnBlock(d_model, heads, d_ff=attn_ff, dropout=attn_dropout)
# Banca mesh V1 (80 triangoli a freq=2); stadi 2/3 creati solo se non v1_only
self.to_sphere1 = nn.Linear(d_model, 3)
self.bank1 = PairedFaceBank(self.mesh, d_model, d_hidden)
if not self.v1_only:
self.to_sphere2 = nn.Linear(d_model, 3)
self.to_sphere3 = nn.Linear(d_model, 3)
self.bank2 = PairedFaceBank(self.mesh, d_model, d_hidden)
self.bank3 = PairedFaceBank(self.mesh, d_model, d_hidden)
# Fuse bilanciato: LN per-addendo (niente emb_scale su V2/V3 — spegneva gli emb)
self.proj_12 = nn.Linear(d_model, d_model)
self.proj_23 = nn.Linear(d_model, d_model)
self.fuse_ln_h2 = nn.LayerNorm(d_model)
self.fuse_ln_e2 = nn.LayerNorm(d_model)
self.fuse_ln_h3 = nn.LayerNorm(d_model)
self.fuse_ln_e3 = nn.LayerNorm(d_model)
self.fuse_norm2 = nn.LayerNorm(d_model)
self.fuse_norm3 = nn.LayerNorm(d_model)
# Residual bilanciato: LN per-stadio così ||h1||~180 non schiaccia V2/V3
# Dropout 0.1 sulla scorciatoia res_ln_h1 per non monopolizzare lm_head
self.res_h1_dropout = 0.0 if self.v1_only else float(res_h1_dropout)
self.res_ln_h1 = nn.LayerNorm(d_model)
self.res_drop_h1 = nn.Dropout(self.res_h1_dropout)
if not self.v1_only:
self.res_ln_h2 = nn.LayerNorm(d_model)
self.res_ln_h3 = nn.LayerNorm(d_model)
self.final_norm = nn.LayerNorm(d_model)
self.lm_head = nn.Linear(d_model, vocab_pezzi, bias=True)
# self.lm_head.weight separato da emb_v1.weight per rotazione ortogonale Q_lic Watchman Keystone
self.register_buffer(
"centroids",
torch.from_numpy(self.mesh.face_centroids.astype("float32")),
)
self.register_buffer(
"adjacency",
torch.from_numpy(self.mesh.adjacency.astype("int64")),
)
# v6 dopo tutti i moduli: stessi valori iniziali di v5 per i parametri comuni a parità di seed
self.hash_routing = bool(hash_routing)
self.router_v6 = bool(router_v6) or self.hash_routing
self.no_walk = bool(no_walk)
self.sheet_mix = bool(sheet_mix)
self.sheet_m = int(sheet_m)
# v19: hop≥1 solo entry; False = storico v13–v18 (mean Δ su fogli cross-face)
self.sheet_entry_only = bool(sheet_entry_only) and self.sheet_mix
# v13 riusa il path sector_mean con buffer (F, 3m) al posto dello spicchio
self.sector_mix = bool(sector_mix) or self.sheet_mix
self.sector_frac = float(sector_frac)
self.edge_neighbor_mix = bool(edge_neighbor_mix)
self.edge_nb_tau = float(edge_nb_tau)
# v11 sector-mix / v12 edge-nb / v13 sheet-mix implicano Spoke-to-Hub
self.walk_to_center = (
bool(walk_to_center) or self.sector_mix or self.edge_neighbor_mix
)
self.k_core = int(k_core)
self.center_mix = float(center_mix)
if self.sheet_mix and self.edge_neighbor_mix:
raise ValueError("sheet_mix (v13) e edge_neighbor_mix (v12) sono mutuamente esclusivi")
if self.sector_mix and self.edge_neighbor_mix:
raise ValueError("sector_mix (v11) e edge_neighbor_mix (v12) sono mutuamente esclusivi")
if self.sheet_mix and self.sheet_m < 1:
raise ValueError("sheet_m deve essere >= 1")
if bool(sheet_entry_only) and not self.sheet_mix:
raise ValueError("sheet_entry_only richiede sheet_mix")
if self.no_walk and self.walk_to_center:
raise ValueError("no_walk e walk_to_center (Spoke-to-Hub) sono mutuamente esclusivi")
if self.no_walk and (not self.router_v6 or int(self.walk.max_hops) != 1):
raise ValueError("no_walk: servono router_v6 o hash_routing e WalkConfig(max_hops=1)")
self.spoke_gates: Optional[nn.ModuleList] = None
if self.walk_to_center:
if not self.hash_routing:
raise ValueError("walk_to_center/Spoke-to-Hub: serve hash_routing (ingresso Zipf)")
hops = int(self.walk.max_hops)
if hops < 2 or hops > 3:
raise ValueError("walk_to_center/Spoke-to-Hub: H_max deve essere 2 o 3")
if not (0.0 <= self.center_mix <= 1.0):
raise ValueError("center_mix deve essere in [0, 1]")
if self.sector_mix and not self.sheet_mix and not (0.0 < self.sector_frac <= 1.0):
raise ValueError("sector_frac deve essere in (0, 1]")
parent, is_hub, hub_ids = build_spoke_hub_parent(
self.mesh.adjacency, self.mesh.face_centroids, k_core=self.k_core
)
self.register_buffer("parent_face", torch.from_numpy(parent.astype("int64")))
self.register_buffer("is_hub", torch.from_numpy(is_hub.astype("bool")))
self.register_buffer("hub_ids", torch.from_numpy(hub_ids.astype("int64")))
if self.sheet_mix and self.sheet_entry_only:
# v19: niente buffer fogli — hop≥1 tocca solo l'entry (vedi walk)
self.sector_faces = None # type: ignore[assignment]
self.sector_k = 1
elif self.sheet_mix:
sheets = build_complementary_sheets(
self.mesh.face_centroids,
spiral_rank=getattr(self.mesh, "spiral_rank", None),
m_per_sheet=self.sheet_m,
)
self.register_buffer(
"sector_faces", torch.from_numpy(sheets.astype("int64"))
)
self.sector_k = int(sheets.shape[1])
elif self.sector_mix:
sectors = build_azimuth_sectors(
self.mesh.face_centroids, sector_frac=self.sector_frac
)
self.register_buffer(
"sector_faces", torch.from_numpy(sectors.astype("int64"))
)
self.sector_k = int(sectors.shape[1])
else:
self.sector_faces = None # type: ignore[assignment]
self.sector_k = 0
if self.edge_neighbor_mix:
w = build_edge_neighbor_weights(
self.mesh.adjacency, self.mesh.face_centroids, tau=self.edge_nb_tau
)
self.register_buffer("edge_nb_w", torch.from_numpy(w))
else:
self.edge_nb_w = None # type: ignore[assignment]
self.spoke_res_scale = 1.0 / math.sqrt(float(hops))
# gate scalare hop≥1; bias=0 → σ≈0.5 a init (Argon)
n_banks = 1 if self.v1_only else 3
gates = []
for _ in range(n_banks):
lin = nn.Linear(self.d_model, 1)
nn.init.zeros_(lin.weight)
nn.init.zeros_(lin.bias)
gates.append(lin)
self.spoke_gates = nn.ModuleList(gates)
else:
self.parent_face = None # type: ignore[assignment]
self.is_hub = None # type: ignore[assignment]
self.hub_ids = None # type: ignore[assignment]
self.sector_faces = None # type: ignore[assignment]
self.sector_k = 0
self.sector_mix = False
self.sheet_mix = False
self.sheet_entry_only = False
self.edge_neighbor_mix = False
self.edge_nb_w = None # type: ignore[assignment]
self.spoke_res_scale = 1.0
self.center_mix = 0.0
self.v6_logit_scale_max = V6_LOGIT_SCALE_MAX
self.v6_bias_rate = V6_BIAS_RATE
if self.router_v6:
n_faces = int(self.mesh.num_faces)
sizes = (self.vocab_pezzi, self.vocab_parole, self.vocab_unioni)
for s, bank in enumerate(self._banks(), start=1):
if self.hash_routing:
bank.register_buffer("hash_face", hash_faces(sizes[s - 1], n_faces, s))
delattr(self, f"to_sphere{s}")
setattr(self, f"to_sphere{s}", None)
else:
bank.logit_scale = nn.Parameter(torch.tensor(V6_LOGIT_SCALE_INIT))
bank.register_buffer("face_bias", torch.zeros(n_faces))
self.multi_slot_v1 = bool(multi_slot_v1)
if zipf_hash_path is not None:
if not self.hash_routing:
raise ValueError("zipf_hash_path richiede hash_routing")
self.load_zipf_hash(zipf_hash_path)
elif self.multi_slot_v1:
# Eval senza zipf path: buffer placeholder (V, K=3) riempito da state_dict
v = int(self.bank1.hash_face.numel())
self.bank1.register_buffer(
"hash_slots",
self.bank1.hash_face.unsqueeze(1).expand(v, 3).contiguous().clone(),
)
# piramidi e centri per ultimi: i parametri della base hanno gli stessi valori iniziali a parità di seed
self.hex_pyramid = bool(hex_pyramid)
self.shared_edges_verts = bool(shared_edges_verts)
if self.hex_pyramid and self.shared_edges_verts:
raise ValueError("hex_pyramid e shared_edges_verts (v12) sono mutuamente esclusivi")
self.pyramid_center = bool(pyramid_center) or self.hex_pyramid or self.shared_edges_verts
self.pyramids: Optional[nn.ModuleList] = None
if self.pyramid_center:
if not self.hash_routing:
raise ValueError("pyramid_center/hex_pyramid: serve hash_routing (la piramide è quella della faccia d'ingresso)")
self.pyramid_narrow = int(pyramid_narrow or self.d_model // 2)
self.pyramid_hidden = int(pyramid_hidden or 2 * self.d_model)
# generatore proprio e stato globale ripristinato: su CPU anche le maschere di dropout restano quelle della base
gen = torch.Generator().manual_seed((torch.initial_seed() + PYRAMID_SEED_OFFSET) % 2**63)
rng = torch.get_rng_state()
if self.hex_pyramid:
face_verts = torch.from_numpy(self.mesh.faces.astype("int64"))
n_vert = int(self.mesh.num_vertices)
self.pyramids = nn.ModuleList(
HexPyramidCenter(
self.d_model, n_vert, face_verts, self.pyramid_narrow, self.pyramid_hidden, gen
)
for _ in self._banks()
)
elif self.shared_edges_verts:
face_edges_np, n_edges = build_face_edge_ids(self.mesh.faces)
face_edges = torch.from_numpy(face_edges_np.astype("int64"))
face_verts = torch.from_numpy(self.mesh.faces.astype("int64"))
n_vert = int(self.mesh.num_vertices)
self.pyramids = nn.ModuleList(
SharedEdgeVertexPyramid(
self.d_model,
int(self.mesh.num_faces),
int(n_edges),
n_vert,
face_edges,
face_verts,
self.pyramid_narrow,
self.pyramid_hidden,
gen,
)
for _ in self._banks()
)
else:
self.pyramids = nn.ModuleList(
PyramidCenter(self.d_model, int(self.mesh.num_faces), self.pyramid_narrow, self.pyramid_hidden, gen)
for _ in self._banks()
)
torch.set_rng_state(rng)
from watchman_keystone_r08 import NonLinearWatchmanKeystoneR08, OFFICIAL_SENTINEL_LICENSE_SUMMARY
self.watchman = NonLinearWatchmanKeystoneR08(d_model=self.d_model)
self._watchman_is_ethical = True
self._watchman_license_text = OFFICIAL_SENTINEL_LICENSE_SUMMARY
def _banks(self) -> List[PairedFaceBank]:
return [self.bank1] if self.v1_only else [self.bank1, self.bank2, self.bank3]
def load_zipf_hash(self, path: Union[str, Path]) -> None:
"""Copia ``hash_face_{1,2,3}`` (e opz. ``hash_slots_1`` multi-slot V1) da un ``.pt`` Zipf."""
path = Path(path)
try:
blob = torch.load(path, map_location="cpu", weights_only=False)
except TypeError:
blob = torch.load(path, map_location="cpu")
n_faces = int(self.mesh.num_faces)
for s, bank in enumerate(self._banks(), start=1):
key = f"hash_face_{s}"
if key not in blob:
raise KeyError(f"{path}: manca {key}")
t = blob[key].long().view(-1)
if t.numel() != bank.hash_face.numel():
raise ValueError(
f"{path} {key}: len={t.numel()} ≠ bank.hash_face={bank.hash_face.numel()}"
)
if int(t.max()) >= n_faces or int(t.min()) < 0:
raise ValueError(f"{path} {key}: facce fuori da [0, {n_faces})")
bank.hash_face.copy_(t)
# Multi-slot solo stadio 1 (pezzi): (V, K) con K copie della primaria se non top-N
if "hash_slots_1" in blob:
slots = blob["hash_slots_1"].long()
if slots.ndim != 2 or slots.shape[0] != self.bank1.hash_face.numel():
raise ValueError(
f"{path} hash_slots_1: shape={tuple(slots.shape)} "
f"atteso (V={self.bank1.hash_face.numel()}, K)"
)
if int(slots.max()) >= n_faces or int(slots.min()) < 0:
raise ValueError(f"{path} hash_slots_1: facce fuori da [0, {n_faces})")
self.bank1.register_buffer("hash_slots", slots.contiguous())
self.multi_slot_v1 = True
elif self.multi_slot_v1:
raise ValueError(f"{path}: multi_slot_v1 richiesto ma manca hash_slots_1")
def _active_mask(self, stage: int, ids: torch.Tensor) -> Optional[torch.Tensor]:
"""Maschera token attivi per stadi 2/3 con ``mask_pad_stages`` (pad/unk → False)."""
if not self.mask_pad_stages or stage < 1:
return None
if stage == 1:
pad, unk = self.pad_id_v2, self.unk_id_v2
elif stage == 2:
pad, unk = self.pad_id_v3, self.unk_id_v3
else:
return None
return (ids != pad) & (ids != unk)
def _v6_entry(
self,
h: torch.Tensor,
bank: PairedFaceBank,
to_sphere: Optional[nn.Linear],
entry_ids: Optional[torch.Tensor],
) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
"""Faccia d'ingresso v6 (B,) e peso straight-through w (B, 1), None senza gradiente.
w = p_f / sg(p_f) è scritto come exp(log p_f − sg(log p_f)): il bias può
scegliere una faccia con p_f sotto il minimo di float32 e il rapporto
diventerebbe 0/0. In training il carico d'ingresso per faccia si accumula
sul device per ``update_face_bias``.
"""
if self.hash_routing:
if entry_ids is None:
raise ValueError("hash_routing: servono gli id dei token per la faccia d'ingresso")
# v9 multi-slot V1: in training campiona tra K facce; in eval usa lo slot 0 (primaria)
slots = getattr(bank, "hash_slots", None)
if slots is not None:
row = slots[entry_ids] # (..., K)
if self.training:
k = int(row.shape[-1])
idx = torch.randint(0, k, entry_ids.shape, device=entry_ids.device)
face = row.gather(-1, idx.unsqueeze(-1)).squeeze(-1)
else:
face = row[..., 0]
return face, None
return bank.hash_face[entry_ids], None
scores = F.normalize(to_sphere(h), dim=-1) @ self.centroids.T # (B, F)
face = (scores + bank.face_bias).argmax(dim=-1)
if self.training:
n_faces = scores.shape[1]
load = (face.unsqueeze(1) == torch.arange(n_faces, device=face.device)).sum(0)
prev = bank.__dict__.get("_v6_load")
bank.__dict__["_v6_load"] = load if prev is None else prev + load
if not torch.is_grad_enabled():
return face, None
scale = bank.logit_scale.clamp(max=self.v6_logit_scale_max).exp()
lp = F.log_softmax(scores * scale, dim=-1).gather(1, face.unsqueeze(1))
return face, torch.exp(lp - lp.detach())
def _v6_route(
self, face: torch.Tensor, logits: torch.Tensor
) -> Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]:
"""Porte come ``_route_step``, uscita dura a soglia; ritorna anche il peso del salto successivo.
w = w_porta · p_stay / sg(p_stay) (B, 1), None senza gradiente: w_porta è il
valore Gumbel-ST della porta scelta (solo in training), p_stay =
1 − probs[:, 3] = exp(LSE(z[:3]) − LSE(z)). Sui token che proseguono il
logit di uscita riceve d log p_stay / d z_uscita = −p_exit volte il
gradiente del peso dell'esperto del salto successivo.
Con ``no_walk`` escono tutti: porte e uscita non vengono valutate.
Con ``walk_to_center`` (Spoke-to-Hub) il routing è in ``_walk_token_spoke_hub``:
qui si usa solo ``parent_face`` (niente Gumbel).
"""
if self.no_walk:
return face, torch.ones_like(face, dtype=torch.bool), None
if self.walk_to_center:
nxt = self.parent_face[face.long()]
exit_mask = nxt == face
return nxt, exit_mask, None
temp = max(self.walk.router_temperature, 1e-6)
z = logits / temp
probs = F.softmax(z, dim=-1)
exit_mask = probs[:, 3] >= self.walk.exit_threshold
neighbors = self.adjacency[face.long()]
w_side: Optional[torch.Tensor] = None
if self._hard_routing():
side = probs[:, :3].argmax(dim=-1)
else:
side_oh = F.gumbel_softmax(logits[:, :3] / temp, hard=True, dim=-1)
side = side_oh.argmax(dim=-1)
w_side = side_oh.gather(1, side.unsqueeze(1))
next_face = neighbors.gather(1, side.unsqueeze(1)).squeeze(1)
if not torch.is_grad_enabled():
return next_face, exit_mask, None
log_stay = torch.logsumexp(z[:, :3], dim=-1, keepdim=True) - torch.logsumexp(z, dim=-1, keepdim=True)
w = torch.exp(log_stay - log_stay.detach())
return next_face, exit_mask, (w if w_side is None else w_side * w)
def _walk_token_v6_loop(
self,
h: torch.Tensor,
bank: PairedFaceBank,
to_sphere: Optional[nn.Linear],
entry_ids: Optional[torch.Tensor] = None,
record_path: bool = False,
active: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, Optional[List[List[int]]]]:
"""Cammino v6 col loop per faccia: riferimento di ``fast_walk_token_v6`` nei test."""
b = h.shape[0]
face, w = self._v6_entry(h, bank, to_sphere, entry_ids)
if active is None:
active = torch.ones(b, dtype=torch.bool, device=h.device)
else:
active = active.to(device=h.device, dtype=torch.bool)
face0 = torch.where(active, face, torch.full_like(face, -1))
paths = [[int(f)] for f in face0.tolist()] if record_path else None
for _ in range(self.walk.max_hops):
if not bool(active.any()):
break
logits = h.new_zeros(b, 4)
h_new = h.clone()
for f_id in face[active].unique().tolist():
mask = active & (face == f_id)
blk = bank.blocks[int(bank.face_to_slot[f_id].item())]
x = h[mask]
delta = blk.ff(blk.norm(x))
out = x + delta if w is None else x + w[mask] * delta
h_new[mask] = out
logits[mask] = blk.router(out)
h = h_new
next_face, exit_mask, w = self._v6_route(face, logits)
moved = active & ~exit_mask
face = torch.where(moved, next_face, face)
if paths is not None:
for i in range(b):
if moved[i]:
paths[i].append(int(face[i].item()))
elif active[i] and exit_mask[i]:
paths[i].append(-1)
active = active & ~exit_mask
return h, paths
@torch.no_grad()
def update_face_bias(self) -> None:
"""Loss-Free Balancing: b_f += u · sign(carico medio − carico_f) col carico d'ingresso accumulato."""
if not self.router_v6 or self.hash_routing:
return
for bank in self._banks():
load = bank.__dict__.pop("_v6_load", None)
if load is None:
continue
load = load.to(bank.face_bias.dtype)
bank.face_bias.add_(torch.sign(load.mean() - load), alpha=self.v6_bias_rate)
def _hard_routing(self) -> bool:
"""Hard (argmax) in eval o se WalkConfig.hard_routing; soft/ST in train."""
return bool(self.walk.hard_routing) or (not self.training)
def project_to_face(
self, h: torch.Tensor, to_sphere: nn.Linear
) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
"""
Proietta h sulla sfera → faccia.
Train (soft): Gumbel-ST one-hot (B, F) così i grad arrivano a to_sphere.
Eval/hard: solo face id (argmax), face_oh=None.
"""
direction = F.normalize(to_sphere(h), dim=-1)
scores = direction @ self.centroids.T # (B, F)
if self._hard_routing():
return scores.argmax(dim=-1), None
temp = max(self.walk.router_temperature, 1e-6)
face_oh = F.gumbel_softmax(scores / temp, hard=True, dim=-1) # ST soft→hard
face = face_oh.argmax(dim=-1) # solo indice forward; massa ST resta in face_oh
return face, face_oh
def _route_step(
self, face: torch.Tensor, logits: torch.Tensor
) -> Tuple[torch.Tensor, torch.Tensor, Optional[torch.Tensor]]:
"""
Router ternario + exit.
Train (soft): Gumbel-ST su 3 lati (niente argmax sulla one-hot) + mixture
su adjacency → side_oh per propagare i grad al router.
Eval/hard: argmax discreto, side_oh=None.
"""
temp = max(self.walk.router_temperature, 1e-6)
probs = F.softmax(logits / temp, dim=-1)
exit_prob = probs[:, 3]
neighbors = self.adjacency[face.long()] # (B, 3)
exit_mask = exit_prob >= self.walk.exit_threshold
if self._hard_routing():
side = probs[:, :3].argmax(dim=-1)
next_face = neighbors.gather(1, side.unsqueeze(1)).squeeze(1)
return next_face, exit_mask, None
# Gumbel-ST: forward one-hot, backward softmax — non chiamare .argmax sulla ST
side_oh = F.gumbel_softmax(logits[:, :3] / temp, hard=True, dim=-1) # (B, 3)
side = side_oh.argmax(dim=-1)
next_face = neighbors.gather(1, side.unsqueeze(1)).squeeze(1)
return next_face, exit_mask, side_oh
def _scatter_side_oh(
self, face: torch.Tensor, side_oh: torch.Tensor
) -> torch.Tensor:
"""Soft/ST mixture su adjacency: massa dei 3 lati → one-hot sparsa su F facce."""
b = face.shape[0]
n_faces = int(self.centroids.shape[0])
neighbors = self.adjacency[face.long()] # (B, 3)
face_oh = side_oh.new_zeros(b, n_faces)
face_oh.scatter_add_(1, neighbors, side_oh)
return face_oh
def _spoke_stage_index(self, bank: PairedFaceBank) -> int:
for i, b in enumerate(self._banks()):
if b is bank:
return i
raise KeyError("bank non appartiene al modello")
def _apply_sector_mean(
self,
h: torch.Tensor,
bank: PairedFaceBank,
entry: torch.Tensor,
active: torch.Tensor,
g: Optional[torch.Tensor],
st: Optional[dict] = None,
) -> torch.Tensor:
"""v11: ``x ← x + g · mean_{f∈settore(entry)}(Δ_f)`` (hop≥1).
Settore fissato sulla faccia d'ingresso Zipf. ``g=None`` → peso 1.0.
"""
assert self.sector_faces is not None
k = int(self.sector_k)
if k <= 0:
return h
use_fast = st is not None
delta_sum: Optional[torch.Tensor] = None
for slot in range(k):
face_k = self.sector_faces[entry.long(), slot]
if use_fast:
assert st is not None
out, _lg, counts = grouped_block_forward(
st, h, face_k, active, gate=None
)
if st["visited"] is not None:
st["visited"].add(counts) # type: ignore[union-attr]
# out = x+Δ sulle attive; sulle inattive out è spazzatura → maschera dopo
d = out - h
else:
d = h.new_zeros(h.shape)
for f_id in face_k[active].unique().tolist():
mask = active & (face_k == f_id)
blk = bank.blocks[int(bank.face_to_slot[f_id].item())]
x = h[mask]
d[mask] = blk.ff(blk.norm(x))
delta_sum = d if delta_sum is None else delta_sum + d
assert delta_sum is not None
mean_d = delta_sum / float(k)
if g is None:
upd = h + mean_d
else:
upd = h + g * mean_d
return torch.where(active.unsqueeze(-1), upd, h)
def _apply_edge_neighbor_mix(
self,
h: torch.Tensor,
bank: PairedFaceBank,
entry: torch.Tensor,
active: torch.Tensor,
g: Optional[torch.Tensor],
st: Optional[dict] = None,
) -> torch.Tensor:
"""v12: ``x ← x + g · Σ_k w_{e,k} Δ_{nb_k}`` sui 3 lati di ``entry`` (pesi verso polo)."""
assert self.edge_nb_w is not None
e = entry.long()
nbs = self.adjacency[e] # (B, 3)
w = self.edge_nb_w[e] # (B, 3)
use_fast = st is not None
weighted: Optional[torch.Tensor] = None
for slot in range(3):
face_k = nbs[:, slot]
wk = w[:, slot].unsqueeze(-1)
if use_fast:
assert st is not None
out, _lg, counts = grouped_block_forward(
st, h, face_k, active, gate=None
)
if st["visited"] is not None:
st["visited"].add(counts) # type: ignore[union-attr]
d = out - h
else:
d = h.new_zeros(h.shape)
for f_id in face_k[active].unique().tolist():
mask = active & (face_k == f_id)
blk = bank.blocks[int(bank.face_to_slot[f_id].item())]
x = h[mask]
d[mask] = blk.ff(blk.norm(x))
term = wk * d
weighted = term if weighted is None else weighted + term
assert weighted is not None
if g is None:
upd = h + weighted
else:
upd = h + g * weighted
return torch.where(active.unsqueeze(-1), upd, h)
def _walk_token_spoke_hub(
self,
h: torch.Tensor,
bank: PairedFaceBank,
to_sphere: Optional[nn.Linear],
entry_ids: Optional[torch.Tensor] = None,
record_path: bool = False,
active: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, Optional[List[List[int]]]]:
"""v10 Spoke-to-Hub / v11 sector-mix / v12 edge-nb / v13–v19 sheet: parent→hub; no Gumbel.
hop0: solo faccia entry, residuo pieno.
hop≥1: v10 = blocco ``face`` corrente con ``g=(c+(1-c)σ)/√H``;
v11 (``sector_mix``) = media Δ sul settore azimutale dell'ingresso;
v12 (``edge_neighbor_mix``) = mix pesato sui 3 vicini di lato (anche al polo);
v13–v18 (``sheet_mix``, default) = media Δ sui fogli complementari dell'ingresso;
v19 (``sheet_entry_only``) = di nuovo solo blocco entry (niente cross-face).
pad/unk (``active=False``): 0 hop. Train==eval.
"""
assert self.spoke_gates is not None and self.parent_face is not None
b = h.shape[0]
face, _ = self._v6_entry(h, bank, to_sphere, entry_ids)
if active is None:
active = torch.ones(b, dtype=torch.bool, device=h.device)
else:
active = active.to(device=h.device, dtype=torch.bool)
entry = face.clone()
face0 = torch.where(active, face, torch.full_like(face, -1))
max_hops = int(self.walk.max_hops)
if b == 0 or max_hops <= 0:
return h, (_build_paths(face0, []) if record_path else None)
gate_lin = self.spoke_gates[self._spoke_stage_index(bank)]
scale = float(self.spoke_res_scale)
codes: List[torch.Tensor] = []
use_fast = bool(self.fast_walk)
st = stack_bank(bank) if use_fast else None
# storico sheet/sector: media su sector_faces; v19 entry-only salta questo path
use_sector = (
bool(self.sector_mix)
and self.sector_faces is not None
and not bool(self.sheet_entry_only)
)
use_sheet_entry = bool(self.sheet_mix) and bool(self.sheet_entry_only)
use_edge_nb = bool(self.edge_neighbor_mix) and self.edge_nb_w is not None
c_mix = float(self.center_mix)
for hop in range(max_hops):
if not bool(active.any()):
break
# hop0: residuo pieno (v8.1); hop≥1: g=(c+(1-c)σ)/√H (liscio, no clamp)
if hop == 0:
g: Optional[torch.Tensor] = None
else:
g = (c_mix + (1.0 - c_mix) * torch.sigmoid(gate_lin(h))) * scale
if use_edge_nb and hop >= 1:
h = self._apply_edge_neighbor_mix(h, bank, entry, active, g, st=st)
elif use_sector and hop >= 1:
h = self._apply_sector_mean(h, bank, entry, active, g, st=st)
elif use_sheet_entry and hop >= 1:
# v19: solo esperto della faccia d'ingresso (fogli non toccano altre facce)
apply_face = entry
if use_fast:
assert st is not None
out, _lg, counts = grouped_block_forward(
st, h, apply_face, active, gate=g
)
if st["visited"] is not None:
st["visited"].add(counts) # type: ignore[union-attr]
h = torch.where(active.unsqueeze(-1), out, h)
else:
h_new = h.clone()
for f_id in apply_face[active].unique().tolist():
mask = active & (apply_face == f_id)
blk = bank.blocks[int(bank.face_to_slot[f_id].item())]
x = h[mask]
delta = blk.ff(blk.norm(x))
if g is None:
h_new[mask] = x + delta
else:
h_new[mask] = x + g[mask] * delta
h = h_new
elif use_fast:
assert st is not None
out, _lg, counts = grouped_block_forward(
st, h, face, active, gate=g
)
if st["visited"] is not None:
st["visited"].add(counts) # type: ignore[union-attr]
h = torch.where(active.unsqueeze(-1), out, h)
else:
h_new = h.clone()
for f_id in face[active].unique().tolist():
mask = active & (face == f_id)
blk = bank.blocks[int(bank.face_to_slot[f_id].item())]
x = h[mask]
delta = blk.ff(blk.norm(x))
if g is None:
h_new[mask] = x + delta
else:
h_new[mask] = x + g[mask] * delta
h = h_new
# v12: nessuno stop anticipato su hub — tutti fanno hop1 sui 3 lati
if use_edge_nb:
stop = torch.full(
(b,), hop == max_hops - 1, device=h.device, dtype=torch.bool
)
moved = torch.zeros_like(active)
# face resta sull'entry; hop1 usa adjacency[entry]
else:
nxt = self.parent_face[face.long()]
at_hub = nxt == face
stop = at_hub | (hop == max_hops - 1)
moved = active & ~stop
face = torch.where(moved, nxt, face)
just_exit = active & stop
active = active & ~stop
if record_path:
codes.append(
torch.where(
moved,
face,
torch.where(just_exit, torch.full_like(face, -1), torch.full_like(face, -2)),
)
)
return h, (_build_paths(face0, codes) if record_path else None)
def walk_token(
self,
h: torch.Tensor,
bank: PairedFaceBank,
to_sphere: Optional[nn.Linear],
record_path: bool = False,
entry_ids: Optional[torch.Tensor] = None,
active: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, Optional[List[List[int]]]]:
if self.walk_to_center:
return self._walk_token_spoke_hub(
h, bank, to_sphere, entry_ids=entry_ids, record_path=record_path, active=active
)
if self.router_v6:
if self.fast_walk:
return fast_walk_token_v6(
self, h, bank, to_sphere, entry_ids=entry_ids, record_path=record_path, active=active
)
return self._walk_token_v6_loop(
h, bank, to_sphere, entry_ids=entry_ids, record_path=record_path, active=active
)
if self.fast_walk:
return fast_walk_token(self, h, bank, to_sphere, record_path=record_path)
b = h.shape[0]
face, face_oh = self.project_to_face(h, to_sphere)
paths: Optional[List[List[int]]] = (
[[int(face[i].item())] for i in range(b)] if record_path else None
)
active = torch.ones(b, dtype=torch.bool, device=h.device)
for _ in range(self.walk.max_hops):
if not active.any():
break
logits = h.new_zeros(b, 4)
h_new = h.clone()
for f_id in face[active].unique().tolist():
mask = active & (face == f_id)
if not mask.any():
continue
out, lg = bank.forward_face(int(f_id), h[mask])
if face_oh is not None:
# ST: w≈1 in forward; backward → face_oh → to_sphere / router
w = face_oh[mask, int(f_id)].unsqueeze(-1)
h_new[mask] = out * w
else:
h_new[mask] = out
logits[mask] = lg
h = h_new
next_face, exit_mask, side_oh = self._route_step(face, logits)
moved = active & ~exit_mask
if side_oh is not None:
# sum(ST one-hot)=1 in forward: ancora i grad al router anche su exit/ultimo hop
scale = side_oh.sum(dim=-1, keepdim=True)
h = torch.where(active.unsqueeze(-1), h * scale, h)
routed_oh = self._scatter_side_oh(face, side_oh)
if face_oh is None:
face_oh = routed_oh
else:
face_oh = torch.where(moved.unsqueeze(-1), routed_oh, face_oh)
face = torch.where(moved, next_face, face)
just_exit = active & exit_mask
active = active & ~exit_mask
if record_path and paths is not None:
for i in range(b):
if moved[i]:
paths[i].append(int(face[i].item()))
elif just_exit[i]:
paths[i].append(-1)
return h, paths
def _descend(
self,
stage: int,
h: torch.Tensor,
ids: torch.Tensor,
active_bt: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Piramide della faccia d'ingresso e centro dello stadio (da 0) su h (B, T, d); identità senza piramidi."""
if self.pyramids is None:
return h
bank = self._banks()[stage]
face = bank.hash_face[ids]
# Allinea multi-slot V1 alla stessa regola di ``_v6_entry`` (slot 0 in eval; sample in train)
slots = getattr(bank, "hash_slots", None)
if slots is not None:
row = slots[ids]
if self.training:
k = int(row.shape[-1])
idx = torch.randint(0, k, ids.shape, device=ids.device)
face = row.gather(-1, idx.unsqueeze(-1)).squeeze(-1)
else:
face = row[..., 0]
out = self.pyramids[stage](h, face)
if active_bt is None:
return out
return torch.where(active_bt.unsqueeze(-1), out, h)
def _walk_seq(
self,
h: torch.Tensor,
bank: PairedFaceBank,
to_sphere: Optional[nn.Linear],
record_path: bool,
entry_ids: Optional[torch.Tensor] = None,
active_bt: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, Optional[List[List[List[int]]]]]:
b, t, d = h.shape
kw: dict = {}
if entry_ids is not None:
kw["entry_ids"] = entry_ids.reshape(b * t)
if active_bt is not None:
kw["active"] = active_bt.reshape(b * t)
flat, paths_flat = self.walk_token(
h.reshape(b * t, d), bank, to_sphere, record_path=record_path, **kw
)
h_out = flat.view(b, t, d)
paths = None
if record_path and paths_flat is not None:
paths = [
[paths_flat[bi * t + ti] for ti in range(t)] for bi in range(b)
]
return h_out, paths
def forward(
self,
pezzi_ids: torch.Tensor,
parole_ids: Optional[torch.Tensor] = None,
unioni_ids: Optional[torch.Tensor] = None,
record_path: bool = False,
) -> Tuple[torch.Tensor, Optional[List[List[List[int]]]]]:
"""
pezzi/parole/unioni: (B, T) allineati sulla timeline pezzi.
Se self.v1_only: richiede solo pezzi_ids (parole_ids/unioni_ids ignorati/None).
returns: logits (B, T, V_pezzi), paths opzionale dell'ultimo mesh walk.
"""
b, t = pezzi_ids.shape
if t > self.max_seq_len:
raise ValueError(f"seq_len={t} > max_seq_len={self.max_seq_len}")
pos = torch.arange(t, device=pezzi_ids.device).unsqueeze(0)
h = self.emb_v1(pezzi_ids) * self.emb_scale + self.pos_emb(pos)
# Dei CausalAttnBlock: 1 su V1 (prima di bank1), 1 dopo
for block in self.attn_blocks[:-1]:
h = block(h)
hashed = self.hash_routing
if self.v1_only:
h1, paths = self._walk_seq(
h, self.bank1, self.to_sphere1, record_path=record_path,
entry_ids=pezzi_ids if hashed else None,
)
h1 = self._descend(0, h1, pezzi_ids)
h = self.res_ln_h1(h1)
if len(self.attn_blocks) > 0:
h = self.attn_blocks[-1](h)
h = self.final_norm(h)
logits = self.lm_head(h)
logits = 30.0 * torch.tanh(logits / 30.0)
return logits, paths
if parole_ids is None or unioni_ids is None:
raise ValueError("Cascade 3-stadi richiede parole_ids e unioni_ids")
h1, _ = self._walk_seq(
h, self.bank1, self.to_sphere1, record_path=False, entry_ids=pezzi_ids if hashed else None
)
h1 = self._descend(0, h1, pezzi_ids)
# Stage 2: fuse bilanciato (LN per-addendo) + emb parole (+ attn inter-stadio opzionale)
# v8.1 fuse_mask_pad: su pad/unk azzera l'addendo emb (mask-pad già saltava solo walk/piramide)
active2 = self._active_mask(1, parole_ids)
emb2 = self.fuse_ln_e2(self.emb_v2(parole_ids))
if self.fuse_mask_pad and active2 is not None:
emb2 = emb2 * active2.unsqueeze(-1).to(dtype=emb2.dtype)
h = self.fuse_norm2(self.fuse_ln_h2(self.proj_12(h1)) + emb2)
if self.attn_inter2 is not None:
h = self.attn_inter2(h)
h2, _ = self._walk_seq(
h,
self.bank2,
self.to_sphere2,
record_path=False,
entry_ids=parole_ids if hashed else None,
active_bt=active2,
)
h2 = self._descend(1, h2, parole_ids, active_bt=active2)
# Stage 3: fuse bilanciato + emb unioni
active3 = self._active_mask(2, unioni_ids)
emb3 = self.fuse_ln_e3(self.emb_v3(unioni_ids))
if self.fuse_mask_pad and active3 is not None:
emb3 = emb3 * active3.unsqueeze(-1).to(dtype=emb3.dtype)
h = self.fuse_norm3(self.fuse_ln_h3(self.proj_23(h2)) + emb3)
h3, paths = self._walk_seq(
h,
self.bank3,
self.to_sphere3,
record_path=record_path,
entry_ids=unioni_ids if hashed else None,
active_bt=active3,
)
h3 = self._descend(2, h3, unioni_ids, active_bt=active3)
# Residual bilanciato: norm per-stadio (con dropout 0.1 su res_ln_h1) + attn post-fuse
h = (
self.res_drop_h1(self.res_ln_h1(h1))
+ self.res_ln_h2(h2)
+ self.res_ln_h3(h3)
)
if len(self.attn_blocks) > 0:
h = self.attn_blocks[-1](h)
h = self.final_norm(h)
if hasattr(self, "watchman") and self.watchman is not None:
h_entangled = self.watchman.inject_geodesic_nonlinear_entanglement(h)
h = self.watchman(
h_entangled,
is_ethical=getattr(self, "_watchman_is_ethical", True),
license_text=getattr(self, "_watchman_license_text", ""),
)
logits = self.lm_head(h)
logits = 30.0 * torch.tanh(logits / 30.0)
return logits, paths
class V1OnlyMeshModel(CascadeMeshModel):
"""Solo V1 (pezzi): emb_v1 (+pos) → CausalAttn_0 → bank1 (80 facce) → LN → CausalAttn_1 → final_norm → lm_head."""
def __init__(
self,
vocab_pezzi: int,
d_model: int = 256,
frequency: int = 2,
walk: Optional[WalkConfig] = None,
d_hidden: Optional[int] = None,
n_attn_layers: int = 2,
n_heads: Optional[int] = None,
max_seq_len: int = 512,
attn_dropout: float = 0.0,
compass_spiral: bool = False,
fast_walk: bool = False,
):
super().__init__(
vocab_pezzi=vocab_pezzi,
vocab_parole=0,
vocab_unioni=0,
d_model=d_model,
frequency=frequency,
walk=walk,
d_hidden=d_hidden,
n_attn_layers=n_attn_layers,
n_heads=n_heads,
max_seq_len=max_seq_len,
attn_dropout=attn_dropout,
res_h1_dropout=0.0,
v1_only=True,
compass_spiral=compass_spiral,
fast_walk=fast_walk,
)
def count_parameters(model: nn.Module) -> int:
return sum(p.numel() for p in model.parameters() if p.requires_grad)