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__)