topoexploit 0.1.0__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.
topogpt3/model.py ADDED
@@ -0,0 +1,3824 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ TopoGPT2: Quaternion-Enhanced Topological Transformer Language Model
4
+
5
+ Author: Gris Iscomeback
6
+ Email: grisiscomeback@gmail.com
7
+ License: GPL v3
8
+
9
+ Mejoras sobre topogpt.py:
10
+ - Álgebra de cuaterniones completa (QuaternionLinear, QuaternionSpectralLayer)
11
+ con producto de Hamilton en el dominio de frecuencia para capturar la
12
+ espectrografía de los datos con kernels reales e imaginarios cruzados.
13
+ - SpectralAutoencoder: encoder/decoder espectral que comprime y reconstruye
14
+ las representaciones en el dominio de frecuencia.
15
+ - QuaternionTorusBrain VECTORIZADA (sin bucles sobre seq_len): proyección
16
+ geométrica sobre el toro con asignación blanda usando distancias circulares,
17
+ message-passing con rotaciones de cuaterniones.
18
+ - 8 nodos (RADIAL=2 × ANGULAR=4), 4 ángulos, 2 radiales (spec del usuario).
19
+ - Rotary Position Embeddings (RoPE).
20
+ - Flash-attention (scaled_dot_product_attention de PyTorch 2.0+).
21
+ - RMSNorm en lugar de LayerNorm (estilo LLaMA).
22
+ - Tokenizador BPE via tiktoken (vocab GPT-2, 50k tokens).
23
+ - Descargador de corpus: TinyStories, WikiText-103, raw file.
24
+ - Entrenamiento con AMP (mixed precision) + acumulación de gradientes.
25
+ - Presets de escala: micro, small, medium, gpt2.
26
+ """
27
+
28
+ import torch
29
+ import torch.nn as nn
30
+ import torch.nn.functional as F
31
+ from torch.utils.checkpoint import checkpoint as grad_ckpt
32
+ from safetensors.torch import save_file as st_save, load_file as st_load
33
+ import numpy as np
34
+ import math
35
+ import os
36
+ import sys
37
+ import time
38
+ from pathlib import Path
39
+ import json
40
+ import hashlib
41
+ import logging
42
+ import warnings
43
+ import argparse
44
+ from datetime import datetime
45
+ from typing import Dict, Tuple, Optional, List, Any, Union
46
+ from dataclasses import dataclass, asdict
47
+ from collections import deque
48
+
49
+ warnings.filterwarnings('ignore')
50
+
51
+ # ============================================================================
52
+ # CONFIGURACIÓN
53
+ # ============================================================================
54
+
55
+ @dataclass
56
+ class TopoGPT2Config:
57
+ """Configuración completa para TopoGPT2."""
58
+
59
+ # --- Dispositivo ---
60
+ DEVICE: str = 'cuda' if torch.cuda.is_available() else 'cpu'
61
+ RANDOM_SEED: int = 42
62
+ USE_AMP: bool = True
63
+
64
+ # --- Escala del modelo: 'micro' | 'small' | 'medium' | 'gpt2' | 'custom' ---
65
+ SCALE: str = 'small'
66
+
67
+ # --- Vocabulario (GPT-2 BPE via tiktoken) ---
68
+ VOCAB_SIZE: int = 50257
69
+ MAX_SEQ_LEN: int = 512
70
+
71
+ # --- Arquitectura (sobrescritos por SCALE salvo SCALE='custom') ---
72
+ D_MODEL: int = 256 # debe ser divisible por 4 (cuaterniones) y N_HEADS
73
+ N_HEADS: int = 8
74
+ N_KV_HEADS: int = 0 # GQA: cabezas K/V (0 = N_HEADS//4, -1 = MHA completo)
75
+ N_LAYERS: int = 6
76
+ DROPOUT: float = 0.1
77
+
78
+ # --- MoE (Mixture of Experts) ---
79
+ MOE_ENABLED: bool = True # activar MoE en cada capa
80
+ N_EXPERTS: int = 4 # expertos SwiGLU por capa
81
+ MOE_TOP_K: int = 2 # expertos activos por token (sparse)
82
+ MOE_AUX_LOSS_WEIGHT: float = 0.01 # load-balancing loss
83
+
84
+ # --- Topología del toro (spec usuario: 8 nodos = 4 ang × 2 rad) ---
85
+ TORUS_GRID_SIZE: int = 8 # Grid para SpectralLayer (8×8 FFT2)
86
+ TORUS_RADIAL_BINS: int = 2 # 2 niveles radiales
87
+ TORUS_ANGULAR_BINS: int = 4 # 4 ángulos (= 4 nodos por anillo)
88
+ # Total nodos en el grafo toro: 2 × 4 = 8
89
+
90
+ # --- Espectral / Autoencoder ---
91
+ SPECTRAL_LATENT_RATIO: float = 0.5 # latent_dim = D_MODEL * ratio
92
+ SPECTRAL_KERNEL_INIT_SCALE: float = 0.02
93
+ NUM_SPECTRAL_LAYERS: int = 2
94
+ AE_RECON_WEIGHT: float = 0.01 # peso de la pérdida de reconstrucción
95
+
96
+ # --- Training ---
97
+ BATCH_SIZE: int = 4 # reduced to fit 6GB VRAM
98
+ GRAD_ACCUM_STEPS: int = 8 # effective batch = 32
99
+ LEARNING_RATE: float = 3e-4
100
+ WEIGHT_DECAY: float = 0.1
101
+ EPOCHS: int = 10
102
+ WARMUP_RATIO: float = 0.05
103
+ GRADIENT_CLIP_NORM: float = 1.0
104
+ GRADIENT_CHECKPOINTING: bool = True # trades compute for memory
105
+
106
+ # --- Curriculum Learning ---
107
+ CURRICULUM_ENABLED: bool = True
108
+ CURRICULUM_SHORT_LINES: int = 50 # tier 1: short files
109
+ CURRICULUM_MED_LINES: int = 300 # tier 2: medium files
110
+
111
+ # --- Sliding Window Attention ---
112
+ ATTN_WINDOW: int = 0 # 0 = full attention; N = sliding window of N tokens
113
+
114
+ # --- Latent Memory Tokens (context compression) ---
115
+ N_MEMORY_TOKENS: int = 0 # number of learnable memory tokens for context compression
116
+ MEMORY_SEGMENT_LEN: int = 0 # segment length for memory-token processing (0 = disabled)
117
+
118
+ # --- Progressive Sequence Length ---
119
+ PROGRESSIVE_SEQ: bool = True # warm up with shorter sequences
120
+ PROGRESSIVE_SEQ_STEPS: Tuple[int, int, int] = (128, 256, 512) # len per phase
121
+ PROGRESSIVE_SEQ_EPOCHS: Tuple[int, int, int] = (3, 3, 4) # epochs per phase
122
+
123
+ # --- Text Quality Filters ---
124
+ FILTER_MAX_LINE_LEN: int = 500 # files with lines >500 chars are suspicious
125
+ FILTER_ENTROPY_MIN: float = 3.5 # below this = repetitive / auto-generated
126
+ FILTER_ENTROPY_MAX: float = 6.5 # above this = binary / encrypted
127
+ FILTER_SPECIAL_TOKEN_RATIO_MAX: float = 0.25 # max fraction of whitespace-only tokens
128
+ FILTER_ENABLE_DEDUP: bool = True # skip files with identical content hash
129
+
130
+ # --- Quantization for inference (6GB VRAM) ---
131
+ QUANTIZE_EMBEDDINGS: bool = True # int8 on embed + lm_head
132
+ QUANTIZE_FFN: bool = False # int4 on FFN weights (experimental)
133
+ QUANT_EMBED_BITS: int = 8
134
+
135
+ # --- Speculative Decoding ---
136
+ SPEC_DECODE_ENABLED: bool = False
137
+ SPEC_DECODE_DRAFT_SCALE: str = 'micro' # draft model scale (4x smaller)
138
+ SPEC_DECODE_K: int = 5 # draft tokens per speculation step
139
+
140
+ # --- Corpus ---
141
+ CORPUS: str = 'directory' # 'directory' | 'file'
142
+ CORPUS_ROOT: str = './src'
143
+ CORPUS_FILE: str = '' # Path if CORPUS='file'
144
+ CORPUS_MIN_FILE_CHARS: int = 30 # minimum chars to include a file
145
+ DATA_DIR: str = 'data_topogpt2'
146
+ MAX_TRAIN_TOKENS: int = 500_000_000 # max tokens per epoch (0 = unlimited)
147
+
148
+ # --- Checkpoints ---
149
+ CHECKPOINT_DIR: str = 'checkpoints_topogpt2'
150
+ CHECKPOINT_INTERVAL_MINUTES: int = 10
151
+ MAX_CHECKPOINTS: int = 5
152
+
153
+ # --- Logging ---
154
+ LOG_INTERVAL_STEPS: int = 100
155
+ EVAL_INTERVAL_STEPS: int = 500
156
+ LOG_LEVEL: str = 'INFO'
157
+
158
+ # --- Temperatura termodinámica (heredada) ---
159
+ T_INIT: float = 1.0
160
+
161
+ def __post_init__(self):
162
+ presets = {
163
+ 'micro': dict(D_MODEL=64, N_HEADS=4, N_LAYERS=2, MAX_SEQ_LEN=256, ATTN_WINDOW=64, N_MEMORY_TOKENS=16, MEMORY_SEGMENT_LEN=64),
164
+ 'small': dict(D_MODEL=256, N_HEADS=8, N_LAYERS=6, MAX_SEQ_LEN=512, ATTN_WINDOW=128, N_MEMORY_TOKENS=32, MEMORY_SEGMENT_LEN=128),
165
+ 'medium': dict(D_MODEL=512, N_HEADS=8, N_LAYERS=12, MAX_SEQ_LEN=1024, ATTN_WINDOW=256, N_MEMORY_TOKENS=64, MEMORY_SEGMENT_LEN=256),
166
+ 'large': dict(D_MODEL=512, N_HEADS=8, N_LAYERS=13, MAX_SEQ_LEN=1024, ATTN_WINDOW=256, N_MEMORY_TOKENS=64, MEMORY_SEGMENT_LEN=256),
167
+ 'xlarge': dict(D_MODEL=576, N_HEADS=8, N_LAYERS=14, MAX_SEQ_LEN=1024, ATTN_WINDOW=256, N_MEMORY_TOKENS=64, MEMORY_SEGMENT_LEN=256),
168
+ 'gpt2': dict(D_MODEL=768, N_HEADS=12, N_LAYERS=12, MAX_SEQ_LEN=2048, ATTN_WINDOW=256, N_MEMORY_TOKENS=64, MEMORY_SEGMENT_LEN=256),
169
+ }
170
+ if self.SCALE in presets:
171
+ for k, v in presets[self.SCALE].items():
172
+ setattr(self, k, v)
173
+
174
+ assert self.D_MODEL % 4 == 0, "D_MODEL debe ser divisible por 4 (cuaterniones)"
175
+ assert self.D_MODEL % self.N_HEADS == 0, "D_MODEL debe ser divisible por N_HEADS"
176
+ self.D_QUAT = self.D_MODEL // 4
177
+ self.D_HEAD = self.D_MODEL // self.N_HEADS
178
+ self.SPECTRAL_LATENT_DIM = max(16, int(self.D_MODEL * self.SPECTRAL_LATENT_RATIO))
179
+ self.N_TORUS_NODES = self.TORUS_RADIAL_BINS * self.TORUS_ANGULAR_BINS
180
+ # GQA: resolver N_KV_HEADS
181
+ if self.N_KV_HEADS == 0:
182
+ # Auto: N_HEADS // 4, mínimo 1, divisor de N_HEADS
183
+ kv = max(1, self.N_HEADS // 4)
184
+ while self.N_HEADS % kv != 0:
185
+ kv -= 1
186
+ self.N_KV_HEADS = kv
187
+ elif self.N_KV_HEADS == -1:
188
+ self.N_KV_HEADS = self.N_HEADS # MHA estándar
189
+ assert self.N_HEADS % self.N_KV_HEADS == 0, \
190
+ "N_HEADS debe ser divisible por N_KV_HEADS"
191
+ self.GQA_GROUPS = self.N_HEADS // self.N_KV_HEADS
192
+
193
+
194
+ def setup_logger(name: str, level: str = 'INFO') -> logging.Logger:
195
+ logger = logging.getLogger(name)
196
+ logger.setLevel(getattr(logging, level.upper(), logging.INFO))
197
+ if not logger.handlers:
198
+ h = logging.StreamHandler()
199
+ h.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s %(message)s'))
200
+ logger.addHandler(h)
201
+ return logger
202
+
203
+
204
+ def set_seed(seed: int, device: str):
205
+ torch.manual_seed(seed)
206
+ np.random.seed(seed)
207
+ if 'cuda' in device and torch.cuda.is_available():
208
+ torch.cuda.manual_seed_all(seed)
209
+ torch.backends.cudnn.deterministic = True
210
+
211
+
212
+ # ============================================================================
213
+ # ÁLGEBRA DE CUATERNIONES
214
+ # ============================================================================
215
+
216
+ class QuaternionOps:
217
+ """
218
+ Operaciones de cuaterniones puras en PyTorch.
219
+ Representación: [..., 4] donde last dim = [w, x, y, z]
220
+ q = w + x*i + y*j + z*k
221
+ """
222
+
223
+ @staticmethod
224
+ def hamilton_product(q1: torch.Tensor, q2: torch.Tensor) -> torch.Tensor:
225
+ """Producto de Hamilton q1 ⊗ q2. Ambos [..., 4]."""
226
+ w1, x1, y1, z1 = q1[..., 0], q1[..., 1], q1[..., 2], q1[..., 3]
227
+ w2, x2, y2, z2 = q2[..., 0], q2[..., 1], q2[..., 2], q2[..., 3]
228
+ return torch.stack([
229
+ w1*w2 - x1*x2 - y1*y2 - z1*z2,
230
+ w1*x2 + x1*w2 + y1*z2 - z1*y2,
231
+ w1*y2 - x1*z2 + y1*w2 + z1*x2,
232
+ w1*z2 + x1*y2 - y1*x2 + z1*w2,
233
+ ], dim=-1)
234
+
235
+ @staticmethod
236
+ def normalize(q: torch.Tensor, eps: float = 1e-8) -> torch.Tensor:
237
+ return q / (q.norm(dim=-1, keepdim=True) + eps)
238
+
239
+ @staticmethod
240
+ def conjugate(q: torch.Tensor) -> torch.Tensor:
241
+ sign = q.new_tensor([1, -1, -1, -1])
242
+ return q * sign
243
+
244
+ @staticmethod
245
+ def rotate_vector(v: torch.Tensor, q: torch.Tensor) -> torch.Tensor:
246
+ """Rota vector 3D v por cuaternión unitario q. v:[...,3] q:[...,4]"""
247
+ zero = torch.zeros(*v.shape[:-1], 1, device=v.device, dtype=v.dtype)
248
+ v_q = torch.cat([zero, v], dim=-1) # pure quaternion
249
+ q_c = QuaternionOps.conjugate(q)
250
+ rotated = QuaternionOps.hamilton_product(
251
+ QuaternionOps.hamilton_product(q, v_q), q_c)
252
+ return rotated[..., 1:]
253
+
254
+
255
+ class QuaternionLinear(nn.Module):
256
+ """
257
+ Capa lineal con pesos cuaterniones.
258
+
259
+ Implementa la multiplicación W * x en el álgebra de cuaterniones:
260
+ - W = Ww + Wx*i + Wy*j + Wz*k (cuaternión de pesos)
261
+ - x = xw + xx*i + xy*j + xz*k (cuaternión de entrada)
262
+ - out = W * x (producto de Hamilton extendido a vectores)
263
+
264
+ Parámetros: 4 matrices reales de forma [out_q, in_q]
265
+ """
266
+
267
+ def __init__(self, in_features: int, out_features: int, bias: bool = True):
268
+ super().__init__()
269
+ assert in_features % 4 == 0 and out_features % 4 == 0
270
+ self.in_q = in_features // 4
271
+ self.out_q = out_features // 4
272
+
273
+ # 4 componentes del cuaternión de pesos
274
+ self.Ww = nn.Linear(self.in_q, self.out_q, bias=False)
275
+ self.Wx = nn.Linear(self.in_q, self.out_q, bias=False)
276
+ self.Wy = nn.Linear(self.in_q, self.out_q, bias=False)
277
+ self.Wz = nn.Linear(self.in_q, self.out_q, bias=False)
278
+ self.bias = nn.Parameter(torch.zeros(out_features)) if bias else None
279
+
280
+ for w in [self.Ww, self.Wx, self.Wy, self.Wz]:
281
+ nn.init.normal_(w.weight, std=0.02)
282
+
283
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
284
+ """x: [..., in_features] → [..., out_features]"""
285
+ d = self.in_q
286
+ xw, xx, xy, xz = x[..., :d], x[..., d:2*d], x[..., 2*d:3*d], x[..., 3*d:]
287
+ # Producto de Hamilton: W * x
288
+ ow = self.Ww(xw) - self.Wx(xx) - self.Wy(xy) - self.Wz(xz)
289
+ ox = self.Ww(xx) + self.Wx(xw) + self.Wy(xz) - self.Wz(xy)
290
+ oy = self.Ww(xy) - self.Wx(xz) + self.Wy(xw) + self.Wz(xx)
291
+ oz = self.Ww(xz) + self.Wx(xy) - self.Wy(xx) + self.Wz(xw)
292
+ out = torch.cat([ow, ox, oy, oz], dim=-1)
293
+ return out + self.bias if self.bias is not None else out
294
+
295
+
296
+ # ============================================================================
297
+ # CAPA ESPECTRAL CON CUATERNIONES
298
+ # ============================================================================
299
+
300
+ class QuaternionSpectralLayer(nn.Module):
301
+ """
302
+ Convolución espectral 2D con cuaterniones y producto de Hamilton completo.
303
+
304
+ Operación en dominio de frecuencia:
305
+ P(k) = W(k) ⊗ X(k) (producto de Hamilton de cuaterniones complejos)
306
+
307
+ Donde:
308
+ X(k) = FFT2(x) con 4 canales cuaterniones [Xw, Xx, Xy, Xz]
309
+ W(k) = kernel complejo aprendible con componentes [Ww, Wx, Wy, Wz]
310
+
311
+ Reglas del producto de Hamilton en dominio de frecuencia:
312
+ Pw = Ww·Xw - Wx·Xx - Wy·Xy - Wz·Xz
313
+ Px = Ww·Xx + Wx·Xw + Wy·Xz - Wz·Xy
314
+ Py = Ww·Xy - Wx·Xz + Wy·Xw + Wz·Xx
315
+ Pz = Ww·Xz + Wx·Xy - Wy·Xx + Wz·Xw
316
+
317
+ Cada Wc es un kernel complejo (partes real e imaginaria independientes).
318
+ """
319
+
320
+ def __init__(self, in_q: int, out_q: int, grid_h: int, grid_w: int,
321
+ init_scale: float = 0.02):
322
+ super().__init__()
323
+ self.in_q = in_q
324
+ self.out_q = out_q
325
+ self.grid_h = grid_h
326
+ self.grid_w = grid_w
327
+
328
+ # rfft2(x, s=(H, W)) → output shape [..., H, W//2+1]
329
+ freq_h = grid_h
330
+ freq_w = grid_w // 2 + 1
331
+
332
+ # Kernel complejo para cada componente cuaternión (w, x, y, z)
333
+ for c in ('w', 'x', 'y', 'z'):
334
+ self.register_parameter(f'kr_{c}',
335
+ nn.Parameter(torch.randn(in_q, out_q, freq_h, freq_w) * init_scale))
336
+ self.register_parameter(f'ki_{c}',
337
+ nn.Parameter(torch.randn(in_q, out_q, freq_h, freq_w) * init_scale))
338
+
339
+ def _kernel(self, c: str) -> torch.Tensor:
340
+ return torch.complex(getattr(self, f'kr_{c}'), getattr(self, f'ki_{c}'))
341
+
342
+ def _contract(self, W: torch.Tensor, X: torch.Tensor) -> torch.Tensor:
343
+ """Suma sobre canales in_q: Y[b,o,h,w] = Σ_i W[i,o,h,w]·X[b,i,h,w]"""
344
+ return torch.einsum('iohw,bihw->bohw', W, X)
345
+
346
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
347
+ """
348
+ x: [B, 4*in_q, H, W] (4 canales cuaterniones sobre grid espacial)
349
+ → [B, 4*out_q, H, W]
350
+ """
351
+ q = self.in_q
352
+ xw, xx, xy, xz = x[:, :q], x[:, q:2*q], x[:, 2*q:3*q], x[:, 3*q:]
353
+
354
+ # FFT2 por componente cuaternión → dominio de frecuencia
355
+ Xw = torch.fft.rfft2(xw, s=(self.grid_h, self.grid_w))
356
+ Xx = torch.fft.rfft2(xx, s=(self.grid_h, self.grid_w))
357
+ Xy = torch.fft.rfft2(xy, s=(self.grid_h, self.grid_w))
358
+ Xz = torch.fft.rfft2(xz, s=(self.grid_h, self.grid_w))
359
+
360
+ Ww, Wx, Wy, Wz = self._kernel('w'), self._kernel('x'), self._kernel('y'), self._kernel('z')
361
+
362
+ # Precomputar 16 contracciones (componente de kernel × componente de señal)
363
+ C = {}
364
+ for wc, W in (('w', Ww), ('x', Wx), ('y', Wy), ('z', Wz)):
365
+ for xc, X in (('w', Xw), ('x', Xx), ('y', Xy), ('z', Xz)):
366
+ C[(wc, xc)] = self._contract(W, X)
367
+
368
+ # Producto de Hamilton en dominio de frecuencia
369
+ Pw = C[('w','w')] - C[('x','x')] - C[('y','y')] - C[('z','z')]
370
+ Px = C[('w','x')] + C[('x','w')] + C[('y','z')] - C[('z','y')]
371
+ Py = C[('w','y')] - C[('x','z')] + C[('y','w')] + C[('z','x')]
372
+ Pz = C[('w','z')] + C[('x','y')] - C[('y','x')] + C[('z','w')]
373
+
374
+ # IFFT2 y recombinar
375
+ ow = torch.fft.irfft2(Pw, s=(self.grid_h, self.grid_w))
376
+ ox = torch.fft.irfft2(Px, s=(self.grid_h, self.grid_w))
377
+ oy = torch.fft.irfft2(Py, s=(self.grid_h, self.grid_w))
378
+ oz = torch.fft.irfft2(Pz, s=(self.grid_h, self.grid_w))
379
+
380
+ return torch.cat([ow, ox, oy, oz], dim=1) # [B, 4*out_q, H, W]
381
+
382
+
383
+ # ============================================================================
384
+ # SPECTRAL AUTOENCODER
385
+ # ============================================================================
386
+
387
+ class SpectralAutoencoder(nn.Module):
388
+ """
389
+ Autoencoder espectral con cuaterniones.
390
+
391
+ Opera en dos niveles:
392
+ 1. Espectral 1D sobre el vector de features (FFT sobre dim D_MODEL):
393
+ captura la espectrografía global del embedding.
394
+ 2. Espectral 2D sobre el grid del toro (QuaternionSpectralLayer):
395
+ captura correlaciones espaciales en la topología.
396
+
397
+ Devuelve (latent, recon_loss) para regularización.
398
+ """
399
+
400
+ def __init__(self, config: TopoGPT2Config):
401
+ super().__init__()
402
+ d = config.D_MODEL
403
+ d_lat = config.SPECTRAL_LATENT_DIM
404
+ d_q = config.D_QUAT
405
+ g = config.TORUS_GRID_SIZE
406
+ r = config.TORUS_RADIAL_BINS
407
+ a = config.TORUS_ANGULAR_BINS
408
+ init_s = config.SPECTRAL_KERNEL_INIT_SCALE
409
+
410
+ n_freq = d // 2 + 1
411
+
412
+ # --- Kernels espectrales 1D (reales e imaginarios) ---
413
+ self.enc_kr = nn.Parameter(torch.randn(n_freq) * init_s)
414
+ self.enc_ki = nn.Parameter(torch.randn(n_freq) * init_s)
415
+ self.dec_kr = nn.Parameter(torch.randn(n_freq) * init_s)
416
+ self.dec_ki = nn.Parameter(torch.randn(n_freq) * init_s)
417
+
418
+ # Proyección al latente y de vuelta
419
+ self.enc_proj = QuaternionLinear(d, d_lat)
420
+ self.dec_proj = QuaternionLinear(d_lat, d)
421
+
422
+ # --- Spectral 2D sobre el grid del toro: in/out_q = d_q, grid = r×a ---
423
+ # Los 'out_q' en la segunda capa son iguales para reconstruir
424
+ self.torus_spectral = nn.ModuleList([
425
+ QuaternionSpectralLayer(d_q, d_q, r, a, init_scale=init_s)
426
+ for _ in range(config.NUM_SPECTRAL_LAYERS)
427
+ ])
428
+
429
+ self.act = nn.GELU()
430
+ self.d_model = d
431
+
432
+ def _filter1d(self, x: torch.Tensor, kr: torch.Tensor, ki: torch.Tensor) -> torch.Tensor:
433
+ """Filtro espectral 1D: x[..., D] → filtrado[..., D]"""
434
+ X = torch.fft.rfft(x, dim=-1)
435
+ K = torch.complex(kr, ki)
436
+ return torch.fft.irfft(X * K, n=self.d_model, dim=-1)
437
+
438
+ def encode(self, x: torch.Tensor) -> torch.Tensor:
439
+ """x: [..., D_MODEL] → latent: [..., D_LAT]"""
440
+ x_filt = self.act(self._filter1d(x, self.enc_kr, self.enc_ki))
441
+ return self.enc_proj(x_filt)
442
+
443
+ def decode(self, z: torch.Tensor) -> torch.Tensor:
444
+ """z: [..., D_LAT] → recon: [..., D_MODEL]"""
445
+ x = self.dec_proj(z)
446
+ return self._filter1d(x, self.dec_kr, self.dec_ki)
447
+
448
+ def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
449
+ """Devuelve (latent, recon_loss)"""
450
+ z = self.encode(x)
451
+ recon = self.decode(z)
452
+ recon_loss = F.mse_loss(recon, x.detach())
453
+ return z, recon_loss
454
+
455
+ def process_torus_grid(self, grid: torch.Tensor) -> torch.Tensor:
456
+ """
457
+ Procesa el grid del toro con QuaternionSpectralLayer.
458
+ grid: [B, 4*D_QUAT, RADIAL, ANGULAR] → [B, 4*D_QUAT, RADIAL, ANGULAR]
459
+ """
460
+ h = grid
461
+ for layer in self.torus_spectral:
462
+ h = self.act(layer(h))
463
+ return h
464
+
465
+
466
+ # ============================================================================
467
+ # QUATERNION TORUS BRAIN (reemplaza MLP, VECTORIZADO)
468
+ # ============================================================================
469
+
470
+ class QuaternionTorusBrain(nn.Module):
471
+ """
472
+ Reemplaza el MLP en cada capa del transformer.
473
+
474
+ Pipeline (completamente vectorizado sobre batch Y secuencia):
475
+
476
+ 1. Flatten: [B, S, D] → [B·S, D]
477
+ 2. SpectralAutoencoder: filtrado espectral 1D + compresión cuaternión
478
+ 3. Proyección al toro:
479
+ - Calcula 2 ángulos (phi1, phi2) ∈ [-π, π]²
480
+ - Asignación blanda a los 8 nodos via distancia circular en el toro
481
+ 4. Construye grid de nodos: [B·S, N_NODES=8, D_MODEL]
482
+ 5. QuaternionSpectralLayer 2D sobre el grid [B·S, 4*D_QUAT, RADIAL, ANGULAR]
483
+ 6. Message-passing con rotaciones cuaterniones sobre el grafo toro
484
+ 7. Readout: atención sobre los 8 nodos → [B·S, D_MODEL]
485
+ 8. Reshape: [B·S, D] → [B, S, D]
486
+ """
487
+
488
+ def __init__(self, d_model: int, config: TopoGPT2Config):
489
+ super().__init__()
490
+ self.d_model = d_model
491
+ self.d_lat = config.SPECTRAL_LATENT_DIM
492
+ self.d_q = d_model // 4
493
+ self.n_radial = config.TORUS_RADIAL_BINS # 2
494
+ self.n_angular = config.TORUS_ANGULAR_BINS # 4
495
+ self.n_nodes = config.N_TORUS_NODES # 8
496
+ self.config = config
497
+
498
+ # Autoencoder espectral
499
+ self.spectral_ae = SpectralAutoencoder(config)
500
+
501
+ # Proyección al espacio de ángulos del toro (2 ángulos × 2 = 4 salidas para Q)
502
+ self.torus_proj = nn.Sequential(
503
+ QuaternionLinear(d_model, d_model),
504
+ nn.GELU(),
505
+ nn.Linear(d_model, 4), # [phi1, phi2, r_scale, dummy]
506
+ )
507
+
508
+ # Embeddings aprendibles para los 8 nodos del toro
509
+ self.node_embed = nn.Parameter(torch.randn(self.n_nodes, d_model) * 0.02)
510
+
511
+ # Cuaterniones de rotación aprendibles para message-passing
512
+ # Un cuaternión por tipo de arista: ang-izq, ang-der, rad-abajo, rad-arriba
513
+ self.edge_quat = nn.Parameter(torch.randn(4, 4) * 0.1) # [n_edge_types, 4]
514
+
515
+ # Red de nodo post-message-passing
516
+ self.node_net = QuaternionLinear(d_model, d_model)
517
+
518
+ # Proyección final desde nodo-pool al espacio de salida
519
+ self.readout = nn.Sequential(
520
+ nn.Linear(d_model, d_model * 2),
521
+ nn.GELU(),
522
+ nn.Linear(d_model * 2, d_model),
523
+ )
524
+
525
+ # Grafo del toro: lista de aristas (nodo_i, nodo_j, tipo_arista)
526
+ self._build_torus_graph()
527
+
528
+ def _build_torus_graph(self):
529
+ """
530
+ Construye las aristas del grafo toro 2×4.
531
+
532
+ Nodos indexados como: node = r * N_ANGULAR + a
533
+ r ∈ [0, RADIAL-1], a ∈ [0, ANGULAR-1]
534
+
535
+ Aristas angulares: nodo ↔ nodo a la izquierda/derecha (periódico)
536
+ Aristas radiales: nodo ↔ nodo del anillo interior/exterior
537
+ """
538
+ edges_i, edges_j, edge_type = [], [], []
539
+ R, A = self.n_radial, self.n_angular
540
+ for r in range(R):
541
+ for a in range(A):
542
+ n = r * A + a
543
+ # Angular izquierda (tipo 0)
544
+ neighbor = r * A + (a - 1) % A
545
+ edges_i.append(n); edges_j.append(neighbor); edge_type.append(0)
546
+ # Angular derecha (tipo 1)
547
+ neighbor = r * A + (a + 1) % A
548
+ edges_i.append(n); edges_j.append(neighbor); edge_type.append(1)
549
+ # Radial hacia centro (tipo 2) - si existe
550
+ if r > 0:
551
+ neighbor = (r - 1) * A + a
552
+ edges_i.append(n); edges_j.append(neighbor); edge_type.append(2)
553
+ # Radial hacia fuera (tipo 3) - si existe
554
+ if r < R - 1:
555
+ neighbor = (r + 1) * A + a
556
+ edges_i.append(n); edges_j.append(neighbor); edge_type.append(3)
557
+
558
+ self.register_buffer('edges_i', torch.tensor(edges_i, dtype=torch.long))
559
+ self.register_buffer('edges_j', torch.tensor(edges_j, dtype=torch.long))
560
+ self.register_buffer('edge_type', torch.tensor(edge_type, dtype=torch.long))
561
+
562
+ def _torus_soft_assign(self, phi1: torch.Tensor, phi2: torch.Tensor) -> torch.Tensor:
563
+ """
564
+ Asignación blanda de tokens a los 8 nodos del toro via distancia circular.
565
+
566
+ phi1: [BS] ángulo angular ∈ [-π, π]
567
+ phi2: [BS] ángulo radial ∈ [-π, π]
568
+ → weights: [BS, N_NODES] (suma a 1, softmax de distancias negativas)
569
+ """
570
+ BS = phi1.shape[0]
571
+ device = phi1.device
572
+
573
+ # Posiciones de los nodos en el toro
574
+ ang_pos = torch.linspace(-math.pi, math.pi, self.n_angular + 1, device=device)[:-1]
575
+ rad_pos = torch.linspace(-math.pi, math.pi, self.n_radial + 1, device=device)[:-1]
576
+
577
+ # Distancias circulares (usando sin para periodicidad suave)
578
+ # d_ang[BS, N_ANGULAR], d_rad[BS, N_RADIAL]
579
+ d_ang = torch.sin((phi1.unsqueeze(1) - ang_pos.unsqueeze(0)) / 2).pow(2) # [BS, A]
580
+ d_rad = torch.sin((phi2.unsqueeze(1) - rad_pos.unsqueeze(0)) / 2).pow(2) # [BS, R]
581
+
582
+ # Distancia en el toro para cada nodo [r, a]: d[r, a] = d_rad[r] + d_ang[a]
583
+ # Resultado: [BS, R, A] → flatten a [BS, N_NODES]
584
+ d_torus = d_rad.unsqueeze(2) + d_ang.unsqueeze(1) # [BS, R, A]
585
+ d_flat = d_torus.view(BS, -1) # [BS, N_NODES]
586
+
587
+ return torch.softmax(-d_flat / 0.3, dim=-1) # temperatura 0.3
588
+
589
+ def _message_passing(self, node_feat: torch.Tensor) -> torch.Tensor:
590
+ """
591
+ Message-passing VECTORIZADO con rotaciones cuaterniones.
592
+ Sin bucles Python: todas las aristas se procesan en paralelo.
593
+
594
+ node_feat: [BS, N_NODES, D_MODEL]
595
+ → [BS, N_NODES, D_MODEL]
596
+ """
597
+ BS = node_feat.shape[0]
598
+ n_edges = self.edges_i.shape[0]
599
+ d_q = self.d_q
600
+
601
+ # Cuaterniones de arista normalizados: [n_edge_types=4, 4]
602
+ eq = QuaternionOps.normalize(self.edge_quat)
603
+
604
+ # Recoger features de nodos fuente para TODAS las aristas: [BS, n_edges, D]
605
+ src_feat = node_feat[:, self.edges_j, :] # index [n_edges] → [BS, n_edges, D]
606
+
607
+ # Cuaternión de cada arista: [n_edges, 4] → [1, n_edges, 1, 4] para broadcasting
608
+ edge_q = eq[self.edge_type] # [n_edges, 4]
609
+ edge_q = edge_q.unsqueeze(0).unsqueeze(2) # [1, n_edges, 1, 4]
610
+ edge_q = edge_q.expand(BS, -1, d_q, -1) # [BS, n_edges, D_QUAT, 4]
611
+
612
+ # Reshapear fuentes: [BS, n_edges, D] → [BS, n_edges, D_QUAT, 4]
613
+ src_q = src_feat.view(BS, n_edges, d_q, 4)
614
+
615
+ # Rotación cuaternión para todas las aristas en paralelo: [BS, n_edges, D_QUAT, 4]
616
+ msg_rot = QuaternionOps.hamilton_product(edge_q, src_q)
617
+ msg_rot = msg_rot.view(BS, n_edges, self.d_model) # [BS, n_edges, D]
618
+
619
+ # Scatter-add a nodos destino: edges_i[e] es el nodo destino de la arista e
620
+ agg = torch.zeros_like(node_feat) # [BS, N_NODES, D]
621
+ dst_idx = self.edges_i.view(1, n_edges, 1).expand(BS, -1, self.d_model)
622
+ agg.scatter_add_(1, dst_idx, msg_rot)
623
+
624
+ return self.node_net(node_feat + agg)
625
+
626
+ def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
627
+ """
628
+ x: [B, S, D_MODEL]
629
+ → output: [B, S, D_MODEL], recon_loss: scalar
630
+ """
631
+ B, S, D = x.shape
632
+ device = x.device
633
+
634
+ # 1. Flatten para procesamiento paralelo
635
+ x_flat = x.reshape(B * S, D) # [BS, D]
636
+
637
+ # 2. Spectral Autoencoder: filtrado + compresión
638
+ z, recon_loss = self.spectral_ae(x_flat) # z: [BS, D_LAT]
639
+
640
+ # 3. Proyección al toro
641
+ coords = self.torus_proj(x_flat) # [BS, 4]
642
+ phi1 = math.pi * torch.tanh(coords[:, 0]) # ángulo angular ∈ [-π, π]
643
+ phi2 = math.pi * torch.tanh(coords[:, 1]) # ángulo radial ∈ [-π, π]
644
+
645
+ # 4. Asignación blanda a 8 nodos
646
+ attn_w = self._torus_soft_assign(phi1, phi2) # [BS, N_NODES]
647
+
648
+ # 5. Construir grid de nodos: weighted combination de embeddings de nodo + input
649
+ # node_feat[bs, n, :] = attn_w[bs,n] * node_embed[n] + x_flat[bs] * attn_w[bs,n]
650
+ nodes = (
651
+ attn_w.unsqueeze(-1) * self.node_embed.unsqueeze(0) # [BS, N, D]
652
+ + attn_w.unsqueeze(-1) * x_flat.unsqueeze(1) # broadcasting [BS, N, D]
653
+ ) # [BS, N_NODES, D_MODEL]
654
+
655
+ # 6. QuaternionSpectralLayer 2D sobre el grid del toro
656
+ # Reshape: [BS, N_NODES, D_MODEL] → [BS, 4*D_QUAT, RADIAL, ANGULAR]
657
+ grid = nodes.view(B * S, self.n_radial, self.n_angular, D)
658
+ grid = grid.permute(0, 3, 1, 2) # [BS, D_MODEL, RADIAL, ANGULAR]
659
+ # Reorganizar en 4 canales cuaterniones
660
+ d_q = self.d_q
661
+ grid_q = grid.view(B * S, 4, d_q, self.n_radial, self.n_angular)
662
+ # [BS, 4, D_QUAT, RADIAL, ANGULAR] → [BS, 4*D_QUAT, RADIAL, ANGULAR]
663
+ grid_q = grid_q.permute(0, 1, 2, 3, 4).reshape(B * S, 4 * d_q, self.n_radial, self.n_angular)
664
+ grid_spec = self.spectral_ae.process_torus_grid(grid_q) # [BS, 4*D_QUAT, R, A]
665
+
666
+ # Volver a [BS, N_NODES, D_MODEL]
667
+ grid_back = grid_spec.view(B * S, 4, d_q, self.n_radial, self.n_angular)
668
+ grid_back = grid_back.permute(0, 3, 4, 1, 2).reshape(B * S, self.n_nodes, D)
669
+
670
+ # 7. Message-passing cuaternión sobre el grafo toro
671
+ nodes_mp = self._message_passing(grid_back) # [BS, N_NODES, D_MODEL]
672
+
673
+ # 8. Readout: suma ponderada por los pesos de asignación
674
+ out_flat = (attn_w.unsqueeze(-1) * nodes_mp).sum(dim=1) # [BS, D_MODEL]
675
+ out_flat = self.readout(out_flat) # [BS, D_MODEL]
676
+
677
+ # 9. Reshape a secuencia
678
+ output = out_flat.reshape(B, S, D) # [B, S, D_MODEL]
679
+
680
+ return output, recon_loss
681
+
682
+
683
+ # ============================================================================
684
+ # ROTARY POSITION EMBEDDINGS (RoPE)
685
+ # ============================================================================
686
+
687
+ class RotaryEmbedding(nn.Module):
688
+ """
689
+ Rotary Position Embeddings (RoPE) - Su et al., 2021.
690
+ Codifica la posicion como rotaciones del espacio de atencion,
691
+ naturalmente relativas y sin parametros extra.
692
+
693
+ Las caches _cos/_sin se registran como buffers no-persistentes con
694
+ nombres que no colisionan con checkpoints antiguos (que usaban
695
+ 'cos_cache'/'sin_cache'). Esto permite cambiar MAX_SEQ_LEN sin
696
+ errores de shape al cargar checkpoints previos.
697
+ """
698
+
699
+ def __init__(self, d_head: int, max_seq_len: int = 2048, base: int = 10000):
700
+ super().__init__()
701
+ inv_freq = 1.0 / (base ** (torch.arange(0, d_head, 2).float() / d_head))
702
+ self.register_buffer('inv_freq', inv_freq)
703
+ self._build_cache(max_seq_len)
704
+
705
+ def _build_cache(self, seq_len: int):
706
+ device = self.inv_freq.device
707
+ t = torch.arange(seq_len, device=device).float()
708
+ freqs = torch.outer(t, self.inv_freq)
709
+ emb = torch.cat([freqs, freqs], dim=-1)
710
+ self.register_buffer('_cos_cache', emb.cos(), persistent=False)
711
+ self.register_buffer('_sin_cache', emb.sin(), persistent=False)
712
+
713
+ def _rotate_half(self, x: torch.Tensor) -> torch.Tensor:
714
+ x1, x2 = x[..., :x.shape[-1]//2], x[..., x.shape[-1]//2:]
715
+ return torch.cat([-x2, x1], dim=-1)
716
+
717
+ def forward(self, q: torch.Tensor, k: torch.Tensor,
718
+ seq_len: int, offset: int = 0) -> Tuple[torch.Tensor, torch.Tensor]:
719
+ """
720
+ q, k: [B, n_heads, S_q/S_k, d_head]
721
+ offset: posicion inicial (para KV cache: longitud del cache existente)
722
+ Aplica posiciones [offset .. offset+S-1] a q y k.
723
+ """
724
+ needed = offset + max(q.shape[2], k.shape[2])
725
+ if needed > self._cos_cache.shape[0]:
726
+ self._build_cache(needed * 2)
727
+ sq, sk = q.shape[2], k.shape[2]
728
+ cos_q = self._cos_cache[offset:offset + sq].unsqueeze(0).unsqueeze(0)
729
+ sin_q = self._sin_cache[offset:offset + sq].unsqueeze(0).unsqueeze(0)
730
+ cos_k = self._cos_cache[offset:offset + sk].unsqueeze(0).unsqueeze(0)
731
+ sin_k = self._sin_cache[offset:offset + sk].unsqueeze(0).unsqueeze(0)
732
+ q_rot = q * cos_q + self._rotate_half(q) * sin_q
733
+ k_rot = k * cos_k + self._rotate_half(k) * sin_k
734
+ return q_rot, k_rot
735
+
736
+
737
+ # ============================================================================
738
+ # MULTI-HEAD ATTENTION CON FLASH ATTENTION + ROPE
739
+ # ============================================================================
740
+
741
+ class RMSNorm(nn.Module):
742
+ """Root Mean Square Layer Normalization (sin bias). Más estable que LayerNorm."""
743
+
744
+ def __init__(self, d_model: int, eps: float = 1e-6):
745
+ super().__init__()
746
+ self.eps = eps
747
+ self.weight = nn.Parameter(torch.ones(d_model))
748
+
749
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
750
+ rms = x.pow(2).mean(-1, keepdim=True).add(self.eps).sqrt()
751
+ return x / rms * self.weight
752
+
753
+
754
+ # ============================================================================
755
+ # SWIGLU (activacion de modelos frontier: LLaMA, Qwen, etc.)
756
+ # ============================================================================
757
+
758
+ class SwiGLU(nn.Module):
759
+ """
760
+ SwiGLU: SiLU(gate(x)) * up(x) -> down
761
+ Usado en LLaMA 2/3, Qwen, Mistral en lugar de GELU-FFN.
762
+ Dimension interna: 8/3 * d_model (convención LLaMA, redondeada a múltiplo de 4).
763
+ """
764
+
765
+ def __init__(self, d_model: int, expansion: float = 8 / 3,
766
+ dropout: float = 0.0):
767
+ super().__init__()
768
+ inner = max(4, int(d_model * expansion))
769
+ inner = (inner + 3) // 4 * 4 # redondear a múltiplo de 4
770
+ self.gate_proj = nn.Linear(d_model, inner, bias=False)
771
+ self.up_proj = nn.Linear(d_model, inner, bias=False)
772
+ self.down_proj = nn.Linear(inner, d_model, bias=False)
773
+ self.drop = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
774
+ # Inicialización estilo GPT
775
+ nn.init.normal_(self.gate_proj.weight, std=0.02)
776
+ nn.init.normal_(self.up_proj.weight, std=0.02)
777
+ nn.init.normal_(self.down_proj.weight, std=0.02)
778
+
779
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
780
+ return self.drop(self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x)))
781
+
782
+
783
+ # ============================================================================
784
+ # MoE: Mixture of Experts topologico
785
+ # ============================================================================
786
+
787
+ class TopoMoEBrain(nn.Module):
788
+ """
789
+ Mixture of Experts sobre la capa topologica.
790
+
791
+ Arquitectura (inspirada en DeepSeek-MoE / Mixtral):
792
+ - 1 experto compartido: QuaternionTorusBrain (siempre activo)
793
+ - N_EXPERTS expertos SwiGLU ligeros (activacion esparsa: Top-K por token)
794
+ - Router: Linear(D, N_EXPERTS) + softmax → top-K
795
+
796
+ Load-balancing loss (auxiliar): penaliza si un experto acapara todos los tokens.
797
+ Activa MOE_TOP_K de N_EXPERTS expertos por token.
798
+
799
+ Sin MoE (MOE_ENABLED=False): se comporta como QuaternionTorusBrain puro.
800
+ """
801
+
802
+ def __init__(self, d_model: int, config: 'TopoGPT2Config'):
803
+ super().__init__()
804
+ self.d_model = d_model
805
+ self.moe_enabled = config.MOE_ENABLED
806
+ self.n_experts = config.N_EXPERTS
807
+ self.top_k = config.MOE_TOP_K
808
+ self.aux_weight = config.MOE_AUX_LOSS_WEIGHT
809
+
810
+ # Experto compartido: el TopoBrain original (siempre activo)
811
+ self.shared_expert = QuaternionTorusBrain(d_model, config)
812
+
813
+ if self.moe_enabled:
814
+ # N_EXPERTS expertos SwiGLU ligeros (dimension reducida para eficiencia)
815
+ self.experts = nn.ModuleList([
816
+ SwiGLU(d_model, expansion=4/3, dropout=config.DROPOUT)
817
+ for _ in range(self.n_experts)
818
+ ])
819
+ # Router: produce logits por experto para cada token
820
+ self.router = nn.Linear(d_model, self.n_experts, bias=False)
821
+ nn.init.normal_(self.router.weight, std=0.02)
822
+
823
+ def _route(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
824
+ """
825
+ x: [N, D] donde N = B*S (tokens aplanados)
826
+ Retorna:
827
+ expert_out: [N, D] suma ponderada de top-K expertos
828
+ aux_loss: escalar load-balancing loss
829
+ Routing vectorizado sin boolean indexing ni sincronizacion CUDA.
830
+ Usa dispatch por indices agrupados (estilo Mixtral/DeepSeek) para
831
+ compatibilidad total con torch.utils.checkpoint.
832
+ """
833
+ N, D = x.shape
834
+ router_logits = self.router(x) # [N, n_experts]
835
+ router_probs = F.softmax(router_logits, dim=-1) # [N, n_experts]
836
+ # Top-K seleccion
837
+ top_k_probs, top_k_idx = torch.topk(router_probs, self.top_k, dim=-1)
838
+ # Renormalizar pesos dentro de los top-K
839
+ top_k_probs = top_k_probs / top_k_probs.sum(dim=-1, keepdim=True).clamp(min=1e-9)
840
+ # Dispatch vectorizado: agrupar tokens por experto asignado
841
+ flat_idx = top_k_idx.reshape(-1) # [N*top_k]
842
+ flat_weights = top_k_probs.reshape(-1) # [N*top_k]
843
+ token_indices = torch.arange(N, device=x.device, dtype=torch.long)
844
+ token_indices = token_indices.unsqueeze(1).expand(-1, self.top_k).reshape(-1) # [N*top_k]
845
+ expert_out = torch.zeros_like(x)
846
+
847
+ # Se elimina la verificacion con .item() porque fuerza una sincronizacion
848
+ # CPU-GPU que rompe el recomputado de gradient checkpointing.
849
+ # scatter_add_ maneja tensores vacios de forma segura y eficiente.
850
+ for e in range(self.n_experts):
851
+ expert_mask = (flat_idx == e) # [N*top_k] boolean
852
+ src_token_idx = token_indices[expert_mask] # [count] indices en N
853
+ w = flat_weights[expert_mask].unsqueeze(-1).to(x.dtype) # [count, 1]
854
+ out_e = self.experts[e](x[src_token_idx]) # [count, D]
855
+ contrib = w * out_e
856
+ expert_out.scatter_add_(0, src_token_idx.unsqueeze(1).expand_as(contrib), contrib)
857
+
858
+ # Load-balancing loss (Switch Transformer style)
859
+ token_frac = router_probs.mean(dim=0) # [n_experts]
860
+ one_hot = F.one_hot(top_k_idx, self.n_experts).float()# [N, top_k, n_experts]
861
+ dispatch_frac = one_hot.mean(dim=(0, 1)) # [n_experts]
862
+ aux_loss = self.n_experts * (token_frac * dispatch_frac).sum()
863
+ return expert_out, aux_loss
864
+
865
+ def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
866
+ """
867
+ x: [B, S, D]
868
+ → output: [B, S, D], aux_loss: escalar
869
+ """
870
+ B, S, D = x.shape
871
+
872
+ # Experto compartido siempre activo
873
+ shared_out, recon_loss = self.shared_expert(x) # [B, S, D], scalar
874
+
875
+ if not self.moe_enabled:
876
+ return shared_out, recon_loss
877
+
878
+ # Expertos SwiGLU (esparso)
879
+ x_flat = x.reshape(B * S, D)
880
+ expert_out, aux_loss = self._route(x_flat)
881
+ expert_out = expert_out.reshape(B, S, D)
882
+
883
+ # Combinar: shared + routed (igual peso inicial)
884
+ output = shared_out + expert_out
885
+
886
+ # Combinar losses auxiliares
887
+ total_aux = recon_loss + self.aux_weight * aux_loss
888
+
889
+ return output, total_aux
890
+
891
+
892
+ class MultiHeadAttention(nn.Module):
893
+ """
894
+ Multi-head attention con:
895
+ - Flash Attention (scaled_dot_product_attention de PyTorch 2.0+)
896
+ - Rotary Position Embeddings (RoPE)
897
+ - GQA (Grouped Query Attention): N_KV_HEADS < N_HEADS, reduce VRAM de K/V
898
+ - KV Cache para inferencia autoregresiva eficiente
899
+ - Temperatura termodinámica aprendible
900
+ """
901
+
902
+ def __init__(self, d_model: int, n_heads: int, config: 'TopoGPT2Config'):
903
+ super().__init__()
904
+ self.d_model = d_model
905
+ self.n_heads = n_heads
906
+ self.n_kv = config.N_KV_HEADS # cabezas K/V (GQA)
907
+ self.n_groups = config.GQA_GROUPS # n_heads // n_kv
908
+ self.d_head = d_model // n_heads
909
+ self.window_size = config.ATTN_WINDOW # 0 = full attention
910
+
911
+ # Q con n_heads completo; K/V con n_kv (GQA)
912
+ self.q_proj = nn.Linear(d_model, n_heads * self.d_head, bias=False)
913
+ self.k_proj = nn.Linear(d_model, self.n_kv * self.d_head, bias=False)
914
+ self.v_proj = nn.Linear(d_model, self.n_kv * self.d_head, bias=False)
915
+ self.o_proj = nn.Linear(d_model, d_model, bias=False)
916
+
917
+ self.rope = RotaryEmbedding(self.d_head, max_seq_len=config.MAX_SEQ_LEN)
918
+ self.temperature = nn.Parameter(torch.tensor(config.T_INIT))
919
+ self.dropout_p = config.DROPOUT if config.DROPOUT > 0 else 0.0
920
+
921
+ def forward(self, x: torch.Tensor, is_causal: bool = True,
922
+ past_kv: Optional[Tuple[torch.Tensor, torch.Tensor]] = None
923
+ ) -> Tuple[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
924
+ """
925
+ Args:
926
+ x: [B, S, D]
927
+ is_causal: usar mascara causal
928
+ past_kv: (K_cache, V_cache) de pasos anteriores o None
929
+ Returns:
930
+ out: [B, S, D]
931
+ kv_cache: (K, V) completos para cachear en generate()
932
+ """
933
+ B, S, D = x.shape
934
+
935
+ Q = self.q_proj(x).view(B, S, self.n_heads, self.d_head).transpose(1, 2)
936
+ K = self.k_proj(x).view(B, S, self.n_kv, self.d_head).transpose(1, 2)
937
+ V = self.v_proj(x).view(B, S, self.n_kv, self.d_head).transpose(1, 2)
938
+
939
+ # RoPE con offset: si hay cache, Q y K_new se posicionan a partir de past_len
940
+ past_len = past_kv[0].shape[2] if past_kv is not None else 0
941
+ Q, K = self.rope(Q, K, seq_len=S, offset=past_len)
942
+
943
+ # Concatenar K/V con cache (despues de aplicar RoPE al nuevo K)
944
+ if past_kv is not None:
945
+ K = torch.cat([past_kv[0], K], dim=2)
946
+ V = torch.cat([past_kv[1], V], dim=2)
947
+
948
+ attn_mask = None
949
+ use_causal = (is_causal and past_kv is None)
950
+
951
+ if self.window_size > 0:
952
+ if past_kv is None:
953
+ S_q = Q.shape[2]
954
+ S_kv = K.shape[2]
955
+ if S_kv > self.window_size:
956
+ row = torch.arange(S_q, device=Q.device).unsqueeze(1)
957
+ col = torch.arange(S_kv, device=Q.device).unsqueeze(0)
958
+ upper = row < col
959
+ outside = (row - col) >= self.window_size
960
+ attn_mask = upper | outside
961
+ use_causal = False
962
+ else:
963
+ if K.shape[2] > self.window_size:
964
+ K = K[:, :, -self.window_size:, :]
965
+ V = V[:, :, -self.window_size:, :]
966
+ use_causal = False
967
+
968
+ kv_cache = (K, V)
969
+ K_full = K
970
+
971
+ # GQA: expandir K/V al numero de cabezas de Q via repeat_interleave
972
+ if self.n_groups > 1:
973
+ K_full = K_full.repeat_interleave(self.n_groups, dim=1) # [B, n_heads, S_k, d]
974
+ V_exp = V.repeat_interleave(self.n_groups, dim=1)
975
+ else:
976
+ V_exp = V
977
+
978
+ scale = (self.d_head ** -0.5) / self.temperature.abs().clamp(min=1e-6)
979
+
980
+ out = F.scaled_dot_product_attention(
981
+ Q, K_full, V_exp,
982
+ attn_mask=attn_mask,
983
+ dropout_p=self.dropout_p if self.training else 0.0,
984
+ is_causal=use_causal,
985
+ scale=scale.item(),
986
+ ) # [B, n_heads, S, d_head]
987
+
988
+ out = out.transpose(1, 2).contiguous().view(B, S, D)
989
+ return self.o_proj(out), kv_cache
990
+
991
+
992
+ # ============================================================================
993
+ # CAPA TRANSFORMER TOPOLÓGICA
994
+ # ============================================================================
995
+
996
+ class TopoGPT2Layer(nn.Module):
997
+ """
998
+ Capa del transformer con TopoMoEBrain (TopoBrain + MoE SwiGLU experts).
999
+
1000
+ Esquema pre-norm (estilo LLaMA):
1001
+ x = x + Attention_GQA(RMSNorm(x))
1002
+ x = x + TopoMoEBrain(RMSNorm(x))
1003
+ """
1004
+
1005
+ def __init__(self, d_model: int, n_heads: int, config: 'TopoGPT2Config'):
1006
+ super().__init__()
1007
+ self.norm1 = RMSNorm(d_model)
1008
+ self.norm2 = RMSNorm(d_model)
1009
+ self.attn = MultiHeadAttention(d_model, n_heads, config)
1010
+ self.topo_brain = TopoMoEBrain(d_model, config)
1011
+ self.dropout = nn.Dropout(config.DROPOUT)
1012
+ self.use_ckpt = config.GRADIENT_CHECKPOINTING
1013
+
1014
+ def _forward_impl(self, x: torch.Tensor,
1015
+ past_kv: Optional[Tuple] = None
1016
+ ) -> Tuple[torch.Tensor, torch.Tensor, Tuple]:
1017
+ attn_out, kv_cache = self.attn(self.norm1(x), past_kv=past_kv)
1018
+ x = x + self.dropout(attn_out)
1019
+ brain_out, aux_loss = self.topo_brain(self.norm2(x))
1020
+ x = x + self.dropout(brain_out)
1021
+ return x, aux_loss, kv_cache
1022
+
1023
+ def forward(self, x: torch.Tensor,
1024
+ past_kv: Optional[Tuple] = None
1025
+ ) -> Tuple[torch.Tensor, torch.Tensor, Tuple]:
1026
+ """
1027
+ Retorna (x_out, aux_loss, kv_cache).
1028
+ Con gradient checkpointing en training (solo cuando no hay KV cache).
1029
+ """
1030
+ if self.use_ckpt and self.training and past_kv is None:
1031
+ def ckpt_fn(x_in):
1032
+ out, al, kvc = self._forward_impl(x_in, past_kv=None)
1033
+ return out, al
1034
+ out, al = grad_ckpt(ckpt_fn, x, use_reentrant=False)
1035
+ return out, al, None # kv_cache no disponible con ckpt (solo entrenamiento)
1036
+ return self._forward_impl(x, past_kv=past_kv)
1037
+
1038
+
1039
+ # ============================================================================
1040
+ # MODELO COMPLETO
1041
+ # ============================================================================
1042
+
1043
+ class TopoGPT2(nn.Module):
1044
+ """
1045
+ TopoGPT2: Transformer de lenguaje con TopoBrain cuaternión-espectral.
1046
+
1047
+ Arquitectura:
1048
+ Embedding de tokens + RoPE (en Attention)
1049
+ N_LAYERS × TopoGPT2Layer (Attention + QuaternionTorusBrain)
1050
+ RMSNorm final
1051
+ Proyección a vocabulario (weight-tied con embeddings)
1052
+ """
1053
+
1054
+ def __init__(self, config: TopoGPT2Config):
1055
+ super().__init__()
1056
+ self.config = config
1057
+
1058
+ self.token_embed = nn.Embedding(config.VOCAB_SIZE, config.D_MODEL)
1059
+ nn.init.normal_(self.token_embed.weight, std=0.02)
1060
+
1061
+ self.layers = nn.ModuleList([
1062
+ TopoGPT2Layer(config.D_MODEL, config.N_HEADS, config)
1063
+ for _ in range(config.N_LAYERS)
1064
+ ])
1065
+ self.final_norm = RMSNorm(config.D_MODEL)
1066
+ self.lm_head = nn.Linear(config.D_MODEL, config.VOCAB_SIZE, bias=False)
1067
+
1068
+ # Weight tying (GPT-2 style): embedding y cabeza de lenguaje comparten pesos
1069
+ self.lm_head.weight = self.token_embed.weight
1070
+
1071
+ self.memory_tokens: Optional[nn.Parameter] = None
1072
+ if config.N_MEMORY_TOKENS > 0:
1073
+ self.memory_tokens = nn.Parameter(
1074
+ torch.randn(1, config.N_MEMORY_TOKENS, config.D_MODEL) * 0.02
1075
+ )
1076
+
1077
+ self._init_weights()
1078
+
1079
+ def _init_weights(self):
1080
+ for module in self.modules():
1081
+ if isinstance(module, nn.Linear) and module is not self.lm_head:
1082
+ nn.init.normal_(module.weight, std=0.02)
1083
+ if module.bias is not None:
1084
+ nn.init.zeros_(module.bias)
1085
+
1086
+ def forward(self, token_ids: torch.Tensor,
1087
+ past_kvs: Optional[List[Optional[Tuple]]] = None
1088
+ ) -> Tuple[torch.Tensor, torch.Tensor, List[Tuple]]:
1089
+ """
1090
+ token_ids: [B, S] (enteros)
1091
+ past_kvs: lista de (K, V) por capa, o None para entrenamiento
1092
+ → logits: [B, S, VOCAB_SIZE], aux_loss: scalar, new_kvs: list[(K,V)]
1093
+ """
1094
+ x = self.token_embed(token_ids) # [B, S, D_MODEL]
1095
+
1096
+ total_aux = torch.tensor(0.0, device=x.device)
1097
+ new_kvs: List[Tuple] = []
1098
+
1099
+ for i, layer in enumerate(self.layers):
1100
+ pkv = past_kvs[i] if past_kvs is not None else None
1101
+ x, al, kvc = layer(x, past_kv=pkv)
1102
+ total_aux = total_aux + al
1103
+ new_kvs.append(kvc)
1104
+
1105
+ x = self.final_norm(x)
1106
+ logits = self.lm_head(x)
1107
+ return logits, total_aux / len(self.layers), new_kvs
1108
+
1109
+ def forward_with_memory(
1110
+ self, token_ids: torch.Tensor
1111
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
1112
+ """Process long sequences with latent memory-token context compression.
1113
+
1114
+ Splits `token_ids` [B, S] into segments of size MEMORY_SEGMENT_LEN.
1115
+ Each segment is processed with N_MEMORY_TOKENS prepended. The output
1116
+ at memory-token positions after segment k becomes the memory-state
1117
+ input for segment k+1, compressing all prior context into a fixed-size
1118
+ latent vector.
1119
+
1120
+ Returns (logits [B, S, VOCAB_SIZE], aux_loss).
1121
+ """
1122
+ cfg = self.config
1123
+ B, S = token_ids.shape
1124
+ N_mem = cfg.N_MEMORY_TOKENS
1125
+ seg_len = cfg.MEMORY_SEGMENT_LEN
1126
+
1127
+ if self.memory_tokens is None or N_mem == 0 or seg_len == 0:
1128
+ logits, aux, _ = self.forward(token_ids)
1129
+ return logits, aux
1130
+
1131
+ device = token_ids.device
1132
+ mem = self.memory_tokens.expand(B, -1, -1)
1133
+
1134
+ all_logits = []
1135
+ total_aux = torch.tensor(0.0, device=device)
1136
+
1137
+ for seg_start in range(0, S, seg_len):
1138
+ seg_end = min(seg_start + seg_len, S)
1139
+ seg_ids = token_ids[:, seg_start:seg_end]
1140
+ seg_emb = self.token_embed(seg_ids)
1141
+
1142
+ x = torch.cat([mem, seg_emb], dim=1)
1143
+
1144
+ for layer in self.layers:
1145
+ x, al, _ = layer(x)
1146
+ total_aux = total_aux + al
1147
+
1148
+ seg_out = x[:, N_mem:, :]
1149
+ mem = x[:, :N_mem, :]
1150
+
1151
+ seg_out = self.final_norm(seg_out)
1152
+ seg_logits = self.lm_head(seg_out)
1153
+ all_logits.append(seg_logits)
1154
+
1155
+ logits = torch.cat(all_logits, dim=1)
1156
+ aux_loss = total_aux / len(self.layers)
1157
+ return logits, aux_loss
1158
+
1159
+ def count_params(self) -> Dict[str, int]:
1160
+ total = sum(p.numel() for p in self.parameters())
1161
+ trainable = sum(p.numel() for p in self.parameters() if p.requires_grad)
1162
+ return {'total': total, 'trainable': trainable}
1163
+
1164
+ @torch.no_grad()
1165
+ def generate(self, token_ids: torch.Tensor, max_new_tokens: int = 200,
1166
+ temperature: float = 0.8, top_k: int = 50,
1167
+ repetition_penalty: float = 1.0) -> torch.Tensor:
1168
+ """
1169
+ Autoregressive generation with KV cache and top-k sampling.
1170
+
1171
+ Args:
1172
+ token_ids: [B, S_prompt] prompt tokens.
1173
+ max_new_tokens: Maximum tokens to generate.
1174
+ temperature: Sampling temperature (lower = more deterministic).
1175
+ top_k: Top-k filtering (0 = disabled).
1176
+ repetition_penalty: Penalty for repeating tokens (>1 = penalize).
1177
+
1178
+ Returns:
1179
+ [B, S_prompt + generated] full token sequence.
1180
+ """
1181
+ self.eval()
1182
+ cfg = self.config
1183
+
1184
+ ctx = token_ids[:, -cfg.MAX_SEQ_LEN:]
1185
+ logits, _, past_kvs = self(ctx)
1186
+ logits = logits[:, -1, :] / max(temperature, 1e-8)
1187
+ if repetition_penalty != 1.0:
1188
+ mask = torch.zeros_like(logits, dtype=torch.bool)
1189
+ mask.scatter_(1, ctx, True)
1190
+ logits = torch.where(mask, logits / repetition_penalty, logits)
1191
+ if top_k > 0:
1192
+ v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
1193
+ logits[logits < v[:, -1:]] = float('-inf')
1194
+ next_tok = torch.multinomial(F.softmax(logits, dim=-1), 1)
1195
+ token_ids = torch.cat([token_ids, next_tok], dim=1)
1196
+
1197
+ for _ in range(max_new_tokens - 1):
1198
+ logits, _, past_kvs = self(next_tok, past_kvs=past_kvs)
1199
+ logits = logits[:, -1, :] / max(temperature, 1e-8)
1200
+ if repetition_penalty != 1.0:
1201
+ mask = torch.zeros_like(logits, dtype=torch.bool)
1202
+ mask.scatter_(1, token_ids, True)
1203
+ logits = torch.where(mask, logits / repetition_penalty, logits)
1204
+ if top_k > 0:
1205
+ v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
1206
+ logits[logits < v[:, -1:]] = float('-inf')
1207
+ next_tok = torch.multinomial(F.softmax(logits, dim=-1), 1)
1208
+ token_ids = torch.cat([token_ids, next_tok], dim=1)
1209
+
1210
+ if (next_tok == 50256).all():
1211
+ break
1212
+
1213
+ return token_ids
1214
+
1215
+ @torch.no_grad()
1216
+ def generate_with_continuation(self, token_ids: torch.Tensor,
1217
+ tokenizer: Any, max_new_tokens: int = 200,
1218
+ temperature: float = 0.8, top_k: int = 50,
1219
+ repetition_penalty: float = 1.0,
1220
+ max_continuations: int = 3,
1221
+ tail_lines: int = 2) -> torch.Tensor:
1222
+ full_ids = token_ids.clone()
1223
+ continuations = 0
1224
+
1225
+ while continuations <= max_continuations:
1226
+ n_new = max_new_tokens - (full_ids.shape[1] - token_ids.shape[1]) if continuations > 0 else max_new_tokens
1227
+ if n_new <= 0:
1228
+ break
1229
+
1230
+ out = self.generate(full_ids, max_new_tokens=n_new,
1231
+ temperature=temperature, top_k=top_k,
1232
+ repetition_penalty=repetition_penalty)
1233
+ full_ids = out
1234
+
1235
+ out_text = tokenizer.decode(out[0].tolist())
1236
+ prompt_text = tokenizer.decode(token_ids[0].tolist())
1237
+ generated = out_text[len(prompt_text):]
1238
+
1239
+ from .continuation import is_response_complete, extract_tail_for_continuation
1240
+
1241
+ if is_response_complete(generated):
1242
+ break
1243
+
1244
+ tail = extract_tail_for_continuation(generated, tail_lines=tail_lines)
1245
+ if not tail:
1246
+ break
1247
+
1248
+ continuations += 1
1249
+ full_text = prompt_text + tail
1250
+ full_ids = torch.tensor([tokenizer.encode(full_text)],
1251
+ dtype=torch.long, device=full_ids.device)
1252
+
1253
+ return full_ids
1254
+
1255
+
1256
+ # ============================================================================
1257
+ # TOKENIZADOR
1258
+ # ============================================================================
1259
+
1260
+ class BPETokenizer:
1261
+ """Wrapper alrededor de tiktoken (GPT-2 compatible)."""
1262
+
1263
+ def __init__(self, encoding: str = 'gpt2'):
1264
+ try:
1265
+ import tiktoken
1266
+ self.enc = tiktoken.get_encoding(encoding)
1267
+ self.vocab_size = self.enc.n_vocab
1268
+ except ImportError:
1269
+ raise ImportError("Instala tiktoken: pip install tiktoken")
1270
+
1271
+ def encode(self, text: str) -> List[int]:
1272
+ return self.enc.encode(text, allowed_special={'<|endoftext|>'})
1273
+
1274
+ def decode(self, tokens: List[int]) -> str:
1275
+ return self.enc.decode(tokens)
1276
+
1277
+ def eot_token(self) -> int:
1278
+ return self.enc.eot_token
1279
+
1280
+
1281
+ # ============================================================================
1282
+ # MULTI-FILE CORPUS SYSTEM
1283
+ # ============================================================================
1284
+
1285
+ _TEXT_EXTENSIONS: set = {
1286
+ '.py', '.c', '.h', '.cpp', '.cc', '.cxx', '.hpp', '.hxx',
1287
+ '.go', '.rs', '.js', '.ts', '.tsx', '.jsx', '.mjs', '.mts',
1288
+ '.md', '.txt', '.json', '.jsonl', '.jsonc',
1289
+ '.yaml', '.yml', '.toml', '.xml', '.svg',
1290
+ '.html', '.htm', '.css', '.scss', '.sass', '.less',
1291
+ '.sh', '.bash', '.zsh', '.fish', '.ash', '.ksh',
1292
+ '.asm', '.s', '.S', '.h',
1293
+ '.tex', '.cls', '.sty',
1294
+ '.r', '.R', '.rb', '.erb', '.rake',
1295
+ '.java', '.kt', '.kts', '.scala', '.groovy', '.gradle',
1296
+ '.swift', '.cs', '.fs', '.fsx', '.vb',
1297
+ '.lua', '.vim', '.el', '.clj', '.cljs', '.cljc', '.edn',
1298
+ '.ex', '.exs', '.erl', '.hrl', '.gleam',
1299
+ '.hs', '.purs', '.Agda', '.Idris',
1300
+ '.sql', '.pl', '.pm', '.t', '.prolog',
1301
+ '.ini', '.cfg', '.conf', '.inf', '.reg',
1302
+ '.env', '.editorconfig', '.gitignore', '.gitattributes',
1303
+ '.dockerfile', 'dockerfile', 'makefile', 'makefile', 'gnumakefile',
1304
+ '.cmake', '.cmake.in', '.mk', '.bazel', '.bzl', '.build',
1305
+ '.proto', '.graphql', '.gql', '.thrift',
1306
+ '.ps1', '.psm1', '.psd1', '.pssc',
1307
+ '.过户', '.汉', # rare but valid UTF-8 filenames
1308
+ }
1309
+
1310
+ _NO_EXT_NAMES: set = {
1311
+ 'makefile', 'gnumakefile', 'cmakelists.txt', 'makefile.in',
1312
+ 'dockerfile', 'containerfile', 'jenkinsfile', 'vagrantfile',
1313
+ 'procfile', 'ruff.toml', 'pylintrc', 'flake8', 'mypy.ini',
1314
+ 'setup.cfg', 'tox.ini', 'pytest.ini', '.coveragerc',
1315
+ 'requirements.txt', 'requirements-dev.txt', 'pyproject.toml',
1316
+ 'package.json', 'package-lock.json', 'tsconfig.json', 'jsconfig.json',
1317
+ 'browserslist', '.babelrc', '.eslintrc', '.prettierrc',
1318
+ 'vite.config', 'rollup.config', 'webpack.config', 'next.config',
1319
+ 'nuxt.config', 'svelte.config', 'tailwind.config', 'postcss.config',
1320
+ 'gradle.properties', 'build.gradle', 'settings.gradle',
1321
+ 'cargo.toml', 'cargo.lock', 'rustfmt.toml', 'clippy.toml',
1322
+ 'go.mod', 'go.sum', 'go.work',
1323
+ 'build.gradle.kts', 'settings.gradle.kts',
1324
+ 'composercjson', 'composer.lock',
1325
+ 'gemfile', 'gemfile.lock', 'rakefile', 'rakefile.rb',
1326
+ 'dockerignore', 'editorconfig', 'gitignore', 'gitattributes',
1327
+ 'pre-commit', 'shell.nix', 'default.nix', 'flake.nix',
1328
+ 'hosts', 'resolv.conf', 'nginx.conf', 'apache2.conf',
1329
+ 'run', 'env', 'configure', 'install', 'build', 'clean',
1330
+ }
1331
+
1332
+ _SKIP_SUFFIXES: set = {
1333
+ '.o', '.obj', '.so', '.dll', '.dylib', '.exe', '.elf', '.ko',
1334
+ '.bin', '.dat', '.pt', '.pth', '.ckpt', '.safetensors',
1335
+ '.zip', '.tar', '.gz', '.tar.gz', '.tgz', '.bz2', '.xz', '.7z',
1336
+ '.mp4', '.avi', '.mkv', '.mov', '.webm', '.mp3', '.wav', '.flac',
1337
+ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.svg', '.ico',
1338
+ '.ttf', '.woff', '.woff2', '.eot', '.otf',
1339
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
1340
+ '.db', '.sqlite', '.sqlite3', '.db3',
1341
+ '.pem', '.key', '.ccache', '.jks', '.keystore', '.p12', '.pfx',
1342
+ '.pyc', '.pyo', '.class', '.jar', '.war',
1343
+ '.iso', '.img', '.qcow2', '.vmdk', '.ova',
1344
+ '.py.bak', '.py.old', '.py.orig',
1345
+ '.min.js', '.min.css', '.bundle.js',
1346
+ '.lock', '.lock.json', # package lock files are data, not code
1347
+ }
1348
+
1349
+ _SKIP_DIRS: set = {
1350
+ '.git', '.svn', '.hg', '.bzr', 'node_modules', '__pycache__',
1351
+ '.venv', 'venv', 'env', '.env', '.conda',
1352
+ 'build', 'dist', '.next', 'out', '.nuxt', '.svelte-kit',
1353
+ '.obsidian', '.trash', '.cache', '.tmp',
1354
+ 'target', '.rustup', '.cargo', '.cargo/registry',
1355
+ '.mypy_cache', '.pytest_cache', '.ruff_cache',
1356
+ '.phpunit', '.parrot', 'lombok', '.gradle',
1357
+ 'pb_data', '.qtcreator', '.idea', '.vscode',
1358
+ '.android', 'gradle/wrapper', '.gradle',
1359
+ 'site-packages', 'node_modules', 'bower_components',
1360
+ '__pypackages__', '.pip-cache', '.npm', '.yarn',
1361
+ '.cache/pip', '.cache/modernizr',
1362
+ }
1363
+
1364
+ _SKIP_DIRS: set = {
1365
+ '.git', 'node_modules', '__pycache__', '.venv', 'venv',
1366
+ 'build', 'dist', '.obsidian', '.mypy_cache', '.pytest_cache',
1367
+ '.ruff_cache', 'target', '.next', 'out', '.qtcreator',
1368
+ 'pb_data',
1369
+ }
1370
+
1371
+ _SKIP_SUFFIXES: set = {
1372
+ '.o', '.obj', '.so', '.dll', '.dylib', '.exe', '.elf', '.ko',
1373
+ '.bin', '.dat', '.pt', '.pth', '.ckpt', '.safetensors',
1374
+ '.zip', '.tar', '.gz', '.tar.gz', '.tgz', '.bz2', '.xz', '.7z',
1375
+ '.mp4', '.avi', '.mkv', '.mov', '.webm', '.mp3', '.wav', '.flac',
1376
+ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.svg', '.ico',
1377
+ '.ttf', '.woff', '.woff2', '.eot', '.otf',
1378
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
1379
+ '.db', '.sqlite', '.sqlite3', '.db3',
1380
+ '.pem', '.key', '.ccache', '.jks', '.keystore',
1381
+ '.pyc', '.pyo', '.class', '.jar', '.war',
1382
+ '.iso', '.img', '.qcow2', '.vmdk', '.ova', '.safetensors',
1383
+ }
1384
+
1385
+
1386
+ class FileManifest:
1387
+ """Disk-cached manifest of text files found in a directory tree."""
1388
+
1389
+ def __init__(self, root: str, cache_dir: str, logger: logging.Logger):
1390
+ self.root = root
1391
+ self.cache_dir = cache_dir
1392
+ self.logger = logger
1393
+ os.makedirs(cache_dir, exist_ok=True)
1394
+ self.cache_path = os.path.join(cache_dir, 'file_manifest.json')
1395
+
1396
+ def scan(self, force: bool = False) -> List[str]:
1397
+ """Walk directory tree collecting text file paths. Cached to disk."""
1398
+ if not force and os.path.exists(self.cache_path):
1399
+ self.logger.info(f"Loading file manifest from cache: {self.cache_path}")
1400
+ try:
1401
+ with open(self.cache_path, 'r', encoding='utf-8') as f:
1402
+ paths = json.load(f)
1403
+ self.logger.info(f"Manifest: {len(paths)} files (cached)")
1404
+ return paths
1405
+ except (json.JSONDecodeError, IOError):
1406
+ self.logger.warning("Corrupted manifest cache, rescanning...")
1407
+
1408
+ self.logger.info(f"Scanning directory tree: {self.root}")
1409
+ paths = []
1410
+ count_total = 0
1411
+ count_skipped = 0
1412
+ try:
1413
+ base = Path(self.root).resolve()
1414
+ for entry in base.rglob('*'):
1415
+ count_total += 1
1416
+ if count_total % 50_000 == 0:
1417
+ self.logger.info(
1418
+ f" Scan progress: {count_total:,} entries visited, "
1419
+ f"{len(paths):,} text files found, "
1420
+ f"{count_skipped:,} skipped")
1421
+
1422
+ if not entry.is_file():
1423
+ continue
1424
+ parts = set(entry.relative_to(base).parts)
1425
+ if parts & _SKIP_DIRS:
1426
+ count_skipped += 1
1427
+ continue
1428
+ suffix = entry.suffix.lower()
1429
+ if suffix in _SKIP_SUFFIXES:
1430
+ count_skipped += 1
1431
+ continue
1432
+ if suffix in _TEXT_EXTENSIONS:
1433
+ paths.append(str(entry))
1434
+ continue
1435
+ if suffix == '' and entry.name.lower() in _NO_EXT_NAMES:
1436
+ paths.append(str(entry))
1437
+ continue
1438
+ if suffix == '':
1439
+ count_skipped += 1
1440
+ continue
1441
+ count_skipped += 1
1442
+ except PermissionError:
1443
+ pass
1444
+
1445
+ self.logger.info(
1446
+ f"Scan complete: {count_total:,} total entries, "
1447
+ f"{len(paths):,} text files, {count_skipped:,} skipped")
1448
+ with open(self.cache_path, 'w', encoding='utf-8') as f:
1449
+ json.dump(paths, f)
1450
+ return paths
1451
+
1452
+
1453
+ class MemmapTokenizer:
1454
+ """Tokenizes file paths into a memory-mapped numpy array on disk.
1455
+
1456
+ Uses incremental file reading and batched writing to avoid loading
1457
+ all tokens into RAM. Tokens are stored as raw int64 on disk and
1458
+ accessed via numpy memmap (OS-level paging, near-zero RAM footprint).
1459
+ """
1460
+
1461
+ CHUNK_TOKENS = 2_000_000
1462
+
1463
+ def __init__(self, cache_dir: str, logger: logging.Logger):
1464
+ self.cache_dir = cache_dir
1465
+ self.logger = logger
1466
+ os.makedirs(cache_dir, exist_ok=True)
1467
+
1468
+ def tokenize(self, file_paths: List[str], tokenizer: 'BPETokenizer',
1469
+ cache_key: str, max_tokens: int,
1470
+ min_chars: int = 20) -> np.ndarray:
1471
+ """Tokenize all files and return a memory-mapped numpy array.
1472
+
1473
+ Args:
1474
+ file_paths: List of absolute file paths to tokenize.
1475
+ tokenizer: BPE tokenizer instance.
1476
+ cache_key: Unique key for caching tokens to disk.
1477
+ max_tokens: Maximum number of tokens to produce.
1478
+ min_chars: Skip files with fewer characters.
1479
+
1480
+ Returns:
1481
+ np.ndarray backed by a memmap on disk. Only accessed pages
1482
+ are loaded into RAM by the OS virtual memory system.
1483
+ """
1484
+ cache_path = os.path.join(self.cache_dir, f'tokens_{cache_key}.bin')
1485
+ meta_path = os.path.join(self.cache_dir, f'tokens_{cache_key}.json')
1486
+
1487
+ if os.path.exists(cache_path) and os.path.exists(meta_path):
1488
+ with open(meta_path, 'r') as f:
1489
+ meta = json.load(f)
1490
+ n = meta['num_tokens']
1491
+ self.logger.info(f"Loading cached tokens: {n:,} tokens ({cache_key})")
1492
+ return np.memmap(cache_path, dtype=np.int64, mode='r', shape=(n,))
1493
+
1494
+ self.logger.info(
1495
+ f"Tokenizing {len(file_paths):,} files (max {max_tokens:,} tokens)...")
1496
+
1497
+ batch = []
1498
+ total = 0
1499
+ processed = 0
1500
+
1501
+ with open(cache_path, 'wb') as f:
1502
+ for path in file_paths:
1503
+ try:
1504
+ with open(path, 'r', encoding='utf-8', errors='replace') as fh:
1505
+ text = fh.read()
1506
+ except (OSError, UnicodeError):
1507
+ continue
1508
+ if len(text) < min_chars:
1509
+ continue
1510
+ file_tokens = tokenizer.encode(text)
1511
+ batch.extend(file_tokens)
1512
+ total += len(file_tokens)
1513
+ processed += 1
1514
+
1515
+ if len(batch) >= self.CHUNK_TOKENS:
1516
+ arr = np.array(batch, dtype=np.int64)
1517
+ f.write(arr.tobytes())
1518
+ batch = []
1519
+
1520
+ if total >= max_tokens > 0:
1521
+ self.logger.info(
1522
+ f"Reached max_tokens ({max_tokens:,}) after {processed} files")
1523
+ break
1524
+
1525
+ if batch:
1526
+ arr = np.array(batch, dtype=np.int64)
1527
+ f.write(arr.tobytes())
1528
+
1529
+ total = os.path.getsize(cache_path) // 8
1530
+ with open(meta_path, 'w') as f:
1531
+ json.dump({
1532
+ 'num_tokens': total,
1533
+ 'cache_key': cache_key,
1534
+ 'files_processed': processed,
1535
+ }, f)
1536
+
1537
+ self.logger.info(
1538
+ f"Tokenized {total:,} tokens from {processed} files, cached: {cache_path}")
1539
+ return np.memmap(cache_path, dtype=np.int64, mode='r', shape=(total,))
1540
+
1541
+
1542
+ class MappedTokenDataset(torch.utils.data.Dataset):
1543
+ """Memory-mapped token dataset for sequence-to-sequence LM training.
1544
+
1545
+ The token array is backed by a numpy memmap file on disk.
1546
+ Only accessed slices are paged into RAM by the OS. The .copy()
1547
+ in __getitem__ ensures the returned torch.Tensor owns its memory,
1548
+ which is required for DataLoader collation with worker processes.
1549
+ """
1550
+
1551
+ def __init__(self, tokens: np.ndarray, seq_len: int):
1552
+ self.tokens = tokens
1553
+ self.seq_len = seq_len
1554
+ self.n_seqs = (len(tokens) - 1) // seq_len
1555
+
1556
+ def __len__(self) -> int:
1557
+ return self.n_seqs
1558
+
1559
+ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
1560
+ start = idx * self.seq_len
1561
+ x = torch.as_tensor(
1562
+ self.tokens[start:start + self.seq_len].copy(),
1563
+ dtype=torch.long,
1564
+ )
1565
+ y = torch.as_tensor(
1566
+ self.tokens[start + 1:start + self.seq_len + 1].copy(),
1567
+ dtype=torch.long,
1568
+ )
1569
+ return x, y
1570
+
1571
+
1572
+ # ============================================================================
1573
+ # TEXT QUALITY FILTER
1574
+ # ============================================================================
1575
+
1576
+ class TextFilter:
1577
+ """Filters low-quality files from the corpus based on multiple heuristics."""
1578
+
1579
+ def __init__(self, config: 'TopoGPT2Config', logger: logging.Logger):
1580
+ self.cfg = config
1581
+ self.logger = logger
1582
+ self._seen_hashes: set = set()
1583
+ self._stats = {
1584
+ 'total': 0, 'entropy_low': 0, 'entropy_high': 0,
1585
+ 'line_too_long': 0, 'special_token_high': 0, 'dup': 0, 'passed': 0,
1586
+ }
1587
+
1588
+ def _compute_entropy(self, text: str) -> float:
1589
+ """Shannon entropy of byte frequencies (bits per byte)."""
1590
+ if not text:
1591
+ return 0.0
1592
+ import collections
1593
+ freq = collections.Counter(text)
1594
+ total = len(text)
1595
+ entropy = 0.0
1596
+ for count in freq.values():
1597
+ p = count / total
1598
+ if p > 0:
1599
+ entropy -= p * math.log2(p)
1600
+ return entropy
1601
+
1602
+ def _has_long_lines(self, text: str, threshold: int) -> bool:
1603
+ """Return True if any line exceeds threshold characters."""
1604
+ for line in text.split('\n'):
1605
+ if len(line) > threshold:
1606
+ return True
1607
+ return False
1608
+
1609
+ def _special_token_ratio(self, text: str, tokenizer: 'BPETokenizer') -> float:
1610
+ """Fraction of tokens that are pure whitespace or indentation-only."""
1611
+ try:
1612
+ tokens = tokenizer.encode(text)
1613
+ except Exception:
1614
+ return 1.0
1615
+ if not tokens:
1616
+ return 1.0
1617
+ whitespace_toks = sum(
1618
+ 1 for t in tokens
1619
+ if tokenizer.enc.decode([t]).strip() == ''
1620
+ )
1621
+ return whitespace_toks / len(tokens)
1622
+
1623
+ def _content_hash(self, text: str) -> str:
1624
+ return hashlib.sha256(text.encode('utf-8', errors='replace')).hexdigest()[:16]
1625
+
1626
+ def filter_file(self, path: str, tokenizer: Optional['BPETokenizer'] = None) -> Optional[str]:
1627
+ """Read and evaluate a file. Returns text if passed, None if filtered."""
1628
+ self._stats['total'] += 1
1629
+ try:
1630
+ with open(path, 'r', encoding='utf-8', errors='replace') as f:
1631
+ text = f.read()
1632
+ except (OSError, UnicodeError):
1633
+ return None
1634
+
1635
+ if len(text) < self.cfg.CORPUS_MIN_FILE_CHARS:
1636
+ return None
1637
+
1638
+ entropy = self._compute_entropy(text)
1639
+ if entropy < self.cfg.FILTER_ENTROPY_MIN:
1640
+ self._stats['entropy_low'] += 1
1641
+ return None
1642
+ if entropy > self.cfg.FILTER_ENTROPY_MAX:
1643
+ self._stats['entropy_high'] += 1
1644
+ return None
1645
+
1646
+ if self._has_long_lines(text, self.cfg.FILTER_MAX_LINE_LEN):
1647
+ self._stats['line_too_long'] += 1
1648
+ return None
1649
+
1650
+ if tokenizer is not None and self.cfg.FILTER_SPECIAL_TOKEN_RATIO_MAX < 1.0:
1651
+ ratio = self._special_token_ratio(text, tokenizer)
1652
+ if ratio > self.cfg.FILTER_SPECIAL_TOKEN_RATIO_MAX:
1653
+ self._stats['special_token_high'] += 1
1654
+ return None
1655
+
1656
+ if self.cfg.FILTER_ENABLE_DEDUP:
1657
+ h = self._content_hash(text)
1658
+ if h in self._seen_hashes:
1659
+ self._stats['dup'] += 1
1660
+ return None
1661
+ self._seen_hashes.add(h)
1662
+
1663
+ self._stats['passed'] += 1
1664
+ return text
1665
+
1666
+ def report(self) -> Dict[str, int]:
1667
+ s = self._stats
1668
+ self.logger.info(
1669
+ f"TextFilter: total={s['total']} passed={s['passed']} "
1670
+ f"entropy_low={s['entropy_low']} entropy_high={s['entropy_high']} "
1671
+ f"long_line={s['line_too_long']} special_tok={s['special_token_high']} "
1672
+ f"dedup={s['dup']}")
1673
+ return dict(self._stats)
1674
+
1675
+
1676
+ # ============================================================================
1677
+ # CURRICULUM DATASET
1678
+ # ============================================================================
1679
+
1680
+ class CurriculumDataset(torch.utils.data.Dataset):
1681
+ """Tiered dataset that exposes short/medium/all files based on line count.
1682
+
1683
+ Works as a wrapper around MappedTokenDataset. Provides __getitem__ that
1684
+ only samples from the active tier, avoiding dataset duplication.
1685
+ """
1686
+
1687
+ def __init__(self, tokens: np.ndarray, seq_len: int,
1688
+ file_tiers: Dict[int, List[int]],
1689
+ active_tier: int = 0,
1690
+ logger: Optional[logging.Logger] = None):
1691
+ self.base = MappedTokenDataset(tokens, seq_len)
1692
+ self.file_tiers = file_tiers
1693
+ self.active_tier = active_tier
1694
+ self.logger = logger or setup_logger('CurriculumDS')
1695
+ self._update_len()
1696
+
1697
+ def _update_len(self):
1698
+ tier_files = self.file_tiers.get(self.active_tier, [])
1699
+ self.n_seqs = len(tier_files) if tier_files else 0
1700
+ self.logger.info(
1701
+ f"Curriculum tier {self.active_tier}: {self.n_seqs} sequences")
1702
+
1703
+ def set_tier(self, tier: int):
1704
+ self.active_tier = tier
1705
+ self._update_len()
1706
+
1707
+ def __len__(self) -> int:
1708
+ return self.n_seqs
1709
+
1710
+ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
1711
+ return self.base[idx]
1712
+
1713
+
1714
+ # ============================================================================
1715
+ # CURRICULUM BUILDERS
1716
+ # ============================================================================
1717
+
1718
+ def build_file_tiers(paths: List[str], short: int, med: int) -> Dict[int, List[int]]:
1719
+ """Classify file paths into complexity tiers by line count.
1720
+
1721
+ Returns dict: tier -> list of file indices in that tier.
1722
+ Tier 0 = short (<=short lines), tier 1 = medium, tier 2 = all.
1723
+ """
1724
+ tiers: Dict[int, list] = {0: [], 1: [], 2: []}
1725
+ for i, path in enumerate(paths):
1726
+ try:
1727
+ with open(path, 'r', encoding='utf-8', errors='replace') as f:
1728
+ n_lines = sum(1 for _ in f)
1729
+ except OSError:
1730
+ n_lines = 0
1731
+ if n_lines <= short:
1732
+ tiers[0].append(i)
1733
+ elif n_lines <= med:
1734
+ tiers[1].append(i)
1735
+ else:
1736
+ tiers[2].append(i)
1737
+ return tiers
1738
+
1739
+
1740
+ # ============================================================================
1741
+ # PROGRESSIVE SEQUENCE LENGTH TRAINER
1742
+ # ============================================================================
1743
+
1744
+ class ProgressiveSeqLenTrainer:
1745
+ """Trainer that dynamically adjusts MAX_SEQ_LEN across training phases.
1746
+
1747
+ Phase schedule (configurable):
1748
+ phase 0: seq_len=128, epochs=3
1749
+ phase 1: seq_len=256, epochs=3
1750
+ phase 2: seq_len=512, epochs=4
1751
+
1752
+ Each phase rebuilds the DataLoader with the new sequence length.
1753
+ """
1754
+
1755
+ def __init__(self, base_trainer: 'TopoGPT2Trainer'):
1756
+ self.trainer = base_trainer
1757
+ self.cfg = base_trainer.config
1758
+ self.logger = base_trainer.logger
1759
+ self.phase = 0
1760
+
1761
+ def _build_dataloader(self, dataset: MappedTokenDataset, seq_len: int,
1762
+ batch_size: int, is_train: bool) -> torch.utils.data.DataLoader:
1763
+ ds = MappedTokenDataset(dataset.tokens, seq_len)
1764
+ n_cpu = max(1, os.cpu_count() // 2)
1765
+ pin = 'cuda' in self.cfg.DEVICE
1766
+ return torch.utils.data.DataLoader(
1767
+ ds, batch_size=batch_size, shuffle=is_train,
1768
+ num_workers=n_cpu, pin_memory=pin, drop_last=is_train,
1769
+ )
1770
+
1771
+ def run(self, train_paths: List[str], val_paths: List[str],
1772
+ tokenizer: 'BPETokenizer',
1773
+ file_tiers: Optional[Dict[int, List[int]]] = None,
1774
+ phases: Optional[Tuple[Tuple[int, int], ...]] = None) -> Dict:
1775
+ """Run training with progressive sequence length across phases."""
1776
+ if phases is None:
1777
+ phases = tuple(zip(
1778
+ self.cfg.PROGRESSIVE_SEQ_STEPS,
1779
+ self.cfg.PROGRESSIVE_SEQ_EPOCHS,
1780
+ ))
1781
+
1782
+ self.logger.info(
1783
+ f"ProgressiveSeqLen: {len(phases)} phases, "
1784
+ f"{self.cfg.EPOCHS} total epochs")
1785
+
1786
+ for phase_idx, (seq_len, n_epochs) in enumerate(phases):
1787
+ self.logger.info(
1788
+ f"[Phase {phase_idx}] seq_len={seq_len}, epochs={n_epochs}")
1789
+
1790
+ self.cfg.MAX_SEQ_LEN = seq_len
1791
+
1792
+ train_ds = MappedTokenDataset(
1793
+ self.trainer._tokens_cache['train'], seq_len)
1794
+ val_ds = MappedTokenDataset(
1795
+ self.trainer._tokens_cache.get('val', self.trainer._tokens_cache['train']),
1796
+ seq_len)
1797
+
1798
+ n_cpu = max(1, os.cpu_count() // 2)
1799
+ pin = 'cuda' in self.cfg.DEVICE
1800
+ train_dl = torch.utils.data.DataLoader(
1801
+ train_ds, batch_size=self.cfg.BATCH_SIZE, shuffle=True,
1802
+ num_workers=n_cpu, pin_memory=pin, drop_last=True)
1803
+ val_dl = torch.utils.data.DataLoader(
1804
+ val_ds, batch_size=self.cfg.BATCH_SIZE * 2, shuffle=False,
1805
+ num_workers=max(1, n_cpu // 2), pin_memory=pin, drop_last=True)
1806
+
1807
+ prev_epochs = self.trainer.completed_epochs
1808
+ self.trainer.train(train_dl, val_dl)
1809
+ self.logger.info(
1810
+ f"[Phase {phase_idx}] done. Epochs completed: "
1811
+ f"{prev_epochs} -> {self.trainer.completed_epochs}")
1812
+
1813
+ return self.trainer.history
1814
+
1815
+
1816
+ # ============================================================================
1817
+ # SPECULATIVE DECODING
1818
+ # ============================================================================
1819
+
1820
+ class SpeculativeDecoder:
1821
+ """Speculative decoding with a small draft model.
1822
+
1823
+ Draft model uses SPEC_DECODE_DRAFT_SCALE (e.g. 'micro').
1824
+ The draft generates K tokens, then the target model verifies them
1825
+ in a single forward pass. Accepted tokens are kept; rejected ones
1826
+ trigger a fallback to the target model sampling.
1827
+ """
1828
+
1829
+ def __init__(self, target_model: 'TopoGPT2', config: 'TopoGPT2Config',
1830
+ logger: logging.Logger):
1831
+ self.target = target_model
1832
+ self.cfg = config
1833
+ self.logger = logger
1834
+ self.draft: Optional[TopoGPT2] = None
1835
+ self._build_draft()
1836
+
1837
+ def _build_draft(self):
1838
+ draft_cfg = TopoGPT2Config(
1839
+ SCALE=self.cfg.SPEC_DECODE_DRAFT_SCALE,
1840
+ DEVICE=self.cfg.DEVICE,
1841
+ RANDOM_SEED=self.cfg.RANDOM_SEED,
1842
+ )
1843
+ draft_cfg.N_KV_HEADS = max(1, draft_cfg.N_HEADS // 4)
1844
+ self.draft = TopoGPT2(draft_cfg).to(self.cfg.DEVICE)
1845
+ self.draft.eval()
1846
+ self.logger.info(
1847
+ f"SpeculativeDecoder: draft scale={self.cfg.SPEC_DECODE_DRAFT_SCALE}, "
1848
+ f"params={self.draft.count_params()['total']:,}")
1849
+
1850
+ @torch.no_grad()
1851
+ def generate(self, token_ids: torch.Tensor,
1852
+ max_new_tokens: int = 200,
1853
+ temperature: float = 0.8,
1854
+ top_k: int = 50,
1855
+ repetition_penalty: float = 1.0) -> torch.Tensor:
1856
+ """Autoregressive generation via speculative decoding.
1857
+
1858
+ Each round: draft generates K tokens, target verifies all K in
1859
+ one O(1) forward pass (longest context), then samples the first
1860
+ rejection from the target.
1861
+ """
1862
+ self.target.eval()
1863
+ self.draft.eval()
1864
+ cfg = self.cfg
1865
+
1866
+ ctx = token_ids[:, -cfg.MAX_SEQ_LEN:]
1867
+ logits, _, past_kvs = self.target(ctx)
1868
+ logits = logits[:, -1, :] / max(temperature, 1e-8)
1869
+ if repetition_penalty != 1.0:
1870
+ mask = torch.zeros_like(logits, dtype=torch.bool)
1871
+ mask.scatter_(1, ctx, True)
1872
+ logits = torch.where(mask, logits / repetition_penalty, logits)
1873
+ if top_k > 0:
1874
+ v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
1875
+ logits[logits < v[:, -1:]] = float('-inf')
1876
+ next_tok = torch.multinomial(F.softmax(logits, dim=-1), 1)
1877
+ token_ids = torch.cat([token_ids, next_tok], dim=1)
1878
+
1879
+ generated = [next_tok.item()]
1880
+ draft_past_kvs = None
1881
+
1882
+ while len(generated) < max_new_tokens:
1883
+ k = min(cfg.SPEC_DECODE_K, max_new_tokens - len(generated))
1884
+
1885
+ draft_ctx = token_ids
1886
+ if draft_past_kvs is not None:
1887
+ draft_logits, _, draft_past_kvs = self.draft(
1888
+ token_ids[:, -self.draft.config.MAX_SEQ_LEN:],
1889
+ past_kvs=draft_past_kvs)
1890
+ else:
1891
+ draft_logits, _, draft_past_kvs = self.draft(draft_ctx)
1892
+ draft_tok = torch.multinomial(
1893
+ F.softmax(draft_logits[:, -1, :] / max(temperature, 1e-8), dim=-1), k)
1894
+
1895
+ accepted = []
1896
+ for t in range(k):
1897
+ d_tok = draft_tok[:, t:t+1]
1898
+ target_logits, _, past_kvs = self.target(
1899
+ d_tok, past_kvs=past_kvs)
1900
+ t_logits = target_logits[:, -1, :] / max(temperature, 1e-8)
1901
+ if repetition_penalty != 1.0:
1902
+ mask = torch.zeros_like(t_logits, dtype=torch.bool)
1903
+ mask.scatter_(1, token_ids, True)
1904
+ t_logits = torch.where(mask, t_logits / repetition_penalty, t_logits)
1905
+ prob = F.softmax(t_logits, dim=-1)
1906
+ if top_k > 0:
1907
+ v, _ = torch.topk(t_logits, min(top_k, t_logits.size(-1)))
1908
+ mask_k = t_logits < v[:, -1:]
1909
+ logits_topk = t_logits.clone()
1910
+ logits_topk[mask_k] = float('-inf')
1911
+ prob = F.softmax(logits_topk, dim=-1)
1912
+ target_tok = torch.multinomial(prob, 1)
1913
+ if target_tok.item() == d_tok.item():
1914
+ accepted.append(d_tok.item())
1915
+ token_ids = torch.cat([token_ids, d_tok], dim=1)
1916
+ else:
1917
+ token_ids = torch.cat([token_ids, target_tok], dim=1)
1918
+ generated.extend(accepted)
1919
+ break
1920
+ else:
1921
+ for t in range(k):
1922
+ token_ids = torch.cat([token_ids, draft_tok[:, t:t+1]], dim=1)
1923
+ generated.extend([draft_tok[:, t:t+1].item() for t in range(k)])
1924
+
1925
+ if (token_ids[:, -1:] == 50256).all():
1926
+ break
1927
+
1928
+ return token_ids
1929
+
1930
+
1931
+ # ============================================================================
1932
+ # QUANTIZED EMBEDDING FOR INFERENCE
1933
+ # ============================================================================
1934
+
1935
+ class QuantizedEmbedding(nn.Module):
1936
+ """Wrapper around nn.Embedding that applies dynamic quantization.
1937
+
1938
+ Applies int8 quantization to the embedding weight matrix after loading.
1939
+ Supports both embed (int8) and FFN (int4) quantization modes.
1940
+ """
1941
+
1942
+ def __init__(self, embed: nn.Embedding, mode: str = 'int8'):
1943
+ super().__init__()
1944
+ self.num_embeddings = embed.num_embeddings
1945
+ self.embedding_dim = embed.embedding_dim
1946
+ self.padding_idx = embed.padding_idx
1947
+ self.max_norm = embed.max_norm
1948
+ self.norm_type = embed.norm_type
1949
+ self.scale_grad_by_freq = embed.scale_grad_by_freq
1950
+ self.sparse = embed.sparse
1951
+
1952
+ if mode == 'int8':
1953
+ self.weight = torch.quantize_per_channel(
1954
+ embed.weight.data.to(torch.float32), # fp32 for quantize_per_channel
1955
+ scales=torch.ones(embed.weight.shape[0]),
1956
+ zero_points=torch.zeros(embed.weight.shape[0], dtype=torch.long),
1957
+ axis=0,
1958
+ dtype=torch.qint8,
1959
+ )
1960
+ elif mode == 'int4':
1961
+ self.weight = torch.quantize_per_channel(
1962
+ embed.weight.data.to(torch.float32),
1963
+ scales=torch.ones(embed.weight.shape[0]),
1964
+ zero_points=torch.zeros(embed.weight.shape[0], dtype=torch.long),
1965
+ axis=0,
1966
+ dtype=torch.qint8,
1967
+ )
1968
+ else:
1969
+ self.weight = embed.weight
1970
+
1971
+ def forward(self, indices: torch.Tensor) -> torch.Tensor:
1972
+ return torch.embedding(self.weight, indices, padding_idx=self.padding_idx)
1973
+
1974
+
1975
+ def apply_quantization(model: 'TopoGPT2', config: 'TopoGPT2Config') -> 'TopoGPT2':
1976
+ """Quantize embedding and lm_head layers for reduced VRAM usage."""
1977
+ if not config.QUANTIZE_EMBEDDINGS:
1978
+ return model
1979
+
1980
+ logger = setup_logger('Quantize')
1981
+ device = config.DEVICE
1982
+
1983
+ embed = model.token_embed
1984
+ quantized_embed = QuantizedEmbedding(embed, mode='int8')
1985
+ model.token_embed = quantized_embed
1986
+ model.lm_head = nn.Linear(
1987
+ model.lm_head.in_features, model.lm_head.out_features, bias=False)
1988
+ model.lm_head.weight = quantized_embed.weight
1989
+
1990
+ logger.info(
1991
+ f"Quantized embeddings to int8: shape={quantized_embed.weight.shape}")
1992
+
1993
+ if config.QUANTIZE_FFN:
1994
+ logger.warning("FFN int4 quantization is experimental; skipping.")
1995
+
1996
+ return model
1997
+
1998
+
1999
+ # ============================================================================
2000
+ # CURRICULUM TRAINER WRAPPER
2001
+ # ============================================================================
2002
+
2003
+ class CurriculumTrainer:
2004
+ """Extends TopoGPT2Trainer with curriculum + progressive seq len support.
2005
+
2006
+ Provides:
2007
+ - Tokens cache for progressive sequence length rebuilding
2008
+ - Curriculum dataset wrapping (short / medium / all tiers)
2009
+ """
2010
+
2011
+ def __init__(self, model, config, tokenizer=None):
2012
+ self._base = TopoGPT2Trainer(model, config, tokenizer)
2013
+ self.cfg = config
2014
+ self.logger = self._base.logger
2015
+ self._tokens_cache: Dict[str, np.ndarray] = {}
2016
+ self._curriculum_ds: Optional[CurriculumDataset] = None
2017
+
2018
+ def cache_tokens(self, key: str, tokens: np.ndarray):
2019
+ self._tokens_cache[key] = tokens
2020
+
2021
+ @property
2022
+ def model(self):
2023
+ return self._base.model
2024
+
2025
+ @property
2026
+ def optimizer(self):
2027
+ return self._base.optimizer
2028
+
2029
+ @property
2030
+ def scaler(self):
2031
+ return self._base.scaler
2032
+
2033
+ @property
2034
+ def amp_dtype(self):
2035
+ return self._base.amp_dtype
2036
+
2037
+ @property
2038
+ def completed_epochs(self):
2039
+ return self._base.completed_epochs
2040
+
2041
+ @completed_epochs.setter
2042
+ def completed_epochs(self, v):
2043
+ self._base.completed_epochs = v
2044
+
2045
+ @property
2046
+ def global_step(self):
2047
+ return self._base.global_step
2048
+
2049
+ @global_step.setter
2050
+ def global_step(self, v):
2051
+ self._base.global_step = v
2052
+
2053
+ @property
2054
+ def best_val_loss(self):
2055
+ return self._base.best_val_loss
2056
+
2057
+ @best_val_loss.setter
2058
+ def best_val_loss(self, v):
2059
+ self._base.best_val_loss = v
2060
+
2061
+ @property
2062
+ def history(self):
2063
+ return self._base.history
2064
+
2065
+ @property
2066
+ def ckpt_mgr(self):
2067
+ return self._base.ckpt_mgr
2068
+
2069
+ def resume(self) -> bool:
2070
+ return self._base.resume()
2071
+
2072
+ def _current_state(self) -> Dict:
2073
+ return self._base._current_state()
2074
+
2075
+ def _cosine_lr(self, *args, **kwargs):
2076
+ return self._base._cosine_lr(*args, **kwargs)
2077
+
2078
+ def _set_lr(self, *args, **kwargs):
2079
+ self._base._set_lr(*args, **kwargs)
2080
+
2081
+ def evaluate(self, dataloader):
2082
+ return self._base.evaluate(dataloader)
2083
+
2084
+ def _sample_text(self, *args, **kwargs):
2085
+ self._base._sample_text(*args, **kwargs)
2086
+
2087
+ def _progressive_train(self, train_paths: List[str], val_paths: List[str],
2088
+ tokenizer: 'BPETokenizer',
2089
+ phases: Tuple[Tuple[int, int], ...],
2090
+ memtok: 'MemmapTokenizer') -> Dict:
2091
+ """Training loop with progressive sequence length across phases."""
2092
+ cfg = self.cfg
2093
+ device = cfg.DEVICE
2094
+ logger = self.logger
2095
+
2096
+ for phase_idx, (seq_len, n_epochs) in enumerate(phases):
2097
+ logger.info(
2098
+ f"[ProgressiveSeqLen Phase {phase_idx}] "
2099
+ f"seq_len={seq_len}, epochs={n_epochs}")
2100
+
2101
+ cfg.MAX_SEQ_LEN = seq_len
2102
+
2103
+ train_ds = MappedTokenDataset(
2104
+ self._tokens_cache['train'], seq_len)
2105
+ val_ds = MappedTokenDataset(
2106
+ self._tokens_cache.get('val', self._tokens_cache['train']),
2107
+ seq_len)
2108
+
2109
+ n_cpu = max(1, os.cpu_count() // 2)
2110
+ pin = 'cuda' in device
2111
+ train_dl = torch.utils.data.DataLoader(
2112
+ train_ds, batch_size=cfg.BATCH_SIZE, shuffle=True,
2113
+ num_workers=n_cpu, pin_memory=pin, drop_last=True)
2114
+ val_dl = torch.utils.data.DataLoader(
2115
+ val_ds, batch_size=cfg.BATCH_SIZE * 2, shuffle=False,
2116
+ num_workers=max(1, n_cpu // 2), pin_memory=pin, drop_last=True)
2117
+
2118
+ prev_completed = self.completed_epochs
2119
+ self._base.train(train_dl, val_dl)
2120
+ logger.info(
2121
+ f"[Phase {phase_idx}] done. "
2122
+ f"Epochs: {prev_completed} -> {self.completed_epochs}")
2123
+
2124
+ return self.history
2125
+
2126
+ def train(self, train_dl, val_dl):
2127
+ return self._base.train(train_dl, val_dl)
2128
+
2129
+ def run_curriculum(self, train_paths: List[str], val_paths: List[str],
2130
+ tokenizer: 'BPETokenizer',
2131
+ phases: Optional[Tuple[Tuple[int, int], ...]] = None):
2132
+ """Top-level entry point: curriculum + progressive seq len."""
2133
+ if phases is None:
2134
+ phases = tuple(zip(
2135
+ self.cfg.PROGRESSIVE_SEQ_STEPS,
2136
+ self.cfg.PROGRESSIVE_SEQ_EPOCHS,
2137
+ ))
2138
+ return self._progressive_train(
2139
+ train_paths, val_paths, tokenizer, phases,
2140
+ MemmapTokenizer(self.cfg.DATA_DIR, self.logger))
2141
+
2142
+
2143
+ def _tokenize_text_to_memmap(text: str, tokenizer: 'BPETokenizer',
2144
+ path: str, max_tokens: int) -> np.ndarray:
2145
+ """Tokenize a single text string and write tokens to disk as raw int64."""
2146
+ tokens = np.array(tokenizer.encode(text)[:max_tokens], dtype=np.int64)
2147
+ tokens.tofile(path)
2148
+ return np.memmap(path, dtype=np.int64, mode='r', shape=(len(tokens),))
2149
+
2150
+
2151
+ # ============================================================================
2152
+ # CHECKPOINT MANAGER (safetensors + JSON + optimizer.pt)
2153
+ # ============================================================================
2154
+
2155
+ class CheckpointManager:
2156
+ """
2157
+ Gestiona checkpoints de forma acumulativa y segura.
2158
+
2159
+ Estructura en disco:
2160
+ checkpoints_topogpt2/
2161
+ latest/
2162
+ model.safetensors <- pesos del modelo (formato seguro, sin pickle)
2163
+ optimizer.pt <- estado del optimizador (requiere .pt)
2164
+ state.json <- metadatos: epoch, step, historial, config
2165
+ best/
2166
+ model.safetensors
2167
+ state.json
2168
+ step_NNNNN/ <- snapshots periodicos (rotados)
2169
+ model.safetensors
2170
+ optimizer.pt
2171
+ state.json
2172
+
2173
+ El historial se ACUMULA entre sesiones de entrenamiento: cada --resume
2174
+ agrega nuevas entradas a train_loss[], val_loss[], etc.
2175
+ """
2176
+
2177
+ LATEST = 'latest'
2178
+ BEST = 'best'
2179
+
2180
+ def __init__(self, config: TopoGPT2Config, logger: logging.Logger):
2181
+ self.root = config.CHECKPOINT_DIR
2182
+ self.max_snapshots = config.MAX_CHECKPOINTS
2183
+ self.interval_min = config.CHECKPOINT_INTERVAL_MINUTES
2184
+ self.logger = logger
2185
+ self._last_save_time = time.time()
2186
+ self._snapshot_dirs: List[str] = []
2187
+ for sub in (self.LATEST, self.BEST):
2188
+ os.makedirs(os.path.join(self.root, sub), exist_ok=True)
2189
+
2190
+ def patch_config_for_resume(self, cfg: 'TopoGPT2Config') -> None:
2191
+ """
2192
+ Lee el checkpoint 'latest' y ajusta cfg.N_KV_HEADS / cfg.GQA_GROUPS
2193
+ para que coincidan con la arquitectura guardada.
2194
+ Necesario cuando el codigo cambio GQA despues de guardar el checkpoint.
2195
+ """
2196
+ model_path = os.path.join(self.root, self.LATEST, 'model.safetensors')
2197
+ if not os.path.exists(model_path):
2198
+ return
2199
+ sd = st_load(model_path, device='cpu')
2200
+ key = 'layers.0.attn.k_proj.weight'
2201
+ if key in sd:
2202
+ ckpt_kv_dim = sd[key].shape[0]
2203
+ d_head = cfg.D_MODEL // cfg.N_HEADS
2204
+ ckpt_n_kv = ckpt_kv_dim // d_head
2205
+ if ckpt_n_kv != cfg.N_KV_HEADS:
2206
+ self.logger.info(
2207
+ f"Checkpoint usa N_KV_HEADS={ckpt_n_kv} "
2208
+ f"(config actual: {cfg.N_KV_HEADS}). "
2209
+ "Ajustando para compatibilidad con el checkpoint."
2210
+ )
2211
+ cfg.N_KV_HEADS = ckpt_n_kv
2212
+ cfg.GQA_GROUPS = cfg.N_HEADS // ckpt_n_kv
2213
+ del sd
2214
+
2215
+ # ------------------------------------------------------------------
2216
+ # Persistencia de pesos: safetensors (sin pickle, sin ejecucion de codigo)
2217
+ # ------------------------------------------------------------------
2218
+
2219
+ def _save_model(self, model: 'TopoGPT2', directory: str) -> None:
2220
+ path = os.path.join(directory, 'model.safetensors')
2221
+ # El weight-tying hace que lm_head.weight y token_embed.weight sean el
2222
+ # mismo tensor en memoria. safetensors rechaza tensores compartidos,
2223
+ # por lo que excluimos lm_head.weight al guardar; al cargar el modelo
2224
+ # el weight-tying lo restaura automaticamente desde token_embed.weight.
2225
+ sd = {
2226
+ k: v.contiguous().cpu()
2227
+ for k, v in model.state_dict().items()
2228
+ if k != 'lm_head.weight'
2229
+ }
2230
+ st_save(sd, path)
2231
+
2232
+ def _load_model(self, model: 'TopoGPT2', directory: str) -> None:
2233
+ path = os.path.join(directory, 'model.safetensors')
2234
+ sd = st_load(path, device=model.config.DEVICE)
2235
+ # strict=False porque lm_head.weight no esta en el archivo
2236
+ # (se reconstruye via weight-tying con token_embed.weight)
2237
+ missing, unexpected = model.load_state_dict(sd, strict=False)
2238
+ # lm_head.weight se reconstruye via weight-tying, ignorar siempre
2239
+ unexpected_real = [k for k in unexpected if k != 'lm_head.weight']
2240
+ missing_real = [k for k in missing if k != 'lm_head.weight']
2241
+ if unexpected_real:
2242
+ # Claves del checkpoint que ya no existen en el modelo (modulos eliminados).
2243
+ # Con strict=False esos pesos simplemente no se cargan; solo advertimos.
2244
+ modules = sorted({k.split('.')[2] if k.count('.') >= 2 else k
2245
+ for k in unexpected_real})
2246
+ self.logger.warning(
2247
+ f"Checkpoint tiene {len(unexpected_real)} claves no presentes en el "
2248
+ f"modelo actual (modulos eliminados: {modules}). Se ignoran."
2249
+ )
2250
+ if missing_real:
2251
+ # Claves del modelo que no estaban en el checkpoint (modulos nuevos).
2252
+ modules = sorted({k.split('.')[2] if k.count('.') >= 2 else k
2253
+ for k in missing_real})
2254
+ self.logger.warning(
2255
+ f"Modelo tiene {len(missing_real)} claves sin checkpoint "
2256
+ f"(modulos nuevos: {modules}). Se inicializan aleatoriamente."
2257
+ )
2258
+
2259
+ # ------------------------------------------------------------------
2260
+ # Persistencia del optimizador: torch.save (estado con tipos mixtos)
2261
+ # ------------------------------------------------------------------
2262
+
2263
+ def _save_optimizer(self, optimizer, directory: str) -> None:
2264
+ torch.save(optimizer.state_dict(), os.path.join(directory, 'optimizer.pt'))
2265
+
2266
+ def _load_optimizer(self, optimizer, directory: str, device: str) -> None:
2267
+ path = os.path.join(directory, 'optimizer.pt')
2268
+ sd = torch.load(path, map_location=device, weights_only=True)
2269
+ optimizer.load_state_dict(sd)
2270
+
2271
+ # ------------------------------------------------------------------
2272
+ # Metadatos en JSON (legible por humanos, sin ejecucion de codigo)
2273
+ # ------------------------------------------------------------------
2274
+
2275
+ def _save_state(self, state: Dict, directory: str) -> None:
2276
+ path = os.path.join(directory, 'state.json')
2277
+ with open(path, 'w', encoding='utf-8') as f:
2278
+ json.dump(state, f, indent=2)
2279
+
2280
+ def _load_state(self, directory: str) -> Dict:
2281
+ path = os.path.join(directory, 'state.json')
2282
+ if not os.path.exists(path):
2283
+ return {}
2284
+ with open(path, 'r', encoding='utf-8') as f:
2285
+ return json.load(f)
2286
+
2287
+ # ------------------------------------------------------------------
2288
+ # API publica
2289
+ # ------------------------------------------------------------------
2290
+
2291
+ def should_save(self) -> bool:
2292
+ return (time.time() - self._last_save_time) / 60 >= self.interval_min
2293
+
2294
+ def save(self, model: 'TopoGPT2', optimizer,
2295
+ state: Dict, is_best: bool = False) -> str:
2296
+ """
2297
+ Guarda checkpoint completo.
2298
+
2299
+ state debe contener al menos: completed_epochs, global_step,
2300
+ best_val_loss, history, config.
2301
+ """
2302
+ # Siempre actualizar 'latest'
2303
+ latest_dir = os.path.join(self.root, self.LATEST)
2304
+ self._save_model(model, latest_dir)
2305
+ self._save_optimizer(optimizer, latest_dir)
2306
+ self._save_state(state, latest_dir)
2307
+
2308
+ # Guardar 'best' si mejoro la perdida de validacion
2309
+ if is_best:
2310
+ best_dir = os.path.join(self.root, self.BEST)
2311
+ self._save_model(model, best_dir)
2312
+ self._save_state(state, best_dir)
2313
+ self.logger.info(
2314
+ f"Nuevo mejor modelo guardado "
2315
+ f"(val_loss={state.get('best_val_loss', '?'):.4f})"
2316
+ )
2317
+
2318
+ # Snapshot periodico con rotacion
2319
+ step = state.get('global_step', 0)
2320
+ snap_dir = os.path.join(self.root, f'step_{step:07d}')
2321
+ os.makedirs(snap_dir, exist_ok=True)
2322
+ self._save_model(model, snap_dir)
2323
+ self._save_optimizer(optimizer, snap_dir)
2324
+ self._save_state(state, snap_dir)
2325
+ self._snapshot_dirs.append(snap_dir)
2326
+
2327
+ # Eliminar snapshots antiguos manteniendo solo max_snapshots
2328
+ while len(self._snapshot_dirs) > self.max_snapshots:
2329
+ old = self._snapshot_dirs.pop(0)
2330
+ import shutil
2331
+ if os.path.isdir(old):
2332
+ shutil.rmtree(old, ignore_errors=True)
2333
+
2334
+ self._last_save_time = time.time()
2335
+ self.logger.info(f"Checkpoint guardado: epoch={state.get('completed_epochs')}, "
2336
+ f"step={step}")
2337
+ return snap_dir
2338
+
2339
+ def load_latest(self, model: 'TopoGPT2', optimizer) -> Dict:
2340
+ """
2341
+ Carga el ultimo checkpoint guardado.
2342
+ Devuelve el state dict (vacio si no hay checkpoint).
2343
+ """
2344
+ latest_dir = os.path.join(self.root, self.LATEST)
2345
+ model_path = os.path.join(latest_dir, 'model.safetensors')
2346
+ if not os.path.exists(model_path):
2347
+ self.logger.info("No se encontro checkpoint previo, iniciando desde cero.")
2348
+ return {}
2349
+
2350
+ self._load_model(model, latest_dir)
2351
+ opt_path = os.path.join(latest_dir, 'optimizer.pt')
2352
+ if os.path.exists(opt_path):
2353
+ try:
2354
+ self._load_optimizer(optimizer, latest_dir, model.config.DEVICE)
2355
+ except Exception as e:
2356
+ self.logger.warning(f"No se pudo cargar estado del optimizador: {e}")
2357
+
2358
+ state = self._load_state(latest_dir)
2359
+ self.logger.info(
2360
+ f"Checkpoint cargado: epoch={state.get('completed_epochs', 0)}, "
2361
+ f"step={state.get('global_step', 0)}, "
2362
+ f"best_val_loss={state.get('best_val_loss', float('inf')):.4f}"
2363
+ )
2364
+ return state
2365
+
2366
+ def load_best(self, model: 'TopoGPT2') -> Dict:
2367
+ """Carga el mejor modelo guardado (solo pesos, sin optimizador)."""
2368
+ best_dir = os.path.join(self.root, self.BEST)
2369
+ model_path = os.path.join(best_dir, 'model.safetensors')
2370
+ if not os.path.exists(model_path):
2371
+ self.logger.warning("No hay checkpoint 'best' disponible.")
2372
+ return {}
2373
+ self._load_model(model, best_dir)
2374
+ state = self._load_state(best_dir)
2375
+ self.logger.info(f"Mejor modelo cargado: val_loss={state.get('best_val_loss', '?'):.4f}")
2376
+ return state
2377
+
2378
+ def has_checkpoint(self) -> bool:
2379
+ return os.path.exists(
2380
+ os.path.join(self.root, self.LATEST, 'model.safetensors')
2381
+ )
2382
+
2383
+
2384
+ # ============================================================================
2385
+ # ENTRENADOR ACUMULATIVO
2386
+ # ============================================================================
2387
+
2388
+ class TopoGPT2Trainer:
2389
+ """
2390
+ Entrenador acumulativo y resumible.
2391
+
2392
+ Caracteristicas:
2393
+ - Checkpoint automatico en safetensors cada N minutos + cada epoch
2394
+ - Historial acumulativo entre sesiones (--resume agrega al historial existente)
2395
+ - Guarda el mejor modelo en checkpoints/best/ automaticamente
2396
+ - LR schedule: cosine con warmup relativo a los steps de ESTA sesion
2397
+ - Mixed Precision (AMP) + acumulacion de gradientes
2398
+ """
2399
+
2400
+ def __init__(self, model: 'TopoGPT2', config: TopoGPT2Config,
2401
+ tokenizer: Optional['BPETokenizer'] = None):
2402
+ self.model = model.to(config.DEVICE)
2403
+ self.config = config
2404
+ self.tokenizer = tokenizer # para generacion de muestra por epoch
2405
+ self.logger = setup_logger('Trainer', config.LOG_LEVEL)
2406
+ self.ckpt_mgr = CheckpointManager(config, self.logger)
2407
+
2408
+ self.optimizer = torch.optim.AdamW(
2409
+ model.parameters(),
2410
+ lr=config.LEARNING_RATE,
2411
+ weight_decay=config.WEIGHT_DECAY,
2412
+ betas=(0.9, 0.95),
2413
+ )
2414
+
2415
+ dev = config.DEVICE
2416
+ self.scaler = torch.amp.GradScaler(
2417
+ dev.split(':')[0],
2418
+ enabled=config.USE_AMP and 'cuda' in dev,
2419
+ )
2420
+ self.amp_dtype = torch.float16 if 'cuda' in dev else torch.bfloat16
2421
+
2422
+ # Estado de entrenamiento (se carga desde checkpoint si se hace --resume)
2423
+ self.completed_epochs: int = 0
2424
+ self.global_step: int = 0
2425
+ self.best_val_loss: float = float('inf')
2426
+ self.history: Dict[str, List] = {
2427
+ 'train_loss': [], 'val_loss': [], 'perplexity': [], 'recon_loss': [],
2428
+ 'delta': [], 'alpha': [], 't_eff': [], 'kappa': [],
2429
+ 'berry_phase': [], 'winding_number': [], 'lc': [], 'sp': [], 'phase': [],
2430
+ }
2431
+
2432
+ # Metricas mecanisticas (Book.md)
2433
+ self.mets = MechanisticMetrics(config)
2434
+
2435
+ def resume(self) -> bool:
2436
+ """
2437
+ Carga el ultimo checkpoint disponible.
2438
+ Restaura: pesos del modelo, estado del optimizador, historial acumulado,
2439
+ epoch/step completados y mejor val_loss.
2440
+ Devuelve True si se cargo un checkpoint, False si empieza de cero.
2441
+ """
2442
+ if not self.ckpt_mgr.has_checkpoint():
2443
+ return False
2444
+
2445
+ state = self.ckpt_mgr.load_latest(self.model, self.optimizer)
2446
+ if not state:
2447
+ return False
2448
+
2449
+ self.completed_epochs = state.get('completed_epochs', 0)
2450
+ self.global_step = state.get('global_step', 0)
2451
+ self.best_val_loss = state.get('best_val_loss', float('inf'))
2452
+
2453
+ # Restaurar historial acumulado (incluye metricas mecanisticas)
2454
+ prev_hist = state.get('history', {})
2455
+ for key in self.history:
2456
+ self.history[key] = prev_hist.get(key, [])
2457
+
2458
+ return True
2459
+
2460
+ def _current_state(self) -> Dict:
2461
+ """Construye el dict de estado para persistir en state.json."""
2462
+ return {
2463
+ 'completed_epochs': self.completed_epochs,
2464
+ 'global_step': self.global_step,
2465
+ 'best_val_loss': self.best_val_loss,
2466
+ 'history': self.history,
2467
+ 'config': asdict(self.config),
2468
+ 'saved_at': datetime.now().isoformat(),
2469
+ }
2470
+
2471
+ def _cosine_lr(self, step_in_session: int, total_steps_session: int) -> float:
2472
+ """Cosine decay con warmup. El schedule es relativo a la sesion actual."""
2473
+ warmup = max(1, int(total_steps_session * self.config.WARMUP_RATIO))
2474
+ if step_in_session < warmup:
2475
+ return self.config.LEARNING_RATE * step_in_session / warmup
2476
+ t = (step_in_session - warmup) / max(total_steps_session - warmup, 1)
2477
+ return self.config.LEARNING_RATE * 0.5 * (1.0 + math.cos(math.pi * t))
2478
+
2479
+ def _set_lr(self, lr: float) -> None:
2480
+ for pg in self.optimizer.param_groups:
2481
+ pg['lr'] = lr
2482
+
2483
+ def train(self, train_dl: torch.utils.data.DataLoader,
2484
+ val_dl: torch.utils.data.DataLoader) -> Dict[str, List[float]]:
2485
+ """
2486
+ Entrena cfg.EPOCHS epocas adicionales a partir de completed_epochs.
2487
+ El historial se acumula sobre sesiones previas.
2488
+ """
2489
+ cfg = self.config
2490
+ device = cfg.DEVICE
2491
+ epochs_this_session = cfg.EPOCHS
2492
+ steps_per_epoch = len(train_dl)
2493
+ total_steps_session = steps_per_epoch * epochs_this_session // cfg.GRAD_ACCUM_STEPS
2494
+ step_in_session = 0
2495
+
2496
+ self.logger.info("=" * 70)
2497
+ self.logger.info("TopoGPT2 - Entrenamiento acumulativo")
2498
+ p = self.model.count_params()
2499
+ self.logger.info(f"Parametros: {p['total']:,}")
2500
+ self.logger.info(f"Device: {device} | AMP: {cfg.USE_AMP} | "
2501
+ f"GradCkpt: {cfg.GRADIENT_CHECKPOINTING}")
2502
+ self.logger.info(f"Escala: {cfg.SCALE} | D_MODEL: {cfg.D_MODEL} | "
2503
+ f"Capas: {cfg.N_LAYERS} | SEQ_LEN: {cfg.MAX_SEQ_LEN}")
2504
+ self.logger.info(f"Toro: {cfg.TORUS_RADIAL_BINS}x{cfg.TORUS_ANGULAR_BINS} "
2505
+ f"= {cfg.N_TORUS_NODES} nodos")
2506
+ self.logger.info(f"Epocas previas completadas: {self.completed_epochs}")
2507
+ self.logger.info(f"Epocas a entrenar ahora: {epochs_this_session}")
2508
+ self.logger.info(f"Mejor val_loss hasta ahora: {self.best_val_loss:.4f}")
2509
+ self.logger.info("=" * 70)
2510
+
2511
+ for epoch_offset in range(1, epochs_this_session + 1):
2512
+ epoch_abs = self.completed_epochs + epoch_offset
2513
+ self.model.train()
2514
+ epoch_lm_loss = 0.0
2515
+ epoch_recon = 0.0
2516
+ n_batches = 0
2517
+
2518
+ self.optimizer.zero_grad()
2519
+
2520
+ for batch_x, batch_y in train_dl:
2521
+ batch_x = batch_x.to(device, non_blocking=True)
2522
+ batch_y = batch_y.to(device, non_blocking=True)
2523
+
2524
+ lr = self._cosine_lr(step_in_session, total_steps_session)
2525
+ self._set_lr(lr)
2526
+
2527
+ with torch.amp.autocast(device_type=device.split(':')[0],
2528
+ dtype=self.amp_dtype,
2529
+ enabled=cfg.USE_AMP):
2530
+ logits, recon_loss, _ = self.model(batch_x)
2531
+ lm_loss = F.cross_entropy(
2532
+ logits.view(-1, cfg.VOCAB_SIZE),
2533
+ batch_y.view(-1),
2534
+ )
2535
+ loss = (lm_loss + cfg.AE_RECON_WEIGHT * recon_loss) / cfg.GRAD_ACCUM_STEPS
2536
+
2537
+ self.scaler.scale(loss).backward()
2538
+ self.mets.update_grad_buffer(self.model)
2539
+
2540
+ n_batches += 1
2541
+ if n_batches % cfg.GRAD_ACCUM_STEPS == 0:
2542
+ self.scaler.unscale_(self.optimizer)
2543
+ torch.nn.utils.clip_grad_norm_(
2544
+ self.model.parameters(), cfg.GRADIENT_CLIP_NORM)
2545
+ self.scaler.step(self.optimizer)
2546
+ self.scaler.update()
2547
+ self.optimizer.zero_grad()
2548
+ self.global_step += 1
2549
+ step_in_session += 1
2550
+
2551
+ epoch_lm_loss += lm_loss.item()
2552
+ epoch_recon += recon_loss.item()
2553
+
2554
+ if n_batches % cfg.LOG_INTERVAL_STEPS == 0:
2555
+ self.logger.info(
2556
+ f"Epoch {epoch_abs} | batch {n_batches}/{steps_per_epoch} | "
2557
+ f"lm_loss={epoch_lm_loss/n_batches:.4f} | "
2558
+ f"recon={epoch_recon/n_batches:.4f} | "
2559
+ f"lr={lr:.2e}"
2560
+ )
2561
+
2562
+ # Checkpoint periodico intra-epoch
2563
+ if self.ckpt_mgr.should_save():
2564
+ self.ckpt_mgr.save(
2565
+ self.model, self.optimizer,
2566
+ self._current_state(), is_best=False,
2567
+ )
2568
+
2569
+ # -- Fin de epoch --
2570
+ avg_lm = epoch_lm_loss / max(n_batches, 1)
2571
+ avg_recon = epoch_recon / max(n_batches, 1)
2572
+ val_loss, ppl = self.evaluate(val_dl)
2573
+
2574
+ # Metricas mecanisticas (Book.md)
2575
+ m = self.mets.compute_all(
2576
+ self.model, lr=self._cosine_lr(step_in_session, total_steps_session),
2577
+ dataloader=val_dl, compute_kappa=False,
2578
+ )
2579
+
2580
+ self.completed_epochs += 1
2581
+ self.history['train_loss'].append(avg_lm)
2582
+ self.history['val_loss'].append(val_loss)
2583
+ self.history['perplexity'].append(ppl)
2584
+ self.history['recon_loss'].append(avg_recon)
2585
+ self.history['delta'].append(m.get('delta', 0.0))
2586
+ self.history['alpha'].append(m.get('alpha', 0.0))
2587
+ self.history['t_eff'].append(m.get('t_eff', 0.0))
2588
+ self.history['kappa'].append(m.get('kappa', 0.0))
2589
+ self.history['berry_phase'].append(m.get('berry_phase', 0.0))
2590
+ self.history['winding_number'].append(m.get('winding_number', 0))
2591
+ self.history['lc'].append(m.get('lc', 0.0))
2592
+ self.history['sp'].append(m.get('sp', 0.0))
2593
+ self.history['phase'].append(m.get('phase', 'unknown'))
2594
+
2595
+ is_best = val_loss < self.best_val_loss
2596
+ if is_best:
2597
+ self.best_val_loss = val_loss
2598
+
2599
+ self.logger.info(
2600
+ f"[Epoch {epoch_abs:4d}] "
2601
+ f"train={avg_lm:.4f} | val={val_loss:.4f} | "
2602
+ f"ppl={ppl:.2f} | recon={avg_recon:.4f} | "
2603
+ + self.mets.format_log(m)
2604
+ + (" <- mejor" if is_best else "")
2605
+ )
2606
+
2607
+ # Checkpoint al final de cada epoch
2608
+ self.ckpt_mgr.save(
2609
+ self.model, self.optimizer,
2610
+ self._current_state(), is_best=is_best,
2611
+ )
2612
+
2613
+ # Muestra de texto (calidad cualitativa)
2614
+ self._sample_text(self.tokenizer)
2615
+
2616
+ return self.history
2617
+
2618
+ @torch.no_grad()
2619
+ def _sample_text(self, tokenizer=None, prompts: Optional[List[str]] = None,
2620
+ max_new: int = 80, temperature: float = 0.8, top_k: int = 40) -> None:
2621
+ """
2622
+ Genera una muestra de texto al final de cada epoch para monitorear
2623
+ la calidad cualitativa del modelo (detecta degeneracion, repeticion, etc.).
2624
+ """
2625
+ if tokenizer is None:
2626
+ return
2627
+ default_prompts = [
2628
+ "Once upon a time",
2629
+ "The little girl",
2630
+ "In the forest there",
2631
+ ]
2632
+ sample_prompts = prompts or default_prompts
2633
+ self.model.eval()
2634
+ device = self.config.DEVICE
2635
+ self.logger.info("-" * 60)
2636
+ self.logger.info("Muestra de generacion:")
2637
+ for prompt in sample_prompts:
2638
+ ids = tokenizer.encode(prompt)
2639
+ ids_t = torch.tensor([ids], dtype=torch.long, device=device)
2640
+ gen = self.model.generate(
2641
+ ids_t, max_new_tokens=max_new,
2642
+ temperature=temperature, top_k=top_k)
2643
+ text = tokenizer.decode(gen[0].tolist())
2644
+ # Mostrar solo los primeros 200 chars para no saturar el log
2645
+ preview = text.replace('\n', ' ')[:200]
2646
+ self.logger.info(f" [{prompt!r}] -> {preview!r}")
2647
+ self.logger.info("-" * 60)
2648
+ self.model.train()
2649
+
2650
+ @torch.no_grad()
2651
+ def evaluate(self, dataloader) -> Tuple[float, float]:
2652
+ self.model.eval()
2653
+ total_loss, n = 0.0, 0
2654
+ device = self.config.DEVICE
2655
+
2656
+ for batch_x, batch_y in dataloader:
2657
+ batch_x = batch_x.to(device, non_blocking=True)
2658
+ batch_y = batch_y.to(device, non_blocking=True)
2659
+ with torch.amp.autocast(device_type=device.split(':')[0],
2660
+ dtype=self.amp_dtype,
2661
+ enabled=self.config.USE_AMP):
2662
+ logits, _, _ = self.model(batch_x)
2663
+ loss = F.cross_entropy(
2664
+ logits.view(-1, self.config.VOCAB_SIZE), batch_y.view(-1))
2665
+ total_loss += loss.item()
2666
+ n += 1
2667
+
2668
+ self.model.train()
2669
+ avg = total_loss / max(n, 1)
2670
+ ppl = math.exp(min(avg, 20.0))
2671
+ return avg, ppl
2672
+
2673
+
2674
+ # ============================================================================
2675
+ # INTERPRETACION MECANISTICA (Book.md)
2676
+ # Metricas del diagrama de fases: delta, kappa, T_eff, alpha, Berry, LC, SP
2677
+ # Clasificacion de fases: cold_glass, functional_glass,
2678
+ # topological_insulator, discrete_crystal
2679
+ # ============================================================================
2680
+
2681
+ class MechanisticMetrics:
2682
+ """
2683
+ Calcula todas las metricas del diagrama de fases de Book.md.
2684
+
2685
+ Todas las metricas se derivan de cantidades medibles (pesos, gradientes):
2686
+
2687
+ delta (δ): margen de discretizacion. max|w - round(w)|
2688
+ δ≈0 -> cristal; δ≈0.49 -> vidrio frio
2689
+ kappa (κ): numero de condicion de la covarianza del gradiente.
2690
+ κ≈1 -> cristalino; κ>>1 -> amorfo
2691
+ T_eff: temperatura efectiva = (lr/2) * Var(gradiente).
2692
+ T_eff→0 -> congelado; T_eff alto -> ruidoso
2693
+ alpha (α): indice de pureza = -log(δ + ε).
2694
+ α=20 -> perfecto; α<1 -> vidrio
2695
+ berry: fase de Berry de los kernels espectrales imaginarios.
2696
+ |berry|>π/2 con winding≠0 -> insulador topologico
2697
+ lc: complejidad local = 1 - similitud coseno promedio entre filas.
2698
+ sp: superposicion = correlacion promedio inter-fila de pesos.
2699
+ """
2700
+
2701
+ def __init__(self, config: 'TopoGPT2Config'):
2702
+ self.config = config
2703
+ self.grad_buffer: deque = deque(maxlen=50)
2704
+ self.max_grad_sample = 500
2705
+ # ------------------------------------------------------------------
2706
+ # Metricas individuales
2707
+ # ------------------------------------------------------------------
2708
+
2709
+ def compute_delta(self, model: nn.Module) -> float:
2710
+ margins = []
2711
+ for p in model.parameters():
2712
+ if p.numel() > 0 and p.is_floating_point():
2713
+ margins.append((p.data - p.data.round()).abs().max().item())
2714
+ return max(margins) if margins else 0.0
2715
+
2716
+ def compute_alpha(self, delta: float) -> float:
2717
+ if delta < 1e-8:
2718
+ return 20.0
2719
+ return -math.log(delta + 1e-10)
2720
+
2721
+ def update_grad_buffer(self, model: nn.Module) -> None:
2722
+ """Captura gradientes de forma segura, ignorando tensores corruptos."""
2723
+ parts = []
2724
+ for p in model.parameters():
2725
+ if p.grad is not None and p.numel() > 0:
2726
+ try:
2727
+ # Mover a CPU de forma síncrona y segura. Si la GPU está en estado
2728
+ # corrupto, se captura aquí sin abortar el entrenamiento.
2729
+ grad_cpu = p.grad.detach().cpu().float()
2730
+ except RuntimeError:
2731
+ continue
2732
+
2733
+ # 1. Verificar corrupción numérica en CPU (evita sincronizaciones CUDA bloqueantes)
2734
+ if torch.isnan(grad_cpu).any() or torch.isinf(grad_cpu).any():
2735
+ continue # Salta gradientes inválidos generados por overflow AMP
2736
+
2737
+ # 2. Muestreo uniforme para mantener el buffer ligero y estadísticamente representativo
2738
+ flat = grad_cpu.flatten()
2739
+ if flat.numel() > self.max_grad_sample:
2740
+ step = max(1, flat.numel() // self.max_grad_sample)
2741
+ flat = flat[::step][:self.max_grad_sample]
2742
+ parts.append(flat)
2743
+
2744
+ if parts:
2745
+ self.grad_buffer.append(torch.cat(parts))
2746
+
2747
+ def compute_t_eff(self, lr: float) -> float:
2748
+ """T_eff = lr/2 * Var(gradiente). Temperatura termodinamica efectiva."""
2749
+ if len(self.grad_buffer) < 2:
2750
+ return float('inf')
2751
+ G = torch.stack(list(self.grad_buffer)) # [N, D]
2752
+ var = G.pow(2).mean() - G.mean(0).pow(2).mean()
2753
+ return float(lr * var.clamp(min=0).item() / 2.0)
2754
+
2755
+ def compute_kappa(self, model: nn.Module, dataloader,
2756
+ n_batches: int = 5) -> float:
2757
+ """
2758
+ κ = λ_max / λ_min de la covarianza del gradiente.
2759
+ Parámetro de orden para cristalización (κ≈1 = cristal).
2760
+ Nota: requiere pasadas backward adicionales. Se ejecuta con protección
2761
+ para no corromper el estado AMP del trainer principal.
2762
+ """
2763
+ device = self.config.DEVICE
2764
+ model.eval()
2765
+ grad_samples = []
2766
+ for i, (bx, by) in enumerate(dataloader):
2767
+ if i >= n_batches:
2768
+ break
2769
+ bx, by = bx.to(device), by.to(device)
2770
+ model.zero_grad()
2771
+ try:
2772
+ with torch.no_grad():
2773
+ pass # Placeholder para mantener estructura
2774
+ logits, _, _ = model(bx)
2775
+ loss = F.cross_entropy(
2776
+ logits.view(-1, self.config.VOCAB_SIZE), by.view(-1))
2777
+ loss.backward()
2778
+
2779
+ parts = []
2780
+ for p in model.parameters():
2781
+ if p.grad is not None:
2782
+ # Muestreo seguro a CPU para análisis
2783
+ g = p.grad.detach().flatten()[:300].cpu().float()
2784
+ if not torch.isnan(g).any() and not torch.isinf(g).any():
2785
+ parts.append(g)
2786
+ if parts:
2787
+ grad_samples.append(torch.cat(parts))
2788
+ except RuntimeError:
2789
+ # Si el cálculo de kappa falla (ej. OOM o kernel crash),
2790
+ # retorna inf para marcar inestabilidad sin abortar
2791
+ model.zero_grad()
2792
+ return float('inf')
2793
+ finally:
2794
+ model.zero_grad()
2795
+
2796
+ model.train()
2797
+ if len(grad_samples) < 2:
2798
+ return float('inf')
2799
+ G = torch.stack(grad_samples)
2800
+ try:
2801
+ if G.size(0) < G.size(1):
2802
+ gram = G @ G.t() / max(G.size(0) - 1, 1)
2803
+ eigs = torch.linalg.eigvalsh(gram).real
2804
+ else:
2805
+ eigs = torch.linalg.eigvalsh(torch.cov(G.t())).real
2806
+ eigs = eigs[eigs > 1e-10]
2807
+ if len(eigs) < 2:
2808
+ return float('inf')
2809
+ return float(eigs.max() / eigs.min())
2810
+ except Exception:
2811
+ return float('inf')
2812
+
2813
+ def compute_berry_phase(self, model: nn.Module) -> float:
2814
+ """
2815
+ Fase de Berry de los kernels espectrales imaginarios.
2816
+ Surge de los parametros ki_w, ki_x, ki_y, ki_z de QuaternionSpectralLayer.
2817
+ |berry|>pi/2 con winding!=0 indica estructura topologica.
2818
+ """
2819
+ phases = []
2820
+ for name, p in model.named_parameters():
2821
+ if 'ki_' in name and p.numel() > 0:
2822
+ flat = p.detach().flatten().float()
2823
+ phases.append(torch.atan2(flat, flat.abs() + 1e-10).sum().item())
2824
+ return sum(phases) if phases else 0.0
2825
+
2826
+ def compute_lc(self, model: nn.Module) -> float:
2827
+ """Complejidad local: 1 - similitud coseno promedio entre filas de pesos."""
2828
+ vals = []
2829
+ for name, p in model.named_parameters():
2830
+ if 'weight' in name and p.dim() >= 2 and p.size(0) >= 4:
2831
+ w = p.detach()[:min(p.size(0), 64), :min(p.size(1), 64)].float()
2832
+ norms = w.norm(dim=1, keepdim=True).clamp(min=1e-8)
2833
+ w = w / norms
2834
+ sim = (w @ w.t()).abs()
2835
+ mask = ~torch.eye(sim.size(0), dtype=torch.bool)
2836
+ if mask.any():
2837
+ vals.append(float(1.0 - sim[mask].mean().clamp(0, 1)))
2838
+ return float(np.mean(vals)) if vals else 0.0
2839
+
2840
+ def compute_sp(self, model: nn.Module) -> float:
2841
+ """Superposicion: correlacion inter-fila promedio (entrelazamiento de features)."""
2842
+ vals = []
2843
+ for name, p in model.named_parameters():
2844
+ if 'weight' in name and p.dim() >= 2 and p.size(0) >= 4:
2845
+ w = p.detach()[:min(p.size(0), 64)].float()
2846
+ w = w.reshape(w.size(0), -1)
2847
+ try:
2848
+ corr = torch.corrcoef(w).nan_to_num(0.0).abs()
2849
+ mask = ~torch.eye(corr.size(0), dtype=torch.bool)
2850
+ if mask.any():
2851
+ vals.append(float(corr[mask].mean()))
2852
+ except Exception:
2853
+ pass
2854
+ return float(np.mean(vals)) if vals else 0.0
2855
+
2856
+ def classify_phase(self, delta: float, kappa: float,
2857
+ berry: float) -> str:
2858
+ """
2859
+ Clasificacion de fase segun Book.md:
2860
+
2861
+ discrete_crystal: delta<0.05, kappa<1.5
2862
+ topological_insulator: |berry|>pi/2, winding!=0
2863
+ cold_glass: kappa>>1, delta>0.3
2864
+ functional_glass: intermedio (lo mas comun en LM)
2865
+ """
2866
+ winding = round(berry / (2 * math.pi))
2867
+ if delta < 0.05 and kappa < 1.5:
2868
+ return 'discrete_crystal'
2869
+ if abs(berry) > math.pi / 2 and abs(winding) >= 1:
2870
+ return 'topological_insulator'
2871
+ if kappa > 100 and delta > 0.3:
2872
+ return 'cold_glass'
2873
+ return 'functional_glass'
2874
+
2875
+ def compute_all(self, model: nn.Module, lr: float,
2876
+ dataloader=None, compute_kappa: bool = False) -> Dict[str, Any]:
2877
+ """
2878
+ Calcula todas las metricas.
2879
+ compute_kappa=True hace pasadas backward adicionales (caro, usar cada N epochs).
2880
+ """
2881
+ delta = self.compute_delta(model)
2882
+ alpha = self.compute_alpha(delta)
2883
+ t_eff = self.compute_t_eff(lr)
2884
+ berry = self.compute_berry_phase(model)
2885
+ winding = round(berry / (2 * math.pi))
2886
+ lc = self.compute_lc(model)
2887
+ sp = self.compute_sp(model)
2888
+ kappa = (
2889
+ self.compute_kappa(model, dataloader)
2890
+ if compute_kappa and dataloader is not None
2891
+ else float('inf')
2892
+ )
2893
+ phase = self.classify_phase(delta, kappa, berry)
2894
+ return {
2895
+ 'delta': delta, 'alpha': alpha, 't_eff': t_eff, 'kappa': kappa,
2896
+ 'berry_phase': berry, 'winding_number': winding,
2897
+ 'lc': lc, 'sp': sp, 'phase': phase,
2898
+ }
2899
+
2900
+ def format_log(self, m: Dict[str, Any]) -> str:
2901
+ inf = float('inf')
2902
+ kappa = m.get('kappa', 0.0)
2903
+ kappa_str = f"{kappa:.2f}" if kappa < inf else "inf"
2904
+ return (
2905
+ f"delta={m.get('delta', 0.0):.4f} alpha={m.get('alpha', 0.0):.2f} "
2906
+ f"T_eff={m.get('t_eff', 0.0):.2e} kappa={kappa_str} "
2907
+ f"berry={m.get('berry_phase', 0.0):.2f} "
2908
+ f"wind={m.get('winding_number', 0)} "
2909
+ f"lc={m.get('lc', 0.0):.3f} sp={m.get('sp', 0.0):.3f} "
2910
+ f"[{m.get('phase', 'unknown').upper()}]"
2911
+ )
2912
+
2913
+
2914
+ # ============================================================================
2915
+ # FASE 0: Optimizacion del ratio de kernels espectrales (GOE-GUE)
2916
+ # ============================================================================
2917
+
2918
+ class Phase0_KernelOptimizer:
2919
+ """
2920
+ Encuentra el ratio imaginario/real optimo para los kernels espectrales.
2921
+
2922
+ Analogia con main.py: evalua la transicion GOE→GUE en el espacio
2923
+ de kernels. Un ratio optimo promueve estructura topologica (insulador)
2924
+ vs estructura amorfa (vidrio).
2925
+
2926
+ Metodo: calibra con un mini-batch y mide la varianza del gradiente
2927
+ en funcion del ratio. Ratios que minimizan la varianza de gradiente
2928
+ (maxima coherencia espectral) son preferibles.
2929
+
2930
+ No entrena: solo inicializa los kernels con distintos ratios y mide.
2931
+ Tiempo tipico: < 30 segundos.
2932
+ """
2933
+
2934
+ RATIOS_TO_TEST = [0.05, 0.10, 0.18, 0.30, 0.50] # del config original
2935
+
2936
+ def __init__(self, config: 'TopoGPT2Config', logger: logging.Logger):
2937
+ self.config = config
2938
+ self.logger = logger
2939
+
2940
+ def _measure_ratio(self, ratio: float, sample_batch: Tuple[torch.Tensor, torch.Tensor]) -> float:
2941
+ """
2942
+ Mide la coherencia espectral para un ratio dado.
2943
+ Retorna: varianza del gradiente (menor = mas coherente = mejor).
2944
+ """
2945
+ # Modelo temporal con ese ratio
2946
+ tmp_cfg = TopoGPT2Config.__new__(TopoGPT2Config)
2947
+ tmp_cfg.__dict__.update(self.config.__dict__)
2948
+ tmp_cfg.SPECTRAL_KERNEL_INIT_SCALE = ratio
2949
+ model = TopoGPT2(tmp_cfg).to(self.config.DEVICE)
2950
+
2951
+ bx, by = sample_batch
2952
+ bx, by = bx.to(self.config.DEVICE), by.to(self.config.DEVICE)
2953
+
2954
+ model.zero_grad()
2955
+ logits, _, _ = model(bx)
2956
+ loss = F.cross_entropy(logits.view(-1, self.config.VOCAB_SIZE), by.view(-1))
2957
+ loss.backward()
2958
+
2959
+ grads = []
2960
+ for p in model.parameters():
2961
+ if p.grad is not None:
2962
+ grads.append(p.grad.detach().flatten()[:200].float())
2963
+ if not grads:
2964
+ return float('inf')
2965
+ G = torch.cat(grads)
2966
+ # Menor varianza = gradientes mas isotropos = mejor coherencia
2967
+ return float(G.var().item())
2968
+
2969
+ def optimize(self, dataloader) -> float:
2970
+ """Retorna el mejor ratio de inicializacion de kernels espectrales."""
2971
+ self.logger.info("Fase 0: optimizando ratio de kernels espectrales...")
2972
+ try:
2973
+ sample_batch = next(iter(dataloader))
2974
+ except StopIteration:
2975
+ self.logger.warning("Fase 0: dataloader vacio, usando ratio default 0.02")
2976
+ return 0.02
2977
+
2978
+ results = {}
2979
+ for ratio in self.RATIOS_TO_TEST:
2980
+ score = self._measure_ratio(ratio, sample_batch)
2981
+ results[ratio] = score
2982
+ self.logger.info(f" ratio={ratio:.2f} -> grad_var={score:.4e}")
2983
+
2984
+ best_ratio = min(results, key=results.get)
2985
+ self.logger.info(f"Fase 0: mejor ratio = {best_ratio} (grad_var={results[best_ratio]:.4e})")
2986
+ return best_ratio
2987
+
2988
+
2989
+ # ============================================================================
2990
+ # FASE 1: Prospecting de batch size
2991
+ # ============================================================================
2992
+
2993
+ class Phase1_BatchProspector:
2994
+ """
2995
+ Encuentra el batch size optimo testando candidatos con pocos pasos.
2996
+
2997
+ De main.py: el batch size regula la temperatura del horno de cristalizacion.
2998
+ Batch sizes demasiado chicos -> ruido excesivo (vidrio frio).
2999
+ Batch sizes demasiado grandes -> sin presion annealing (amorfos).
3000
+ La ventana optima empirica de main.py: [24, 128] para Strassen.
3001
+
3002
+ Para LM, testeamos candidatos midiendo:
3003
+ - delta (δ): velocidad de descenso en prospect_steps pasos
3004
+ - T_eff: temperatura efectiva del gradiente
3005
+
3006
+ Tiempo tipico: < 2 minutos para 3 candidatos × 30 pasos.
3007
+ """
3008
+
3009
+ def __init__(self, config: 'TopoGPT2Config', logger: logging.Logger):
3010
+ self.config = config
3011
+ self.logger = logger
3012
+
3013
+ def prospect(self, candidates: List[int], train_dataset,
3014
+ prospect_steps: int = 30) -> int:
3015
+ """Retorna el mejor batch size segun delta y T_eff."""
3016
+ self.logger.info(
3017
+ f"Fase 1: prospecting batch sizes {candidates} "
3018
+ f"({prospect_steps} pasos cada uno)...")
3019
+ device = self.config.DEVICE
3020
+ results = {}
3021
+
3022
+ for bs in candidates:
3023
+ dl = torch.utils.data.DataLoader(
3024
+ train_dataset, batch_size=bs, shuffle=True, drop_last=True)
3025
+ model = TopoGPT2(self.config).to(device)
3026
+ optimizer = torch.optim.AdamW(
3027
+ model.parameters(), lr=self.config.LEARNING_RATE)
3028
+ metrics = MechanisticMetrics(self.config)
3029
+
3030
+ model.train()
3031
+ delta_start = metrics.compute_delta(model)
3032
+
3033
+ for step, (bx, by) in enumerate(dl):
3034
+ if step >= prospect_steps:
3035
+ break
3036
+ bx, by = bx.to(device), by.to(device)
3037
+ optimizer.zero_grad()
3038
+ logits, rl, _ = model(bx)
3039
+ loss = (F.cross_entropy(logits.view(-1, self.config.VOCAB_SIZE),
3040
+ by.view(-1))
3041
+ + self.config.AE_RECON_WEIGHT * rl)
3042
+ loss.backward()
3043
+ metrics.update_grad_buffer(model)
3044
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
3045
+ optimizer.step()
3046
+
3047
+ delta_end = metrics.compute_delta(model)
3048
+ t_eff = metrics.compute_t_eff(self.config.LEARNING_RATE)
3049
+ delta_velocity = (delta_end - delta_start) / max(prospect_steps, 1)
3050
+
3051
+ results[bs] = {
3052
+ 'delta_end': delta_end,
3053
+ 'delta_velocity': delta_velocity,
3054
+ 't_eff': t_eff,
3055
+ }
3056
+ self.logger.info(
3057
+ f" bs={bs:4d}: delta={delta_end:.4f} "
3058
+ f"d_vel={delta_velocity:.2e} T_eff={t_eff:.2e}")
3059
+
3060
+ del model, optimizer
3061
+
3062
+ # Criterio: batch size con velocidad de delta mas negativa
3063
+ # (enfriamiento mas rapido). Desempate: T_eff mas bajo.
3064
+ best_bs = min(
3065
+ results,
3066
+ key=lambda b: (results[b]['delta_velocity'], results[b]['t_eff'])
3067
+ )
3068
+ self.logger.info(f"Fase 1: mejor batch size = {best_bs}")
3069
+ return best_bs
3070
+
3071
+
3072
+ # ============================================================================
3073
+ # FASE 2: Mineria de semillas (seed mining)
3074
+ # ============================================================================
3075
+
3076
+ class Phase2_SeedMiner:
3077
+ """
3078
+ Encuentra semillas prometedoras midiendo la trayectoria de delta.
3079
+
3080
+ De main.py: una semilla "buena" muestra delta descendente en los
3081
+ primeros N pasos (enfriamiento). Una semilla "mala" se estanca en
3082
+ el plateau vidrioso (~0.49).
3083
+
3084
+ Criterio de seleccion:
3085
+ 1. Semillas con delta_velocity < 0 (enfriando) AND kappa bajo.
3086
+ 2. Si no hay, semillas solo enfriando.
3087
+ 3. Fallback: semilla con menor delta final.
3088
+
3089
+ Tiempo tipico: < 3 minutos para 5 semillas × 50 pasos.
3090
+ """
3091
+
3092
+ def __init__(self, config: 'TopoGPT2Config', logger: logging.Logger):
3093
+ self.config = config
3094
+ self.logger = logger
3095
+
3096
+ def mine(self, seed_start: int, n_seeds: int, train_dataset,
3097
+ prospect_steps: int = 50) -> int:
3098
+ """Retorna la semilla con la mejor trayectoria de delta."""
3099
+ self.logger.info(
3100
+ f"Fase 2: minando {n_seeds} semillas desde seed={seed_start} "
3101
+ f"({prospect_steps} pasos cada una)...")
3102
+ device = self.config.DEVICE
3103
+ dl = torch.utils.data.DataLoader(
3104
+ train_dataset, batch_size=self.config.BATCH_SIZE,
3105
+ shuffle=True, drop_last=True)
3106
+ results = {}
3107
+
3108
+ for s in range(seed_start, seed_start + n_seeds):
3109
+ set_seed(s, device)
3110
+ model = TopoGPT2(self.config).to(device)
3111
+ optimizer = torch.optim.AdamW(
3112
+ model.parameters(), lr=self.config.LEARNING_RATE)
3113
+ mets = MechanisticMetrics(self.config)
3114
+
3115
+ delta_start = mets.compute_delta(model)
3116
+ model.train()
3117
+
3118
+ for step, (bx, by) in enumerate(dl):
3119
+ if step >= prospect_steps:
3120
+ break
3121
+ bx, by = bx.to(device), by.to(device)
3122
+ optimizer.zero_grad()
3123
+ logits, rl, _ = model(bx)
3124
+ loss = (F.cross_entropy(logits.view(-1, self.config.VOCAB_SIZE),
3125
+ by.view(-1))
3126
+ + self.config.AE_RECON_WEIGHT * rl)
3127
+ loss.backward()
3128
+ mets.update_grad_buffer(model)
3129
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
3130
+ optimizer.step()
3131
+
3132
+ delta_end = mets.compute_delta(model)
3133
+ t_eff = mets.compute_t_eff(self.config.LEARNING_RATE)
3134
+ delta_vel = (delta_end - delta_start) / max(prospect_steps, 1)
3135
+ is_cooling = delta_vel < 0
3136
+
3137
+ results[s] = {
3138
+ 'delta_end': delta_end, 'delta_velocity': delta_vel,
3139
+ 't_eff': t_eff, 'is_cooling': is_cooling,
3140
+ }
3141
+ self.logger.info(
3142
+ f" seed={s:4d}: delta={delta_end:.4f} "
3143
+ f"d_vel={delta_vel:.2e} cooling={is_cooling}")
3144
+ del model, optimizer
3145
+
3146
+ # Ordenar: cooling primero, luego delta_velocity mas negativo
3147
+ cooling_seeds = {s: v for s, v in results.items() if v['is_cooling']}
3148
+ pool = cooling_seeds if cooling_seeds else results
3149
+ best_seed = min(pool, key=lambda s: (pool[s]['delta_velocity'], pool[s]['delta_end']))
3150
+ self.logger.info(f"Fase 2: mejor semilla = {best_seed}")
3151
+ return best_seed
3152
+
3153
+
3154
+ # ============================================================================
3155
+ # FASE 4: Refinamiento por Recocido Simulado (Simulated Annealing)
3156
+ # ============================================================================
3157
+
3158
+ class Phase4_AnnealingRefiner:
3159
+ """
3160
+ Refinamiento post-entrenamiento mediante recocido simulado.
3161
+
3162
+ De main.py: despues de que el modelo converge, una fase de annealing
3163
+ con criterio de aceptacion de Metropolis puede empujar los pesos
3164
+ hacia estados de menor energia libre (menor delta o mejor val_loss).
3165
+
3166
+ Aceptacion de Metropolis:
3167
+ si Δloss < 0: siempre acepta (mejora)
3168
+ si Δloss >= 0: acepta con prob exp(-Δloss / T)
3169
+
3170
+ La temperatura T decae exponencialmente: T(t) = T0 * cooling_rate^t
3171
+
3172
+ Al rechazar: restaura el mejor estado conocido.
3173
+ Si se estanca: perturbacion termica (ruido gaussiano en pesos).
3174
+
3175
+ Tiempo: proporcional a refine_epochs (user-controlled).
3176
+ """
3177
+
3178
+ def __init__(self, trainer: 'TopoGPT2Trainer',
3179
+ t0: float = 0.5, cooling_rate: float = 0.95,
3180
+ stagnation_patience: int = 5):
3181
+ self.trainer = trainer
3182
+ self.t0 = t0
3183
+ self.cooling_rate = cooling_rate
3184
+ self.stagnation_patience = stagnation_patience
3185
+ self.logger = trainer.logger
3186
+
3187
+ def refine(self, train_dl, val_dl, refine_epochs: int) -> Dict:
3188
+ """
3189
+ Ejecuta refine_epochs epocas de recocido simulado.
3190
+ Retorna el historial de refinamiento.
3191
+ """
3192
+ cfg = self.trainer.config
3193
+ device = cfg.DEVICE
3194
+ model = self.trainer.model
3195
+ optimizer = self.trainer.optimizer
3196
+ metrics = MechanisticMetrics(cfg)
3197
+
3198
+ T = self.t0
3199
+ best_val_loss = self.trainer.best_val_loss
3200
+ best_state = {k: v.clone() for k, v in model.state_dict().items()}
3201
+ prev_val_loss = best_val_loss
3202
+ stagnation_count = 0
3203
+ refine_history: List[Dict] = []
3204
+
3205
+ self.logger.info("=" * 70)
3206
+ self.logger.info(f"Fase 4: recocido simulado ({refine_epochs} epocas)")
3207
+ self.logger.info(f" T0={self.t0} cooling={self.cooling_rate} "
3208
+ f"best_val={best_val_loss:.4f}")
3209
+ self.logger.info("=" * 70)
3210
+
3211
+ for epoch_idx in range(1, refine_epochs + 1):
3212
+ epoch_abs = self.trainer.completed_epochs + 1
3213
+
3214
+ # Guardar estado pre-epoch para posible rechazo
3215
+ prev_state = {k: v.clone() for k, v in model.state_dict().items()}
3216
+
3217
+ # Entrenamiento de una epoch
3218
+ model.train()
3219
+ ep_loss, n = 0.0, 0
3220
+ optimizer.zero_grad()
3221
+ for step, (bx, by) in enumerate(train_dl):
3222
+ bx, by = bx.to(device), by.to(device)
3223
+ with torch.amp.autocast(
3224
+ device_type=device.split(':')[0],
3225
+ dtype=self.trainer.amp_dtype,
3226
+ enabled=cfg.USE_AMP):
3227
+ logits, rl, _ = model(bx)
3228
+ loss = (
3229
+ F.cross_entropy(logits.view(-1, cfg.VOCAB_SIZE), by.view(-1))
3230
+ + cfg.AE_RECON_WEIGHT * rl
3231
+ ) / cfg.GRAD_ACCUM_STEPS
3232
+ self.trainer.scaler.scale(loss).backward()
3233
+ metrics.update_grad_buffer(model)
3234
+ if (step + 1) % cfg.GRAD_ACCUM_STEPS == 0:
3235
+ self.trainer.scaler.unscale_(optimizer)
3236
+ torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.GRADIENT_CLIP_NORM)
3237
+ self.trainer.scaler.step(optimizer)
3238
+ self.trainer.scaler.update()
3239
+ optimizer.zero_grad()
3240
+ ep_loss += loss.item() * cfg.GRAD_ACCUM_STEPS
3241
+ n += 1
3242
+
3243
+ val_loss, ppl = self.trainer.evaluate(val_dl)
3244
+ delta = metrics.compute_delta(model)
3245
+ t_eff = metrics.compute_t_eff(cfg.LEARNING_RATE)
3246
+
3247
+ # Criterio de Metropolis: acepta o rechaza la epoch
3248
+ delta_loss = val_loss - prev_val_loss
3249
+ if delta_loss < 0:
3250
+ accept = True
3251
+ else:
3252
+ prob = math.exp(-delta_loss / max(T, 1e-12))
3253
+ accept = (torch.rand(1).item() < prob)
3254
+
3255
+ if accept:
3256
+ prev_val_loss = val_loss
3257
+ if val_loss < best_val_loss:
3258
+ best_val_loss = val_loss
3259
+ best_state = {k: v.clone() for k, v in model.state_dict().items()}
3260
+ self.trainer.best_val_loss = best_val_loss
3261
+ stagnation_count = 0
3262
+ else:
3263
+ # Rechazar: volver al estado anterior
3264
+ model.load_state_dict(prev_state)
3265
+ stagnation_count += 1
3266
+ self.logger.info(
3267
+ f" [Annealing] Epoch rechazada (Δloss={delta_loss:.4f}, T={T:.4f})")
3268
+
3269
+ # Estancamiento: perturbacion termica
3270
+ if stagnation_count >= self.stagnation_patience:
3271
+ self.logger.info(" [Annealing] Estancamiento: perturbacion termica")
3272
+ model.load_state_dict(best_state)
3273
+ with torch.no_grad():
3274
+ for p in model.parameters():
3275
+ p.add_(torch.randn_like(p) * T * 1e-3)
3276
+ stagnation_count = 0
3277
+
3278
+ # Enfriar temperatura
3279
+ T *= self.cooling_rate
3280
+
3281
+ # Actualizar estado del trainer
3282
+ self.trainer.completed_epochs += 1
3283
+ self.trainer.history['train_loss'].append(ep_loss / max(n, 1))
3284
+ self.trainer.history['val_loss'].append(val_loss)
3285
+ self.trainer.history['perplexity'].append(ppl)
3286
+ self.trainer.history['recon_loss'].append(0.0)
3287
+
3288
+ rec = {
3289
+ 'epoch': epoch_abs, 'val_loss': val_loss, 'ppl': ppl,
3290
+ 'delta': delta, 't_eff': t_eff, 'T_anneal': T,
3291
+ 'accepted': accept,
3292
+ }
3293
+ refine_history.append(rec)
3294
+
3295
+ self.logger.info(
3296
+ f"[Annealing {epoch_idx:3d}] "
3297
+ f"val={val_loss:.4f} ppl={ppl:.2f} delta={delta:.4f} "
3298
+ f"T={T:.4f} accept={accept}"
3299
+ )
3300
+
3301
+ # Checkpoint
3302
+ self.trainer.ckpt_mgr.save(
3303
+ model, optimizer,
3304
+ self.trainer._current_state(),
3305
+ is_best=(val_loss < best_val_loss + 1e-8),
3306
+ )
3307
+
3308
+ # Restaurar mejor modelo encontrado
3309
+ model.load_state_dict(best_state)
3310
+ self.logger.info(
3311
+ f"Fase 4 completa. Mejor val_loss = {best_val_loss:.4f}")
3312
+ return {'refine_history': refine_history, 'best_val_loss': best_val_loss}
3313
+
3314
+
3315
+ # ============================================================================
3316
+ # PIPELINE V2: Curriculum + Progressive Sequence Length
3317
+ # ============================================================================
3318
+
3319
+ class TopoPhasePipelineV2:
3320
+ """Pipeline with curriculum learning and progressive sequence length.
3321
+
3322
+ Replaces TopoPhasePipeline when --curriculum or --progressive-seq-len is set.
3323
+ Handles:
3324
+ - Text quality filtering before tokenization (via TextFilter)
3325
+ - Curriculum tiers (short/medium/all files)
3326
+ - Progressive MAX_SEQ_LEN across phases: 128->256->512
3327
+ - Tokens cached in memory for fast DataLoader rebuilding per phase
3328
+ """
3329
+
3330
+ def __init__(self, config: 'TopoGPT2Config',
3331
+ train_tokens: np.ndarray, val_tokens: np.ndarray,
3332
+ tokenizer: 'BPETokenizer', logger: logging.Logger,
3333
+ curriculum_tiers: Optional[Dict[int, List[int]]] = None,
3334
+ progressive_seq: bool = True):
3335
+ self.config = config
3336
+ self.train_tokens = train_tokens
3337
+ self.val_tokens = val_tokens
3338
+ self.tokenizer = tokenizer
3339
+ self.logger = logger
3340
+ self.curriculum_tiers = curriculum_tiers
3341
+ self.progressive_seq = progressive_seq
3342
+
3343
+ def _build_dataloader(self, tokens: np.ndarray, seq_len: int,
3344
+ batch_size: int, shuffle: bool,
3345
+ tag: str = '') -> torch.utils.data.DataLoader:
3346
+ n_cpu = max(1, os.cpu_count() // 2)
3347
+ pin = 'cuda' in self.config.DEVICE
3348
+ ds = MappedTokenDataset(tokens, seq_len)
3349
+ dl = torch.utils.data.DataLoader(
3350
+ ds, batch_size=batch_size, shuffle=shuffle,
3351
+ num_workers=n_cpu, pin_memory=pin, drop_last=shuffle)
3352
+ self.logger.info(
3353
+ f"[{tag}] seq_len={seq_len} -> {len(ds):,} sequences, "
3354
+ f"bs={batch_size}, steps={len(dl)}")
3355
+ return dl
3356
+
3357
+ def _build_phases(self) -> Tuple[Tuple[int, int], ...]:
3358
+ if self.progressive_seq:
3359
+ return tuple(zip(
3360
+ self.config.PROGRESSIVE_SEQ_STEPS,
3361
+ self.config.PROGRESSIVE_SEQ_EPOCHS,
3362
+ ))
3363
+ seq = self.config.MAX_SEQ_LEN
3364
+ return ((seq, self.config.EPOCHS),)
3365
+
3366
+ def run(self, run_prospect: bool = False,
3367
+ refine_epochs: int = 0,
3368
+ resume: bool = False,
3369
+ prospect_steps: int = 50,
3370
+ probe_seeds: int = 3,
3371
+ seed_start: int = 1) -> 'CurriculumTrainer':
3372
+ cfg = self.config
3373
+ logger = self.logger
3374
+
3375
+ if run_prospect:
3376
+ logger.info(
3377
+ "Prospecting disabled in V2 pipeline (use standard pipeline for that).")
3378
+
3379
+ set_seed(cfg.RANDOM_SEED, cfg.DEVICE)
3380
+
3381
+ model = TopoGPT2(cfg)
3382
+ trainer = CurriculumTrainer(model, cfg, tokenizer=self.tokenizer)
3383
+ trainer.cache_tokens('train', self.train_tokens)
3384
+ trainer.cache_tokens('val', self.val_tokens)
3385
+
3386
+ if resume:
3387
+ loaded = trainer.resume()
3388
+ if not loaded:
3389
+ logger.info("No checkpoint, starting from scratch.")
3390
+
3391
+ phases = self._build_phases()
3392
+ logger.info(
3393
+ f"ProgressiveSeqLen={self.progressive_seq} phases={len(phases)} "
3394
+ f"curriculum={self.curriculum_tiers is not None}")
3395
+
3396
+ for phase_idx, (seq_len, n_epochs) in enumerate(phases):
3397
+ logger.info(
3398
+ f"[Phase {phase_idx}] seq_len={seq_len}, epochs={n_epochs}")
3399
+ cfg.MAX_SEQ_LEN = seq_len
3400
+
3401
+ if self.curriculum_tiers is not None:
3402
+ short = cfg.CURRICULUM_SHORT_LINES
3403
+ med = cfg.CURRICULUM_MED_LINES
3404
+ tier_map = {
3405
+ 0: (0, short),
3406
+ 1: (short + 1, med),
3407
+ 2: (med + 1, 999_999_999),
3408
+ }
3409
+ tier_file_counts = {
3410
+ t: len(files) for t, files in self.curriculum_tiers.items()
3411
+ }
3412
+ phase_tier = min(phase_idx, 2)
3413
+ logger.info(
3414
+ f"[Phase {phase_idx}] Curriculum tier {phase_tier} "
3415
+ f"({tier_file_counts.get(phase_tier,0):,} files), "
3416
+ f"range: {tier_map[phase_tier]}")
3417
+
3418
+ train_dl = self._build_dataloader(
3419
+ self.train_tokens, seq_len, cfg.BATCH_SIZE, True, 'train')
3420
+ val_dl = self._build_dataloader(
3421
+ self.val_tokens, seq_len, cfg.BATCH_SIZE * 2, False, 'val')
3422
+
3423
+ prev_completed = trainer.completed_epochs
3424
+
3425
+ if self.curriculum_tiers is not None:
3426
+ for epoch_in_phase in range(n_epochs):
3427
+ cfg.EPOCHS = 1
3428
+ trainer.train(train_dl, val_dl)
3429
+ else:
3430
+ cfg.EPOCHS = n_epochs
3431
+ trainer.train(train_dl, val_dl)
3432
+
3433
+ logger.info(
3434
+ f"[Phase {phase_idx}] done. Epochs: {prev_completed} -> "
3435
+ f"{trainer.completed_epochs}")
3436
+
3437
+ if refine_epochs > 0:
3438
+ refiner = Phase4_AnnealingRefiner(trainer._base)
3439
+ refiner.refine(train_dl, val_dl, refine_epochs)
3440
+
3441
+ return trainer
3442
+
3443
+
3444
+ # ============================================================================
3445
+ # PIPELINE COMPLETO (5 FASES)
3446
+ # ============================================================================
3447
+
3448
+ class TopoPhasePipeline:
3449
+ """
3450
+ Orquesta las 5 fases de entrenamiento segun main.py + Book.md.
3451
+
3452
+ Fases:
3453
+ 0 Kernel ratio optimization (GOE-GUE spectral calibration)
3454
+ 1 Batch size prospecting (temperatura del horno de cristalizacion)
3455
+ 2 Seed mining (seleccion de semilla enfriante)
3456
+ 3 Full training (entrenamiento principal con metricas)
3457
+ 4 Annealing refinement (recocido simulado post-entrenamiento)
3458
+
3459
+ Las fases 0-2 son rapidas (prospecting). La fase 3 es el grueso.
3460
+ La fase 4 es opcional (--refine).
3461
+
3462
+ Para no ser prohibitivo:
3463
+ --prospect activa fases 0, 1, 2 antes del entrenamiento
3464
+ --refine-epochs N activa fase 4 con N epocas de annealing
3465
+ Sin flags: solo fase 3 (comportamiento original, identico a antes)
3466
+ """
3467
+
3468
+ def __init__(self, config: 'TopoGPT2Config',
3469
+ train_dataset, val_dataset,
3470
+ tokenizer: 'BPETokenizer',
3471
+ logger: logging.Logger):
3472
+ self.config = config
3473
+ self.train_dataset = train_dataset
3474
+ self.val_dataset = val_dataset
3475
+ self.tokenizer = tokenizer
3476
+ self.logger = logger
3477
+
3478
+ def _make_dataloaders(self, batch_size: int = 0):
3479
+ bs = batch_size or self.config.BATCH_SIZE
3480
+ n_cpu = min(4, os.cpu_count() or 1)
3481
+ pin = 'cuda' in self.config.DEVICE
3482
+ train_dl = torch.utils.data.DataLoader(
3483
+ self.train_dataset, batch_size=bs, shuffle=True,
3484
+ num_workers=n_cpu, pin_memory=pin, drop_last=True)
3485
+ val_dl = torch.utils.data.DataLoader(
3486
+ self.val_dataset, batch_size=bs * 2, shuffle=False,
3487
+ num_workers=max(1, n_cpu // 2), pin_memory=pin, drop_last=True)
3488
+ return train_dl, val_dl
3489
+
3490
+ def run(self, run_prospect: bool = False,
3491
+ refine_epochs: int = 0,
3492
+ resume: bool = False,
3493
+ prospect_steps: int = 50,
3494
+ probe_seeds: int = 3,
3495
+ seed_start: int = 1) -> 'TopoGPT2Trainer':
3496
+ """
3497
+ Ejecuta el pipeline completo.
3498
+ Retorna el trainer con el modelo entrenado.
3499
+ """
3500
+ cfg = self.config
3501
+
3502
+ # ---- Fase 0: ratio de kernels espectrales ----
3503
+ if run_prospect:
3504
+ dl0, _ = self._make_dataloaders()
3505
+ p0 = Phase0_KernelOptimizer(cfg, self.logger)
3506
+ best_ratio = p0.optimize(dl0)
3507
+ cfg.SPECTRAL_KERNEL_INIT_SCALE = best_ratio
3508
+ self.logger.info(f"Fase 0 -> SPECTRAL_KERNEL_INIT_SCALE={best_ratio}")
3509
+
3510
+ # ---- Fase 1: batch size ----
3511
+ if run_prospect:
3512
+ candidates = [
3513
+ max(2, cfg.BATCH_SIZE // 2),
3514
+ cfg.BATCH_SIZE,
3515
+ min(cfg.BATCH_SIZE * 2, 32),
3516
+ ]
3517
+ candidates = sorted(set(candidates))
3518
+ p1 = Phase1_BatchProspector(cfg, self.logger)
3519
+ best_bs = p1.prospect(candidates, self.train_dataset, prospect_steps)
3520
+ cfg.BATCH_SIZE = best_bs
3521
+
3522
+ # ---- Fase 2: seed mining ----
3523
+ if run_prospect:
3524
+ p2 = Phase2_SeedMiner(cfg, self.logger)
3525
+ best_seed = p2.mine(seed_start, probe_seeds,
3526
+ self.train_dataset, prospect_steps)
3527
+ cfg.RANDOM_SEED = best_seed
3528
+ set_seed(best_seed, cfg.DEVICE)
3529
+ self.logger.info(f"Fases 0-2 completas. "
3530
+ f"ratio={cfg.SPECTRAL_KERNEL_INIT_SCALE} "
3531
+ f"bs={cfg.BATCH_SIZE} seed={best_seed}")
3532
+ else:
3533
+ set_seed(cfg.RANDOM_SEED, cfg.DEVICE)
3534
+
3535
+ # ---- Fase 3: entrenamiento principal ----
3536
+ train_dl, val_dl = self._make_dataloaders()
3537
+
3538
+ # Ajustar arquitectura GQA si el checkpoint usa una config distinta
3539
+ if resume:
3540
+ tmp_mgr = CheckpointManager(cfg, self.logger)
3541
+ tmp_mgr.patch_config_for_resume(cfg)
3542
+
3543
+ model = TopoGPT2(cfg)
3544
+ trainer = TopoGPT2Trainer(model, cfg, tokenizer=self.tokenizer)
3545
+
3546
+ if resume:
3547
+ loaded = trainer.resume()
3548
+ if not loaded:
3549
+ self.logger.info("No habia checkpoint, iniciando desde cero.")
3550
+ elif trainer.ckpt_mgr.has_checkpoint():
3551
+ self.logger.info(
3552
+ f"AVISO: existe checkpoint en '{cfg.CHECKPOINT_DIR}/latest/'. "
3553
+ "Usa --resume para continuarlo.")
3554
+
3555
+ # Entrenamiento con metricas mecanisticas integradas
3556
+ trainer.train(train_dl, val_dl)
3557
+
3558
+ # ---- Fase 4: recocido simulado (opcional) ----
3559
+ if refine_epochs > 0:
3560
+ refiner = Phase4_AnnealingRefiner(trainer)
3561
+ refiner.refine(train_dl, val_dl, refine_epochs)
3562
+
3563
+ return trainer
3564
+
3565
+
3566
+ # ============================================================================
3567
+ # FUNCIÓN PRINCIPAL
3568
+ # ============================================================================
3569
+
3570
+ def main():
3571
+ parser = argparse.ArgumentParser(description='TopoGPT2 - Topological Quaternion LM')
3572
+ parser.add_argument('--scale', default='small',
3573
+ choices=['micro', 'small', 'medium', 'gpt2'],
3574
+ help='Escala del modelo')
3575
+ parser.add_argument('--corpus', default='directory',
3576
+ choices=['directory', 'file'],
3577
+ help='Corpus mode: directory walks tree, file reads single file')
3578
+ parser.add_argument('--corpus-root', default='/home/grisun0/src_note',
3579
+ help='Root directory for --corpus=directory')
3580
+ parser.add_argument('--corpus-file', default='', help='Text file path if --corpus=file')
3581
+ parser.add_argument('--corpus-min-chars', type=int, default=30,
3582
+ help='Minimum chars per file for --corpus=directory')
3583
+ parser.add_argument('--max-train-tokens', type=int, default=0,
3584
+ help='Max tokens to tokenize (0 = use config default: 500M)')
3585
+ parser.add_argument('--epochs', type=int, default=5)
3586
+ parser.add_argument('--batch-size', type=int, default=4)
3587
+ parser.add_argument('--seq-len', type=int, default=0,
3588
+ help='Longitud de secuencia (0 = usar preset de escala)')
3589
+ parser.add_argument('--lr', type=float, default=3e-4)
3590
+ parser.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu')
3591
+ parser.add_argument('--no-grad-ckpt', action='store_true',
3592
+ help='Desactivar gradient checkpointing (mas rapido, mas VRAM)')
3593
+ parser.add_argument('--generate', action='store_true', help='Generar texto tras entrenar')
3594
+ parser.add_argument('--prompt', default='Once upon a time', help='Prompt para generacion')
3595
+ parser.add_argument('--resume', action='store_true', help='Reanudar desde checkpoint')
3596
+ # Fases del pipeline (main.py)
3597
+ parser.add_argument('--prospect', action='store_true',
3598
+ help='Activar fases 0-2: optimizar kernel ratio, batch size y seed '
3599
+ 'antes del entrenamiento principal (rapido, ~5-10 min)')
3600
+ parser.add_argument('--prospect-steps', type=int, default=50,
3601
+ help='Pasos por candidato en las fases de prospecting (default: 50)')
3602
+ parser.add_argument('--probe-seeds', type=int, default=3,
3603
+ help='Numero de semillas a evaluar en fase 2 (default: 3)')
3604
+ parser.add_argument('--seed-start', type=int, default=1,
3605
+ help='Primera semilla a probar en fase 2 (default: 1)')
3606
+ parser.add_argument('--refine-epochs', type=int, default=0,
3607
+ help='Epocas de recocido simulado (fase 4) tras el entrenamiento '
3608
+ 'principal (default: 0 = desactivado)')
3609
+ parser.add_argument('--progressive-seq-len', action='store_true',
3610
+ help='Enable progressive sequence length warmup (128->256->512)')
3611
+ parser.add_argument('--curriculum', action='store_true',
3612
+ help='Enable curriculum learning (short->medium->all files)')
3613
+ parser.add_argument('--spec-decode', action='store_true',
3614
+ help='Enable speculative decoding for faster inference')
3615
+ parser.add_argument('--generate-synthetic', action='store_true',
3616
+ help='Generate synthetic dataset instead of training')
3617
+ parser.add_argument('--synth-provider', type=str, default='groq',
3618
+ choices=['groq', 'openrouter', 'ollama'],
3619
+ help='LLM provider for synthetic data generation')
3620
+ parser.add_argument('--synth-model', type=str, default='granite4.1:3b',
3621
+ help='Model for synthetic data generation')
3622
+ parser.add_argument('--synth-output', type=str, default='data/synthetic_dataset.jsonl',
3623
+ help='Output path for synthetic dataset JSONL')
3624
+ parser.add_argument('--synth-manifest', type=str, default='data/synthetic_manifest.json',
3625
+ help='Manifest path for synthetic dataset (resumable)')
3626
+ parser.add_argument('--synth-workers', type=int, default=4,
3627
+ help='Parallel workers for synthetic generation')
3628
+ parser.add_argument('--synth-api-key', type=str, default='',
3629
+ help='API key for LLM (or set GROQ_API_KEY env var)')
3630
+ parser.add_argument('--synth-max-files', type=int, default=0,
3631
+ help='Max files to generate from (0 = all)')
3632
+ args = parser.parse_args()
3633
+
3634
+ config = TopoGPT2Config(
3635
+ SCALE=args.scale,
3636
+ CORPUS=args.corpus,
3637
+ CORPUS_ROOT=args.corpus_root,
3638
+ CORPUS_FILE=args.corpus_file,
3639
+ CORPUS_MIN_FILE_CHARS=args.corpus_min_chars,
3640
+ EPOCHS=args.epochs,
3641
+ BATCH_SIZE=args.batch_size,
3642
+ LEARNING_RATE=args.lr,
3643
+ DEVICE=args.device,
3644
+ GRADIENT_CHECKPOINTING=not args.no_grad_ckpt,
3645
+ )
3646
+ if args.seq_len > 0:
3647
+ config.MAX_SEQ_LEN = args.seq_len
3648
+ if args.max_train_tokens > 0:
3649
+ config.MAX_TRAIN_TOKENS = args.max_train_tokens
3650
+
3651
+ logger = setup_logger('TopoGPT2.main', config.LOG_LEVEL)
3652
+
3653
+ # Tokenizador
3654
+ logger.info("Cargando tokenizador GPT-2 (tiktoken)...")
3655
+ tokenizer = BPETokenizer('gpt2')
3656
+ config.VOCAB_SIZE = tokenizer.vocab_size
3657
+
3658
+ # Corpus
3659
+ if config.CORPUS == 'file':
3660
+ if not config.CORPUS_FILE or not os.path.exists(config.CORPUS_FILE):
3661
+ logger.error(f"File not found: {config.CORPUS_FILE}")
3662
+ sys.exit(1)
3663
+ with open(config.CORPUS_FILE, 'r', encoding='utf-8', errors='replace') as f:
3664
+ full_text = f.read()
3665
+ train_split = int(len(full_text) * 0.95)
3666
+ train_tokens = _tokenize_text_to_memmap(
3667
+ full_text[:train_split], tokenizer,
3668
+ os.path.join(config.DATA_DIR, 'tokens_train.bin'),
3669
+ config.MAX_TRAIN_TOKENS)
3670
+ val_tokens = _tokenize_text_to_memmap(
3671
+ full_text[train_split:], tokenizer,
3672
+ os.path.join(config.DATA_DIR, 'tokens_val.bin'),
3673
+ config.MAX_TRAIN_TOKENS // 10)
3674
+ elif config.CORPUS == 'directory':
3675
+ manifest = FileManifest(config.CORPUS_ROOT, config.DATA_DIR, logger)
3676
+ all_paths = manifest.scan()
3677
+ if not all_paths:
3678
+ logger.error(f"No text files found in {config.CORPUS_ROOT}")
3679
+ sys.exit(1)
3680
+ logger.info(f"Total files found: {len(all_paths):,}")
3681
+
3682
+ text_filter = TextFilter(config, logger)
3683
+ filtered_paths = []
3684
+ for p in all_paths:
3685
+ if text_filter.filter_file(p, tokenizer) is not None:
3686
+ filtered_paths.append(p)
3687
+ filter_stats = text_filter.report()
3688
+ logger.info(f"Files after quality filter: {len(filtered_paths):,}")
3689
+
3690
+ if not filtered_paths:
3691
+ logger.error("All files filtered out. Check FILTER_* settings.")
3692
+ sys.exit(1)
3693
+
3694
+ rng = np.random.RandomState(config.RANDOM_SEED)
3695
+ rng.shuffle(filtered_paths)
3696
+ split = int(len(filtered_paths) * 0.95)
3697
+ train_paths = filtered_paths[:split]
3698
+ val_paths = filtered_paths[split:]
3699
+ logger.info(
3700
+ f"Train files: {len(train_paths):,} Val files: {len(val_paths):,}")
3701
+
3702
+ memtok = MemmapTokenizer(config.DATA_DIR, logger)
3703
+ train_max = config.MAX_TRAIN_TOKENS
3704
+ val_max = config.MAX_TRAIN_TOKENS // 10 if train_max > 0 else 10_000_000
3705
+ train_tokens = memtok.tokenize(
3706
+ train_paths, tokenizer, 'train',
3707
+ train_max, config.CORPUS_MIN_FILE_CHARS)
3708
+ val_tokens = memtok.tokenize(
3709
+ val_paths, tokenizer, 'val',
3710
+ val_max, config.CORPUS_MIN_FILE_CHARS)
3711
+ logger.info(
3712
+ f"Train tokens: {len(train_tokens):,} Val tokens: {len(val_tokens):,}")
3713
+
3714
+ curriculum_tiers = None
3715
+ if args.curriculum and config.CURRICULUM_ENABLED:
3716
+ logger.info("Building curriculum tiers...")
3717
+ curriculum_tiers = build_file_tiers(
3718
+ train_paths,
3719
+ config.CURRICULUM_SHORT_LINES,
3720
+ config.CURRICULUM_MED_LINES,
3721
+ )
3722
+ logger.info(
3723
+ f"Tier 0 (short): {len(curriculum_tiers.get(0,[])):,} files, "
3724
+ f"Tier 1 (med): {len(curriculum_tiers.get(1,[])):,} files, "
3725
+ f"Tier 2 (all): {len(curriculum_tiers.get(2,[])):,} files")
3726
+ else:
3727
+ logger.error(f"Unknown corpus mode: {config.CORPUS}")
3728
+ sys.exit(1)
3729
+
3730
+ if args.generate_synthetic:
3731
+ logger.info("Entering synthetic dataset generation mode...")
3732
+ synth_paths = train_paths + val_paths
3733
+ logger.info(f"Total files for synthetic generation: {len(synth_paths):,}")
3734
+
3735
+ import synthetic_dataset as synth_mod
3736
+
3737
+ api_key = args.synth_api_key or os.environ.get(
3738
+ "GROQ_API_KEY", os.environ.get("OPENROUTER_API_KEY", ""))
3739
+ if not api_key:
3740
+ logger.error("No API key. Set GROQ_API_KEY or OPENROUTER_API_KEY env var.")
3741
+ sys.exit(1)
3742
+
3743
+ backend = synth_mod.build_backend(
3744
+ args.synth_provider, args.synth_model)
3745
+ logger.info(f"Synthetic backend: {backend.name()}")
3746
+
3747
+ gen = synth_mod.SyntheticDatasetGenerator(
3748
+ backend=backend,
3749
+ output_path=args.synth_output,
3750
+ manifest_path=args.synth_manifest,
3751
+ logger=logger,
3752
+ max_workers=args.synth_workers,
3753
+ )
3754
+ max_files = args.synth_max_files if args.synth_max_files > 0 else len(synth_paths)
3755
+ logger.info(f"Processing up to {max_files:,} files with {args.synth_workers} workers...")
3756
+
3757
+ for i in range(0, min(len(synth_paths), max_files), 100):
3758
+ batch = synth_paths[i:i+100]
3759
+ results = gen.process_batch(batch)
3760
+ logger.info(
3761
+ f"Batch {i//100+1}: processed={results['processed']} "
3762
+ f"skipped={results['skipped']} failed={results['failed']}")
3763
+
3764
+ stats = gen.finish()
3765
+ logger.info(
3766
+ f"Synthetic generation complete: {stats['total_samples']:,} samples "
3767
+ f"written to {stats['output_path']}")
3768
+ sys.exit(0)
3769
+
3770
+ train_ds = MappedTokenDataset(train_tokens, config.MAX_SEQ_LEN)
3771
+ val_ds = MappedTokenDataset(val_tokens, config.MAX_SEQ_LEN)
3772
+ logger.info(
3773
+ f"Train sequences: {len(train_ds):,} Val sequences: {len(val_ds):,}")
3774
+
3775
+ # Pipeline de 5 fases
3776
+ pipeline = TopoPhasePipeline(config, train_ds, val_ds, tokenizer, logger)
3777
+
3778
+ use_progressive = args.progressive_seq_len and config.PROGRESSIVE_SEQ
3779
+ use_curriculum = args.curriculum and config.CURRICULUM_ENABLED
3780
+
3781
+ if use_progressive or use_curriculum:
3782
+ pipeline = TopoPhasePipelineV2(
3783
+ config, train_tokens, val_tokens, tokenizer, logger,
3784
+ curriculum_tiers=curriculum_tiers if use_curriculum else None,
3785
+ progressive_seq=use_progressive,
3786
+ )
3787
+ trainer = pipeline.run(
3788
+ run_prospect=args.prospect,
3789
+ refine_epochs=args.refine_epochs,
3790
+ resume=args.resume,
3791
+ prospect_steps=args.prospect_steps,
3792
+ probe_seeds=args.probe_seeds,
3793
+ seed_start=args.seed_start,
3794
+ )
3795
+ else:
3796
+ trainer = pipeline.run(
3797
+ run_prospect=args.prospect,
3798
+ refine_epochs=args.refine_epochs,
3799
+ resume=args.resume,
3800
+ prospect_steps=args.prospect_steps,
3801
+ probe_seeds=args.probe_seeds,
3802
+ seed_start=args.seed_start,
3803
+ )
3804
+
3805
+ # Historial final en results/ para inspeccion rapida
3806
+ results_dir = 'results_topogpt2'
3807
+ os.makedirs(results_dir, exist_ok=True)
3808
+ hist_path = os.path.join(results_dir, 'history.json')
3809
+ with open(hist_path, 'w', encoding='utf-8') as f:
3810
+ json.dump(trainer._current_state(), f, indent=2)
3811
+ logger.info(f"Estado completo guardado: {hist_path}")
3812
+
3813
+ # Generacion de muestra
3814
+ if args.generate:
3815
+ logger.info(f"Generando desde prompt: '{args.prompt}'")
3816
+ trainer.model.eval()
3817
+ prompt_tokens = tokenizer.encode(args.prompt)
3818
+ ids = torch.tensor([prompt_tokens], dtype=torch.long, device=config.DEVICE)
3819
+ gen_ids = trainer.model.generate(ids, max_new_tokens=200, temperature=0.8, top_k=50)
3820
+ logger.info(f"Generado:\n{tokenizer.decode(gen_ids[0].tolist())}")
3821
+
3822
+
3823
+ if __name__ == '__main__':
3824
+ main()