File size: 12,898 Bytes
0840a17 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 | """EBVT probe for Fractus — instrumentation non-invasive (hooks forward).
Transposition EBVT -> Fractus (voir EBVT-001 / white papers du projet EBVT).
L'arbre d'activation de Fractus est un arbre "caterpillar" :
chaine des n_layers blocs (hubs) + n_experts spokes experts par bloc.
Le signal de cut b' (le "usage cut balance") est le CUT d'utilisation :
spoke (bloc b, expert e) : b' = C[b,e]
(le cut de l'expert = ses events d'activation sur la fenetre)
arete de chaine (bloc k-1 | bloc k) : b' = min(L_k, M - L_k)
(L_k = events dans les blocs 0..k-1 ; M = total d'events)
EBVT mesure (definition exacte du projet, sur l'arbre d'activation) :
V_arch = somme des paires d'aretes adjacentes {e,f} |b'(e) - b'(f)|
Identite layer-cake exacte (verifiee a chaque report — "suspect the
instrument") : pour t = 1..H, P_t = # paires adjacentes avec exactement une
arete dans H_t = {e : b'(e) >= t}, alors
V_arch = somme_t P_t (H = max b')
Metriques :
H = max b' (hauteur de la hierarchie de specialisation :
le cut d'usage le plus profond)
persistence = #t avec P_t > 0 / H
(le profile tient-il sur toutes les echelles ? La reference
de forme du theoreme principal EBVT est la double etoilee
equilibree : une frontiere centrale SOUTENUE sur tous les
seuils — profile persistant.)
P_1 = largeur (frontiere active/inactif au seuil minimal)
support = # experts utilises
dominance = max count / total
Reference de forme : la double etoilee equilibree est la forme qui maximise
la variation de cut (theoreme principal EBVT, white paper). Ici la forme
"double etoilee d'activation" = deux blocs adjacents portant des cut d'usage
equilibres : l'arete de chaine centrale devient le cut le plus profond,
soutenu sur tous les seuils. (L'extremum sur le scaffold 16x128 est un
nouveau probleme ouvert de la famille — a declarer, pas a deviner.)
Le profile P_t est comparable d'un checkpoint a l'autre : H qui croit +
profile persistant = la deuxieme horloge du contenu, structurelle,
independante de topic_hits.
Non-invasif : register_forward_hook sur chaque PhaseRoutedMoE (API
officielle PyTorch, desactivable). Le hook re-calcule _compute_gates sur
les memes phases que le forward (deterministe, sans RNG) => selection
top-k IDENTIQUE a celle du moteur par construction. Zero ligne modifiee
dans le package fractus/.
Fenetre : appelez probe.reset() juste APRES le engine.reset_thought(1) de
la generation (c'est la meme convention que le compteur natif
engine._expert_hits du bloc 0, dont le report fait le cross-check).
"""
import os
import sys
import numpy as np
import torch
class EBVTError(RuntimeError):
pass
class EBVTProbe:
"""Compteur d'activations expert + metriques EBVT sur l'arbre d'activation."""
def __init__(self, engine, verbose: bool = False):
self.engine = engine
self.blocks = list(engine.blocks)
self.n_layers = len(self.blocks)
self.n_experts = self.blocks[0].moe.n_experts
for i, blk in enumerate(self.blocks):
if blk.moe.n_experts != self.n_experts:
raise EBVTError(
f"bloc {i} a {blk.moe.n_experts} experts "
f"(attendu {self.n_experts}) — growth detecte, re-attacher le probe")
self.counts = np.zeros((self.n_layers, self.n_experts), dtype=np.int64)
self._hooks = []
self._verbose = verbose
self.active = False
for bi, blk in enumerate(self.blocks):
self._hooks.append(blk.moe.register_forward_hook(self._make_hook(bi)))
self.active = True
# ------------------------------------------------------------- hooks --
def _make_hook(self, bi: int):
def hook(module, inp, out):
if not self.active:
return
with torch.no_grad():
gates = module._compute_gates(inp[1]) # (B, L, E)
idx = gates.topk(module.top_k, dim=-1).indices # (B, L, K)
flat = idx.flatten().cpu().numpy()
if flat.size:
u, c = np.unique(flat, return_counts=True)
self.counts[bi, u] += c
return hook
def detach(self):
"""Retire les hooks (desactive le comptage)."""
self.active = False
for h in self._hooks:
h.remove()
self._hooks = []
def native_counter(self):
"""Compteur natif du moteur (bloc 0 seul), ou None si absent."""
eh = getattr(self.engine, "_expert_hits", None)
if eh is None or eh.numel() != self.n_experts:
return None
return eh.detach().cpu().numpy().astype(np.int64).ravel().copy()
def reset(self):
"""Vide la fenetre. A appeler juste apres engine.reset_thought(1).
NB : reset_thought N'EFFACE PAS le compteur natif (il l'init seulement
s'il est absent) — c'est un compteur CUMULATIF du moteur. Le report
fait donc un DIFF : snapshot ici, difference au moment du report.
"""
self.counts.fill(0)
self._native_base = self.native_counter()
# --------------------------------------------------- math pure (arbre) --
@staticmethod
def tree_metrics(C) -> dict:
"""Metriques EBVT de la matrice d'activation C (n_layers, n_experts).
Arbre "caterpillar" : chaine des blocs + spokes experts.
b'(spoke b,e) = C[b,e] ; b'(chaine k-1|k) = min(L_k, M - L_k).
V = somme des paires d'aretes adjacentes |b'(e) - b'(f)|.
Identite layer-cake V = somme_t P_t verifiee (EBVTError si casse).
"""
C = np.asarray(C, dtype=np.int64)
if C.ndim != 2:
raise EBVTError(f"C doit etre (layers, experts), got {C.shape}")
B, E = C.shape
M = int(C.sum())
row = C.sum(axis=1) # events par bloc
# b' des aretes de chaine (B-1 aretes) : equilibre events gauche/droite.
if B > 1 and M > 0:
L = np.cumsum(row) # L[k] = events blocs 0..k
chain = np.minimum(L[:-1], M - L[:-1]) # arete (k-1 | k)
else:
chain = np.zeros(B - 1, dtype=np.int64)
# b' incidentes a chaque bloc (tableau 1D par hub).
inc = []
for b in range(B):
parts = [C[b]]
if b > 0:
parts.append(chain[b - 1:b])
if b < B - 1:
parts.append(chain[b:b + 1])
inc.append(np.concatenate(parts))
# --- V direct : pour chaque hub, somme des paires |x_j - x_i| ---
# x trie croissant, d elements : somme_{i<j} |x_j - x_i| =
# somme_i (2i - d + 1) x_i (formule du white paper, index 0)
V = 0
for b in range(B):
x = np.sort(inc[b])
d = x.size
V += int((x * (2 * np.arange(d) - d + 1)).sum())
H = int(max(int(C.max()), int(chain.max())))
base = {
"layers": B, "experts": E, "total": M,
"block_events": [int(v) for v in row.tolist()],
"chain_b": [int(v) for v in chain.tolist()],
}
if M == 0 or H == 0:
base.update({"V": V, "H": H, "P1": 0, "persistence": 0.0,
"support": int((C > 0).sum()), "dominance": 0.0,
"empty": True, "profile": [], "top12": []})
return base
# --- Layer-cake : P_t = somme_v h_t(v)(d(v) - h_t(v)) ---
ks = np.arange(1, H + 1) # (H,)
P = np.zeros(H, dtype=np.int64)
for b in range(B):
x = inc[b]
d = x.size
act = (x[None, :] >= ks[:, None]).sum(axis=1) # (H,)
P += act * (d - act)
V_cake = int(P.sum())
if V_cake != V:
raise EBVTError(
f"layer-cake self-check FAILED : V_direct={V} "
f"vs V_cake={V_cake} — instrument en defaut")
# Profile downsample : premiers t + grille uniforme (<= 32 pts) + dernier.
idx = np.unique(np.concatenate([
np.arange(min(8, H)),
np.linspace(0, H - 1, 24).astype(int),
[H - 1],
]))
profile = [[int(t + 1), int(P[t])] for t in idx]
flat = C.reshape(-1)
top_idx = np.argsort(-flat)[:12]
top12 = []
for i in top_idx:
if flat[i] > 0:
top12.append([int(i // E), int(i % E), int(flat[i])])
base.update({
"V": V,
"H": H,
"P1": int(P[0]),
"persistence": round(float((P > 0).sum()) / max(H, 1), 3),
"support": int((C > 0).sum()),
"dominance": round(float(flat.max()) / max(M, 1), 3),
"profile": profile,
"top12": top12,
"empty": False,
})
return base
# ------------------------------------------------------------- report --
def report(self, with_native_crosscheck: bool = True) -> dict:
"""Metriques EBVT de la fenetre courante (+ cross-check natif bloc 0)."""
m = self.tree_metrics(self.counts)
if with_native_crosscheck:
# Le compteur natif est CUMULATIF (reset_thought ne l'efface pas,
# voir reset()) : on compare le DIFF sur la fenetre au compte du
# probe sur le bloc 0.
native_now = self.native_counter()
base = getattr(self, "_native_base", None)
if native_now is None:
m["native_b0"] = {"match": "no_native_counter"}
else:
native_win = native_now - base if base is not None else native_now
probe_b0 = self.counts[0]
m["native_b0"] = {
"match": bool(np.array_equal(native_win, probe_b0)),
"native_total": int(native_win.sum()),
"probe_total": int(probe_b0.sum()),
"max_abs_diff": int(np.abs(native_win - probe_b0).max()),
}
return m
# -------------------------------------------------------------- selftest --
def _selftest() -> int:
"""Verifications d'instrument (sans model) : identite layer-cake sur
matrices aleatoires + cas figures (vide, spike, double-etoilee)."""
rng = np.random.default_rng(42)
fails = 0
# 1. Identite V_direct == V_cake sur 200 matrices aleatoires.
n_ok = 0
for trial in range(200):
B = int(rng.integers(2, 17))
E = int(rng.integers(1, 129))
C = rng.integers(0, 40, size=(B, E))
try:
m = EBVTProbe.tree_metrics(C)
assert m["V"] >= 0
# H = max b' >= max C ; l'identite layer-cake (si-dessus) est
# l'invariant crucial.
assert m["H"] >= int(C.max())
assert m["H"] <= max(int(C.sum() // 2), int(C.max()))
n_ok += 1
except EBVTError as e:
print(f" FAIL identite trial {trial} : {e}")
print(f" [1/4] identite layer-cake : {n_ok}/200 ok")
fails += 200 - n_ok
# 2. Matrice nulle => V == 0, H == 0 (baseline nulle).
m = EBVTProbe.tree_metrics(np.zeros((16, 128), dtype=np.int64))
ok = m["V"] == 0 and m["H"] == 0
detail = f"FAIL V={m['V']} H={m['H']}"
print(f" [2/4] vide => V==0, H==0 : {'ok' if ok else detail}")
fails += 0 if ok else 1
# 3. Spike (un expert chaud, 50 events) : b' = 50 sur un spoke, 0 partout
# ailleurs (chain : min(50, 0) = 0) => V = 50 * 128 = 6400, H = 50.
C = np.zeros((16, 128), dtype=np.int64)
C[0, 0] = 50
m = EBVTProbe.tree_metrics(C)
ok = m["V"] == 6400 and m["H"] == 50
detail = f"FAIL V={m['V']} H={m['H']}"
print(f" [3/4] spike => V==6400, H==50 : {'ok' if ok else detail}")
fails += 0 if ok else 1
# 4. Double-etoilee d'activation (2 blocs adjacents, cut equilibres) >
# single-hub a masse egale : l'arete de chaine centrale (b' = M/2)
# devient le cut le plus profond, la forme a variation maximale.
M = 256
C_a = np.zeros((16, 128), dtype=np.int64)
C_a[0, :64] = 4 # single hub : 256 events, bloc 0
C_d = np.zeros((16, 128), dtype=np.int64)
C_d[7, :64] = 2 # double etoilee : 2x128, blocs 7|8
C_d[8, :64] = 2
V_a = EBVTProbe.tree_metrics(C_a)["V"]
V_d = EBVTProbe.tree_metrics(C_d)["V"]
ok = V_d > V_a > 0
print(f" [4/4] double-etoilee {V_d} > single-hub {V_a} : "
f"{'ok' if ok else 'FAIL'}")
fails += 0 if ok else 1
print("SELFTEST", "PASS" if fails == 0 else f"FAIL ({fails})")
return fails
if __name__ == "__main__":
if "--selftest" in sys.argv:
sys.exit(_selftest())
print(__doc__)
|