anima-python 0.13.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,486 @@
1
+ # ==========================================================================
2
+ # ⛔ ENGINE-INTERNAL — DO NOT RUN DIRECTLY. 학습/직렬화는 cli/ 단일진입만:
3
+ # anima train | anima serialize (canonical=hexa cli/{train,serialize}.hexa).
4
+ # `python3 train/clm/model/clm_serialize_v2.py` 직접 실행 = 단일진입 우회(#2603) + DIRECTIONAL. cli/ import만 허용.
5
+ # ==========================================================================
6
+ #!/usr/bin/env python3
7
+ """Lane P REMEDY — torch -> .clm serializer matching core/clm_decode.hexa EXACTLY.
8
+
9
+ The prior CLM/model/clm_serialize.py emits a 2-track JSON-header format that
10
+ shares only the "CLM\\x01" magic with the ENGINE decoder and is NOT loadable
11
+ (see .verdicts/lane-p-clm/F-CLM-SERIALIZE-GAP.txt). This v2 serializer packs the
12
+ EXACT CLM\\x01 v0.2 byte layout that core/clm_decode.hexa::clm_decodable parses.
13
+
14
+ CLM\\x01 v0.2 byte layout (ground truth = core/clm_decode.hexa + the golden
15
+ reference state/laneg_d768_recover/reexport_d768_v2_fast.clm):
16
+
17
+ [0:4] MAGIC "CLM\\x01" = bytes 67,76,77,1
18
+ [4] nblk : u8 = 6 (production CLMConvMoE, E=2 / 1 trunk layer)
19
+ then nblk conv blocks, each:
20
+ u32 cout (LE)
21
+ u32 rest (LE) rest = Cin*K
22
+ int4 nibbles ((cout*rest+1)//2 bytes, 2 codes/byte, sym int4)
23
+ fp32 per-channel scale (cout float32 LE)
24
+ block order (decoder-fixed):
25
+ block0 ecW : cout=d, rest=d*K (K = rest0/d)
26
+ block1 tcW : cout=d, rest=d*K
27
+ block2 e0W : cout=d, rest=d*K
28
+ block3 e1W : cout=d, rest=d*K
29
+ block4 rW : cout=E, rest=d (router, K=1) -> E=2
30
+ block5 roW : cout=V, rest=d (readout, K=1) -> V=256
31
+ CLMX v0.2 trailer (REQUIRED — clm_decodable()=false without it):
32
+ "CLMX" = 67,76,77,88
33
+ n_ext : u8 = 11
34
+ then 11 ext arrays, EACH = u32 count (LE) + count float32 (LE), in ORDER:
35
+ embed (V*d), ecB (d), tcB (d), e0B (d), e1B (d), rB (E), roB (V),
36
+ tgG (d), tgB (d), noG (d), noB (d)
37
+ (tg* = trunk GroupNorm affine, no* = output GroupNorm affine)
38
+
39
+ int4-sym quant (decoder dequant w[co,j] = code * scale[co]):
40
+ per OUTPUT channel co: scale[co] = max_j(|w[co,j]|) / 7 (clamped >0)
41
+ code[co,j] = round(w[co,j]/scale[co]) clamped to [-7,7]
42
+
43
+ NIBBLE PACKING — must match the decoder byte-for-byte. The decoder reads, for a
44
+ flat code index i (i even):
45
+ code[i] = (byte & 0xF) - 8 # low nibble
46
+ code[i+1] = ((byte >> 4) & 0xF) - 8 # high nibble
47
+ So: low nibble holds the EVEN index code (+8 offset),
48
+ high nibble holds the ODD index code (+8 offset).
49
+ byte = ((code[i+1]+8) << 4) | (code[i]+8)
50
+ The trailing code of an odd-length run pads its high nibble with 0.
51
+
52
+ torch is imported lazily: pass a ckpt path / state_dict (torch present) OR a
53
+ plain dict of numpy/list weight arrays (torch absent) so the serializer is
54
+ testable without torch.
55
+ """
56
+ from __future__ import annotations
57
+
58
+ # ⛔ ENGINE-INTERNAL — DO NOT RUN DIRECTLY (단일진입 우회 #2603). cli/ import만 허용.
59
+ # 가드는 `from __future__` 뒤에 둔다 — 앞에 두면 SyntaxError(from __future__ must be at top).
60
+ import sys as _anima_entry_guard
61
+ if __name__ == "__main__":
62
+ _anima_entry_guard.exit("⛔ clm_serialize_v2.py 직접 실행 금지 — cli/ 단일진입(anima train/serialize, canonical=hexa) 경유. #2603")
63
+
64
+ import struct
65
+ from typing import Dict, Any
66
+
67
+ try:
68
+ import numpy as np
69
+ except Exception: # pragma: no cover - numpy is effectively always present
70
+ np = None
71
+
72
+ MAGIC = bytes([67, 76, 77, 1]) # "CLM\x01"
73
+ CLMX = bytes([67, 76, 77, 88]) # "CLMX"
74
+ CLMB = bytes([67, 76, 77, 66]) # "CLMB" — bind-readout (Hadamard) extension
75
+ INT4_SYM_MAX = 7
76
+
77
+ # readout-type flag (CLMB byte[4]). 0 = additive Conv1d(d->V) (default, NO CLMB
78
+ # section); 1 = bind/Hadamard g=u*v ; 2 = bind_linear (param-matched add) g=u+v.
79
+ RO_ADDITIVE = 0
80
+ RO_BIND_HADAMARD = 1
81
+ RO_BIND_LINEAR = 2
82
+
83
+ # state_dict key names of a CLMConvMoE configured E=2 / n_trunk_layers=1.
84
+ # (CLM/model/model.py: embed, embed_conv, trunk[0], moe.router, moe.experts[0/1],
85
+ # norm_out, readout). The decoder block order is ecW,tcW,e0W,e1W,rW,roW.
86
+ _KEYMAP = {
87
+ "ecW": "embed_conv.conv.weight",
88
+ "tcW": "trunk.0.conv.conv.weight",
89
+ "e0W": "moe.experts.0.conv.conv.weight",
90
+ "e1W": "moe.experts.1.conv.conv.weight",
91
+ "rW": "moe.router.weight",
92
+ "roW": "readout.weight",
93
+ # ext (CLMX) sources
94
+ "embed": "embed.weight",
95
+ "ecB": "embed_conv.conv.bias",
96
+ "tcB": "trunk.0.conv.conv.bias",
97
+ "e0B": "moe.experts.0.conv.conv.bias",
98
+ "e1B": "moe.experts.1.conv.conv.bias",
99
+ "rB": "moe.router.bias",
100
+ "roB": "readout.bias",
101
+ "tgG": "trunk.0.norm.weight",
102
+ "tgB": "trunk.0.norm.bias",
103
+ "noG": "norm_out.weight",
104
+ "noB": "norm_out.bias",
105
+ }
106
+
107
+ _BLOCK_ORDER = ["ecW", "tcW", "e0W", "e1W", "rW", "roW"]
108
+ _EXT_ORDER = ["embed", "ecB", "tcB", "e0B", "e1B", "rB", "roB",
109
+ "tgG", "tgB", "noG", "noB"]
110
+
111
+
112
+ # --------------------------------------------------------------------------- #
113
+ # v0.3 — GENERAL (n_trunk_layers L, n_experts E) block/ext ordering.
114
+ #
115
+ # The CLM\x01 format is ALREADY self-describing: nblk (byte[4]) + each block's
116
+ # (cout,rest) + n_ext + each ext count fully determine the layout. v0.2 only
117
+ # *hardcoded* the block-role assignment (L=1,E=2). v0.3 generalizes that
118
+ # assignment WITHOUT changing the byte grammar, so it is byte-exact backward
119
+ # compatible — a v0.3 file with L=1,E=2 is byte-identical to a v0.2 file.
120
+ #
121
+ # General block order (nblk = L + E + 3):
122
+ # ecW · tcW_0..tcW_{L-1} · e0W..e{E-1}W · rW(cout=E) · roW(cout=V)
123
+ # General ext order (n_ext = 2L + E + 6):
124
+ # embed · ecB · tcB_0..tcB_{L-1} · e0B..e{E-1}B · rB · roB ·
125
+ # tgG_0..tgG_{L-1} · tgB_0..tgB_{L-1} · noG · noB
126
+ #
127
+ # At L=1,E=2 this reduces EXACTLY to _BLOCK_ORDER / _EXT_ORDER above (the trunk
128
+ # bias tcB_0, then expert biases e0B,e1B, then rB,roB, then trunk GN tgG_0,tgB_0,
129
+ # then noG,noB) — byte-identical to v0.2.
130
+ #
131
+ # (L,E) recovery at decode time: E = cout of block[nblk-2] (router), V = cout of
132
+ # block[nblk-1] (readout), L = nblk - E - 3. No new bytes, no magic bump.
133
+ # --------------------------------------------------------------------------- #
134
+
135
+ # torch state_dict key templates for the general CLMConvMoE (model.py).
136
+ # embed.weight · embed_conv.conv.{weight,bias}
137
+ # trunk.{i}.conv.conv.{weight,bias} · trunk.{i}.norm.{weight,bias}
138
+ # moe.experts.{j}.conv.conv.{weight,bias} · moe.router.{weight,bias}
139
+ # norm_out.{weight,bias} · readout.{weight,bias}
140
+ def _general_block_keymap(L: int, E: int):
141
+ """logical slot -> torch key, for the L*E general block order."""
142
+ km = {"ecW": "embed_conv.conv.weight"}
143
+ for i in range(L):
144
+ km[f"tc{i}W"] = f"trunk.{i}.conv.conv.weight"
145
+ for j in range(E):
146
+ km[f"e{j}W"] = f"moe.experts.{j}.conv.conv.weight"
147
+ km["rW"] = "moe.router.weight"
148
+ km["roW"] = "readout.weight"
149
+ return km
150
+
151
+
152
+ def _general_ext_keymap(L: int, E: int):
153
+ km = {"embed": "embed.weight", "ecB": "embed_conv.conv.bias"}
154
+ for i in range(L):
155
+ km[f"tc{i}B"] = f"trunk.{i}.conv.conv.bias"
156
+ for j in range(E):
157
+ km[f"e{j}B"] = f"moe.experts.{j}.conv.conv.bias"
158
+ km["rB"] = "moe.router.bias"
159
+ km["roB"] = "readout.bias"
160
+ for i in range(L):
161
+ km[f"tg{i}G"] = f"trunk.{i}.norm.weight"
162
+ for i in range(L):
163
+ km[f"tg{i}B"] = f"trunk.{i}.norm.bias"
164
+ km["noG"] = "norm_out.weight"
165
+ km["noB"] = "norm_out.bias"
166
+ return km
167
+
168
+
169
+ def _general_block_order(L: int, E: int):
170
+ return (["ecW"] + [f"tc{i}W" for i in range(L)]
171
+ + [f"e{j}W" for j in range(E)] + ["rW", "roW"])
172
+
173
+
174
+ def _general_ext_order(L: int, E: int):
175
+ return (["embed", "ecB"]
176
+ + [f"tc{i}B" for i in range(L)]
177
+ + [f"e{j}B" for j in range(E)]
178
+ + ["rB", "roB"]
179
+ + [f"tg{i}G" for i in range(L)]
180
+ + [f"tg{i}B" for i in range(L)]
181
+ + ["noG", "noB"])
182
+
183
+
184
+ def _to_np(x) -> "np.ndarray":
185
+ """Coerce a torch.Tensor / numpy array / nested list to a float32 numpy array."""
186
+ if np is None:
187
+ raise RuntimeError("numpy is required for serialize_v2")
188
+ # torch tensor (duck-typed: has detach + cpu + numpy)
189
+ if hasattr(x, "detach"):
190
+ x = x.detach().cpu().float().numpy()
191
+ return np.asarray(x, dtype=np.float32)
192
+
193
+
194
+ def _conv_w_to_2d(w: "np.ndarray", name: str) -> "np.ndarray":
195
+ """Map a weight tensor to the (cout, rest=Cin*K) 2-D layout the decoder expects.
196
+
197
+ nn.Conv1d weight is (out, in, K). Decoder col-major within a block walks
198
+ j = ci*K + k (see clm_decode _clmd_conv1d xcol indexing: ci*K + k), and the
199
+ weight is read flat as w[co*rest + j] with j over Cin*K. nn.Conv1d's (out,in,K)
200
+ flattened C-order is exactly co, then (in, K) -> in*K + k = ci*K + k. So a
201
+ plain reshape(cout, -1) is byte-correct.
202
+ nn.Linear-style (router/readout are Conv1d k=1) -> (out, in, 1) -> (out, in).
203
+ """
204
+ w = np.asarray(w, dtype=np.float32)
205
+ if w.ndim == 3:
206
+ cout = w.shape[0]
207
+ return w.reshape(cout, -1)
208
+ if w.ndim == 2:
209
+ return w
210
+ raise ValueError(f"unexpected weight ndim {w.ndim} for {name}")
211
+
212
+
213
+ def _quant_block(w2d: "np.ndarray"):
214
+ """int4-sym per-output-channel quant. Returns (codes int8 [cout,rest], scale f32 [cout])."""
215
+ cout = w2d.shape[0]
216
+ amax = np.abs(w2d).max(axis=1)
217
+ scale = np.maximum(amax / INT4_SYM_MAX, 1e-12).astype(np.float32) # >0, never div0
218
+ codes = np.round(w2d / scale[:, None])
219
+ codes = np.clip(codes, -INT4_SYM_MAX, INT4_SYM_MAX).astype(np.int64)
220
+ return codes.reshape(-1), scale, cout, w2d.shape[1]
221
+
222
+
223
+ def _pack_nibbles(codes_flat: "np.ndarray") -> bytes:
224
+ """Pack flat int4 codes [-7,7] to bytes, 2 codes/byte, matching the decoder:
225
+ low nibble = even-index code (+8), high nibble = odd-index code (+8)."""
226
+ n = codes_flat.shape[0]
227
+ off = (codes_flat.astype(np.int64) + 8) & 0xF # 0..15
228
+ if n % 2 == 1:
229
+ off = np.concatenate([off, np.zeros(1, dtype=np.int64)])
230
+ lo = off[0::2] # even indices -> low nibble
231
+ hi = off[1::2] # odd indices -> high nibble
232
+ packed = ((hi << 4) | lo).astype(np.uint8)
233
+ return packed.tobytes()
234
+
235
+
236
+ def _pack_conv_block(w2d: "np.ndarray") -> bytes:
237
+ codes_flat, scale, cout, rest = _quant_block(w2d)
238
+ out = bytearray()
239
+ out += struct.pack("<I", cout)
240
+ out += struct.pack("<I", rest)
241
+ out += _pack_nibbles(codes_flat)
242
+ out += scale.astype("<f4").tobytes()
243
+ return bytes(out)
244
+
245
+
246
+ def _pack_ext(arr: "np.ndarray") -> bytes:
247
+ flat = np.asarray(arr, dtype=np.float32).reshape(-1)
248
+ return struct.pack("<I", flat.shape[0]) + flat.astype("<f4").tobytes()
249
+
250
+
251
+ def _resolve_state_dict(state_dict_or_ckpt, cfg) -> Dict[str, "np.ndarray"]:
252
+ """Return a {logical_or_torch_key: np.ndarray} mapping.
253
+
254
+ Accepts:
255
+ - a path (str) to a torch ckpt -> torch.load (lazy import)
256
+ - a torch state_dict (OrderedDict of tensors)
257
+ - a plain dict keyed by torch keys (e.g. 'embed.weight') OR by logical
258
+ slot names (e.g. 'embed','ecW',...) -> values numpy/list arrays.
259
+ """
260
+ sd = state_dict_or_ckpt
261
+ if isinstance(sd, str):
262
+ import torch # lazy
263
+ sd = torch.load(sd, map_location="cpu")
264
+ if isinstance(sd, dict) and "model" in sd and hasattr(sd.get("model"), "items"):
265
+ sd = sd["model"]
266
+ return sd
267
+
268
+
269
+ def _get(sd: Dict[str, Any], logical: str, keymap=None) -> "np.ndarray":
270
+ """Fetch a weight by logical slot name, accepting either logical keys or
271
+ torch state_dict keys in the source dict. `keymap` overrides _KEYMAP (used by
272
+ the general v0.3 path with per-(L,E) slot names)."""
273
+ km = keymap if keymap is not None else _KEYMAP
274
+ if logical in sd:
275
+ return _to_np(sd[logical])
276
+ tkey = km[logical]
277
+ if tkey in sd:
278
+ return _to_np(sd[tkey])
279
+ raise KeyError(
280
+ f"missing weight for slot '{logical}' (tried torch key '{tkey}'); "
281
+ f"available keys: {list(sd.keys())[:12]}..."
282
+ )
283
+
284
+
285
+ def _assert_e2_l1(cfg):
286
+ """The CORE decoder hardcodes E=2 experts + 1 trunk layer. Enforce it."""
287
+ if cfg is None:
288
+ return
289
+ n_e = getattr(cfg, "n_experts", None)
290
+ n_l = getattr(cfg, "n_trunk_layers", None)
291
+ if n_e is not None and n_e != 2:
292
+ raise ValueError(
293
+ f"core/clm_decode.hexa is FIXED to E=2 experts; cfg.n_experts={n_e}. "
294
+ f"Train with a CLMConfig(n_experts=2, n_trunk_layers=1) preset."
295
+ )
296
+ if n_l is not None and n_l != 1:
297
+ raise ValueError(
298
+ f"core/clm_decode.hexa is FIXED to 1 trunk layer; "
299
+ f"cfg.n_trunk_layers={n_l}. Train with n_trunk_layers=1."
300
+ )
301
+
302
+
303
+ def serialize_v2(state_dict_or_ckpt, cfg, out_path: str) -> str:
304
+ """Pack a CLMConvMoE (E=2 / 1-trunk) state_dict to a CLM\\x01 v0.2 .clm that
305
+ core/clm_decode.hexa loads. Returns out_path.
306
+
307
+ cfg may be a CLMConfig (asserted E=2/L1) or None (skip the assert — caller
308
+ vouches the dict already matches the E=2/L1 slot layout, e.g. synthetic test).
309
+ """
310
+ if np is None:
311
+ raise RuntimeError("numpy is required for serialize_v2")
312
+ _assert_e2_l1(cfg)
313
+ sd = _resolve_state_dict(state_dict_or_ckpt, cfg)
314
+
315
+ blob = bytearray()
316
+ blob += MAGIC
317
+ blob += struct.pack("<B", 6) # nblk = 6
318
+
319
+ for slot in _BLOCK_ORDER:
320
+ w = _get(sd, slot)
321
+ w2d = _conv_w_to_2d(w, slot)
322
+ blob += _pack_conv_block(w2d)
323
+
324
+ blob += CLMX
325
+ blob += struct.pack("<B", len(_EXT_ORDER)) # n_ext = 11
326
+ for slot in _EXT_ORDER:
327
+ blob += _pack_ext(_get(sd, slot))
328
+
329
+ with open(out_path, "wb") as f:
330
+ f.write(blob)
331
+ return out_path
332
+
333
+
334
+ def serialize_v3(state_dict_or_ckpt, n_trunk_layers: int, n_experts: int,
335
+ out_path: str) -> str:
336
+ """Pack a GENERAL CLMConvMoE(n_trunk_layers=L, n_experts=E, d, K) to a
337
+ CLM\\x01 v0.3 .clm that core/clm_decode.hexa loads.
338
+
339
+ v0.3 == v0.2 byte grammar, generalized block/ext counts (see the v0.3 note
340
+ above). At L=1,E=2 the output is BYTE-IDENTICAL to serialize_v2 (verified by
341
+ the round-trip gate). d, K, V are read from the weight shapes — no width
342
+ hardcode. Returns out_path.
343
+ """
344
+ if np is None:
345
+ raise RuntimeError("numpy is required for serialize_v3")
346
+ L, E = int(n_trunk_layers), int(n_experts)
347
+ if L < 1 or E < 1:
348
+ raise ValueError(f"need L>=1 and E>=1, got L={L} E={E}")
349
+ sd = _resolve_state_dict(state_dict_or_ckpt, None)
350
+ blob = _pack_main_blob(sd, L, E)
351
+ with open(out_path, "wb") as f:
352
+ f.write(blob)
353
+ return out_path
354
+
355
+
356
+ def _pack_main_blob(sd: Dict[str, Any], L: int, E: int) -> bytearray:
357
+ """Pack the MAIN CLM\\x01 body (MAGIC + nblk + conv blocks + CLMX + ext arrays)
358
+ for a general (L,E) CLMConvMoE, returning a bytearray (no CLMB section). Shared
359
+ by serialize_v3 (additive) and serialize_v3_bind (which appends a CLMB section).
360
+ The readout slot roW (cout=block[nblk-1].cout=V, rest) is taken from
361
+ 'readout.weight' / roB from 'readout.bias': for a bind model the caller has
362
+ routed Wo -> readout.{weight,bias} so the block is (V, k) instead of (V, d)
363
+ (the byte grammar is self-describing — rest is read from the header at decode)."""
364
+ bkm = _general_block_keymap(L, E)
365
+ ekm = _general_ext_keymap(L, E)
366
+ border = _general_block_order(L, E)
367
+ eorder = _general_ext_order(L, E)
368
+ nblk = len(border) # = L + E + 3
369
+ n_ext = len(eorder) # = 2L + E + 6
370
+
371
+ blob = bytearray()
372
+ blob += MAGIC
373
+ blob += struct.pack("<B", nblk)
374
+ for slot in border:
375
+ w = _get(sd, slot, bkm)
376
+ w2d = _conv_w_to_2d(w, slot)
377
+ blob += _pack_conv_block(w2d)
378
+ blob += CLMX
379
+ blob += struct.pack("<B", n_ext)
380
+ for slot in eorder:
381
+ blob += _pack_ext(_get(sd, slot, ekm))
382
+ return blob
383
+
384
+
385
+ def _bget(sd: Dict[str, Any], names) -> "np.ndarray":
386
+ """Fetch the first present key among `names` from a (torch- or logical-keyed)
387
+ state dict, coerced to a float32 numpy array. Accepts a 'base.'-stripped dict."""
388
+ for nm in names:
389
+ if nm in sd:
390
+ return _to_np(sd[nm])
391
+ raise KeyError(f"none of {names} present (have: {list(sd.keys())[:16]}...)")
392
+
393
+
394
+ def serialize_v3_bind(state_dict_or_ckpt, n_trunk_layers: int, n_experts: int,
395
+ readout_type: int, out_path: str) -> str:
396
+ """Pack a BIND-readout CLMConvMoE (the EXP-3 ARM-BIND architecture:
397
+ BindCLM = production trunk + Hadamard byte readout u=Wa(x), v=Wb(x),
398
+ g=u*v (readout_type=1) or u+v (readout_type=2), logits=Wo(g)) to a CLM\\x01
399
+ .clm that core/clm_decode.hexa loads.
400
+
401
+ BYTE LAYOUT (backward-compatible, in-place extension — no magic bump):
402
+ · MAIN body == serialize_v3, EXCEPT the readout slot roW carries **Wo**
403
+ (cout=V, rest=k) and roB carries **Wo.bias** (V). All trunk/embed/MoE
404
+ blocks+ext are IDENTICAL to the additive ctrl arm (only the readout
405
+ differs — the EXP-3 design intent). The self-describing (d,E,V,L,K)
406
+ recovery is UNCHANGED (E=block[nblk-2].cout, V=block[nblk-1].cout, the
407
+ readout block's `rest` field = k).
408
+ · a CLMB trailer is appended AFTER the CLMX ext arrays:
409
+ "CLMB" (67,76,77,66)
410
+ readout_type:u8 (1=Hadamard u*v, 2=linear u+v)
411
+ Wa conv block (cout=k, rest=d, int4-sym + per-channel fp32 scale)
412
+ Wb conv block (cout=k, rest=d, int4-sym + per-channel fp32 scale)
413
+ Wa_bias ext (u32 k + k*f32)
414
+ Wb_bias ext (u32 k + k*f32)
415
+ An additive .clm has NO CLMB section, so the decoder defaults
416
+ readout_type=0 (a_engine_native_learning backward-compat: existing
417
+ additive .clm decode byte-identically).
418
+
419
+ Accepts a torch state_dict (BindCLM.state_dict(): base.<trunk...> + Wa/Wb/Wo
420
+ .{weight,bias}) OR a logical dict (Wa,WaB,Wb,WbB,Wo,WoB + v3 slot names).
421
+ Returns out_path."""
422
+ if np is None:
423
+ raise RuntimeError("numpy is required for serialize_v3_bind")
424
+ L, E = int(n_trunk_layers), int(n_experts)
425
+ rt = int(readout_type)
426
+ if L < 1 or E < 1:
427
+ raise ValueError(f"need L>=1 and E>=1, got L={L} E={E}")
428
+ if rt not in (RO_BIND_HADAMARD, RO_BIND_LINEAR):
429
+ raise ValueError(f"readout_type must be 1 (hadamard) or 2 (linear), got {rt}")
430
+ sd = _resolve_state_dict(state_dict_or_ckpt, None)
431
+
432
+ # normalize: strip BindCLM's 'base.' trunk prefix into bare CLMConvMoE keys.
433
+ norm: Dict[str, Any] = {}
434
+ for k, v in sd.items():
435
+ nk = k[5:] if k.startswith("base.") else k
436
+ norm[nk] = v
437
+
438
+ # bind readout weights (Wa/Wb -> CLMB ; Wo -> routed into the roW/roB slots).
439
+ Wa = _bget(norm, ["Wa.weight", "Wa"])
440
+ WaB = _bget(norm, ["Wa.bias", "WaB"])
441
+ Wb = _bget(norm, ["Wb.weight", "Wb"])
442
+ WbB = _bget(norm, ["Wb.bias", "WbB"])
443
+ Wo = _bget(norm, ["Wo.weight", "Wo"])
444
+ WoB = _bget(norm, ["Wo.bias", "WoB"])
445
+ norm["readout.weight"] = Wo # (V, k, 1) -> roW block (cout=V, rest=k)
446
+ norm["readout.bias"] = WoB # (V,) -> roB ext
447
+
448
+ blob = _pack_main_blob(norm, L, E)
449
+ blob += CLMB
450
+ blob += struct.pack("<B", rt)
451
+ blob += _pack_conv_block(_conv_w_to_2d(Wa, "Wa")) # (k, d)
452
+ blob += _pack_conv_block(_conv_w_to_2d(Wb, "Wb")) # (k, d)
453
+ blob += _pack_ext(WaB) # (k,)
454
+ blob += _pack_ext(WbB) # (k,)
455
+
456
+ with open(out_path, "wb") as f:
457
+ f.write(blob)
458
+ return out_path
459
+
460
+
461
+ def serialize(state_dict_or_ckpt, n_trunk_layers: int, n_experts: int,
462
+ out_path: str) -> str:
463
+ """Unified entry: routes to serialize_v3 (general). For L=1,E=2 the v3 path
464
+ is byte-identical to serialize_v2, so this is the single serializer for ANY
465
+ (L,E,d). cfg-free — caller states (L,E) explicitly."""
466
+ return serialize_v3(state_dict_or_ckpt, n_trunk_layers, n_experts, out_path)
467
+
468
+
469
+ if __name__ == "__main__": # pragma: no cover
470
+ import argparse, json, os, sys
471
+ ap = argparse.ArgumentParser(description="torch CLMConvMoE -> CLM\\x01 v0.2 .clm")
472
+ ap.add_argument("--ckpt", required=True, help="torch state_dict ckpt path")
473
+ ap.add_argument("--out", required=True)
474
+ ap.add_argument("--n-experts", type=int, default=2)
475
+ ap.add_argument("--n-trunk-layers", type=int, default=1)
476
+ ap.add_argument("--d-model", type=int, default=768)
477
+ ap.add_argument("--kernel-size", type=int, default=3)
478
+ a = ap.parse_args()
479
+ _HERE = os.path.dirname(os.path.abspath(__file__))
480
+ if _HERE not in sys.path:
481
+ sys.path.insert(0, _HERE)
482
+ from model import CLMConfig # noqa
483
+ cfg = CLMConfig(n_experts=a.n_experts, n_trunk_layers=a.n_trunk_layers,
484
+ d_model=a.d_model, kernel_size=a.kernel_size)
485
+ p = serialize_v2(a.ckpt, cfg, a.out)
486
+ print(json.dumps({"out": p, "bytes": os.path.getsize(p)}))
anima_py/core/clml.py ADDED
@@ -0,0 +1,155 @@
1
+ """core/clml.py — fork-A read-side context-pooling LANE (CLML), CORE-owned SSOT.
2
+
3
+ The ρ·weave recombination wall (was G1) is READOUT-ROUTING, not trunk representation-capacity
4
+ (H_9235 H2-lite): both concepts of a two-concept context ARE linearly present (mean-pool recovery
5
+ 0.95/0.97), but the last-position (generation) readout only carries the most-recent concept
6
+ (last-position recovery A=0.07, B=1.00 — pure receptive-field decay, language-independent). The
7
+ CLML lane fixes the ROUTE without touching the trunk: it reads the trunk's per-position penultimate
8
+ `yn`, causally pools the full context (where the earlier concept survives), and adds a gated,
9
+ tether-clipped logit bias at composed positions. $0 pre-check PROVEN: mean-pool→gelu bottleneck
10
+ routes both concepts to held-out XOR (0.98) where last-position (0.47) and a linear head (0.43)
11
+ FAIL (state/g1_gamma_binding_lane/fork_a_precheck.py).
12
+
13
+ DISJOINT (a_substrate_disjoint): read-only tap of `yn` + an additive logit bias whose gate is
14
+ LEARNED to open only at composed positions — trunk weights, emit-drive lane, Ψ-tension untouched.
15
+ Absent trailer <=> byte-identical to today's .clm (loaders passthrough on short/absent read).
16
+
17
+ CORE-owned, ONE file (mirrors core/slw.py): lane_apply (torch-free numpy inference mirror,
18
+ byte-parity partner of core/decode.hexa) · pack_clml/read_clml ("CLML" trailer codec) · CLMLModule
19
+ (torch training module, DIRECTIONAL, defined only when torch importable so inference stays torch-free).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import struct
25
+ import numpy as np
26
+
27
+ # ── "CLML" trailer magic (mirrors the CLMB/SLW trailer convention) ──────────────
28
+ CLML_MAGIC = bytes([67, 76, 77, 76]) # "CLML"
29
+
30
+
31
+ def _sigmoid(x):
32
+ return 1.0 / (1.0 + np.exp(-np.clip(x, -30, 30)))
33
+
34
+
35
+ def _gelu(x):
36
+ return 0.5 * x * (1.0 + np.tanh(0.7978845608028654 * (x + 0.044715 * x**3)))
37
+
38
+
39
+ # --------------------------------------------------------------------------- #
40
+ # (a) numpy inference mirror — torch-free, byte-parity target for core/decode.hexa
41
+ # --------------------------------------------------------------------------- #
42
+ def lane_apply(yn, logits, clml):
43
+ """Add the CLML read-side pooling bias to the readout logits.
44
+
45
+ yn: (T, d) float — pre-readout trunk penultimate (the _fwd_trunk output; the SAME
46
+ representation the $0 pre-check pooled — pre-SLW-slot).
47
+ logits: (T, V) float — the trunk readout logits to condition.
48
+ clml: dict from read_clml (W1 (d,r), b1 (r,), W2 (r,V), w_g (2d,), b_g scalar, tau, r).
49
+ Returns (T, V): logits + clip( g_t · gelu(c_t·W1+b1)·W2 , ±tau ), where c_t is the CAUSAL
50
+ cumulative mean of yn (0-param pool) and g_t = sigmoid(w_g·[yn_t;c_t]+b_g) is the learned
51
+ composed-position gate. lane_type=0 or clml=None => exact passthrough (logits unchanged).
52
+ Op order IDENTICAL to CLMLModule.forward (2-production parity)."""
53
+ if clml is None or int(clml.get("lane_type", 0)) == 0:
54
+ return logits
55
+ yn = np.asarray(yn); dt = logits.dtype
56
+ T, d = yn.shape
57
+ csum = np.cumsum(yn, axis=0)
58
+ counts = np.arange(1, T + 1, dtype=yn.dtype).reshape(T, 1)
59
+ c = csum / counts # (T,d) causal mean pool
60
+ gate = _sigmoid(np.concatenate([yn, c], axis=1) @ clml["w_g"] + clml["b_g"]) # (T,)
61
+ z = _gelu(c @ clml["W1"] + clml["b1"]) # (T,r) gelu bottleneck
62
+ delta = (z @ clml["W2"]) * gate[:, None] # (T,V) gated logit bias
63
+ delta = np.clip(delta, -float(clml["tau"]), float(clml["tau"]))
64
+ return (logits + delta).astype(dt)
65
+
66
+
67
+ # --------------------------------------------------------------------------- #
68
+ # (b) "CLML" trailer codec — write (serialize) + read (loaders) · LE f32
69
+ # header: CLML magic · lane_type u8 · r u32 · tau f32
70
+ # arrays (row-major): W1[d·r] b1[r] W2[r·V] w_g[2d] b_g[1] (d,V from the model)
71
+ # --------------------------------------------------------------------------- #
72
+ _ARR_ORDER = ("W1", "b1", "W2", "w_g", "b_g")
73
+
74
+
75
+ def pack_clml(w: dict) -> bytes:
76
+ """Pack a CLML weight dict into appended trailer bytes. `w` = W1 (d,r), b1 (r,),
77
+ W2 (r,V), w_g (2d,), b_g scalar, lane_type, r, tau. Absent trailer <=> byte-identical
78
+ additive/SLW model, so a writer only calls this when the model actually has a lane."""
79
+ out = bytearray()
80
+ out += CLML_MAGIC
81
+ out += struct.pack("<BIf", int(w.get("lane_type", 1)), int(w["r"]), float(w["tau"]))
82
+ for name in _ARR_ORDER:
83
+ out += np.asarray(w[name], dtype="<f4").reshape(-1).tobytes()
84
+ return bytes(out)
85
+
86
+
87
+ def read_clml(buf: bytes, off: int, d: int, V: int):
88
+ """Read a CLML trailer at byte offset `off`. `d`,`V` come from the model (loader passes
89
+ W["d"], W["V"]). Returns (clml_dict, new_off) or (None, off) if absent/short (passthrough-
90
+ safe, same guard idiom as read_slw)."""
91
+ if off < 0 or off + 9 > len(buf) or buf[off:off + 4] != CLML_MAGIC:
92
+ return None, off
93
+ p = off + 4
94
+ lane_type = buf[p]; p += 1
95
+ (r,) = struct.unpack_from("<I", buf, p); p += 4
96
+ (tau,) = struct.unpack_from("<f", buf, p); p += 4
97
+ def take(n, shape):
98
+ nonlocal p
99
+ arr = np.frombuffer(buf, "<f4", n, p).reshape(shape).copy(); p += n * 4
100
+ return arr
101
+ clml = {"lane_type": int(lane_type), "r": int(r), "tau": float(tau)}
102
+ clml["W1"] = take(d * r, (d, r))
103
+ clml["b1"] = take(r, (r,))
104
+ clml["W2"] = take(r * V, (r, V))
105
+ clml["w_g"] = take(2 * d, (2 * d,))
106
+ clml["b_g"] = float(np.frombuffer(buf, "<f4", 1, p)[0]); p += 4
107
+ return clml, p
108
+
109
+
110
+ def clml_weights_from_torch(mod) -> dict:
111
+ """Extract the CLML weight dict (numpy) from a trained torch CLMLModule."""
112
+ def n(t):
113
+ return t.detach().cpu().numpy().astype("<f4")
114
+ return {
115
+ "lane_type": 1, "r": int(mod.r), "tau": float(mod.tau),
116
+ "W1": n(mod.W1.weight).T, "b1": n(mod.W1.bias), # nn.Linear weight is (out,in)=(r,d) → (d,r)
117
+ "W2": n(mod.W2.weight).T, # (V,r) → (r,V)
118
+ "w_g": n(mod.w_g.weight).reshape(-1), "b_g": n(mod.w_g.bias),
119
+ }
120
+
121
+
122
+ # --------------------------------------------------------------------------- #
123
+ # (c) torch training module (DIRECTIONAL) — defined only when torch is present so
124
+ # the inference import (lane_apply / read_clml) stays torch-free (pod-clean).
125
+ # --------------------------------------------------------------------------- #
126
+ try:
127
+ import torch as _torch
128
+ import torch.nn as _nn
129
+ _HAS_TORCH = True
130
+ except Exception: # pragma: no cover - inference pod has no torch
131
+ _HAS_TORCH = False
132
+
133
+ if _HAS_TORCH:
134
+ class CLMLModule(_nn.Module):
135
+ """Learnable read-side context-pooling lane. forward(yn:(B,T,d), logits:(B,T,V)) ->
136
+ (B,T,V). Op order MIRRORS core/clml.lane_apply exactly for 2-production parity.
137
+ Trains ONLY {W1,b1,W2,w_g,b_g} — the trunk is frozen (fork A = read-side, DISJOINT)."""
138
+
139
+ def __init__(self, d, V, r=128, tau=8.0):
140
+ super().__init__()
141
+ self.d, self.V, self.r, self.tau = d, V, r, tau
142
+ self.W1 = _nn.Linear(d, r)
143
+ self.W2 = _nn.Linear(r, V, bias=False)
144
+ self.w_g = _nn.Linear(2 * d, 1)
145
+
146
+ def forward(self, yn, logits):
147
+ B, T, d = yn.shape
148
+ csum = _torch.cumsum(yn, dim=1)
149
+ counts = _torch.arange(1, T + 1, device=yn.device, dtype=yn.dtype).view(1, T, 1)
150
+ c = csum / counts
151
+ gate = _torch.sigmoid(self.w_g(_torch.cat([yn, c], dim=-1))) # (B,T,1)
152
+ z = _torch.nn.functional.gelu(self.W1(c))
153
+ delta = self.W2(z) * gate
154
+ delta = _torch.clamp(delta, -self.tau, self.tau)
155
+ return logits + delta