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/train.py ADDED
@@ -0,0 +1,1939 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ TopoGPT3: Grassmannian / Berry-Holonomy extension of TopoGPT2
4
+
5
+ Author: Gris Iscomeback
6
+ License: GPL v3
7
+
8
+ Lo nuevo respecto a model.py
9
+ ---------------------------------
10
+ 1. Espacio base: Grassmanniana Gr(r, N) sobre el tensor de kernels espectrales
11
+ K(theta) en C^{N_f x N_c}. El estado geometrico vive en U_r(theta) en St(r,N)/U(r),
12
+ con r elegido dinamicamente por el "elbow" del espectro singular de K.
13
+ 2. Fisher gap funcional: Delta_F(theta) = lambda_r(Sigma_F) - lambda_{r+1}(Sigma_F)
14
+ estimado por covarianza empirica de gradientes (mini-batch) o por scores.
15
+ 3. Conexion de Berry discreta: A_n = i * U_n^dagger (U_{n+1} - U_n)
16
+ y holonomia acumulada U_Gamma = P prod_n exp(-i A_n) en U(r).
17
+ 4. Distancia de conjugacion en SU(2) (cuaternionico, r=1 efectivo):
18
+ d_conj(U1, U2) = min_{g in SU(2)} || U1 - g U2 g^{-1} ||_F
19
+ 5. Winding W como proxy heuristico barato (rol secundario).
20
+ 6. Curriculum por dataset, de mas simple a mas dificil:
21
+ Tier 1: CodeAlpaca (instrucciones cortas)
22
+ Tier 2: Code-Feedback-Filtered (chat / explicacion paso a paso)
23
+ Tier 3: Magicoder-Evol-Instruct-110K (problemas complejos)
24
+ Tier 4: Tiny-The-Stack (codigo real multilenguaje)
25
+ Cada tier mantiene splits train / val / holdout *disjuntos*.
26
+ El conjunto HOLDOUT nunca se ve durante entrenamiento; se usa
27
+ solo para medir generalizacion verdadera al final de cada tier
28
+ y al final del pipeline.
29
+
30
+ Diseno
31
+ ------
32
+ - Reutiliza la arquitectura TopoGPT2 (tokenizador BPE, modelo cuaternionico
33
+ con torus + spectral autoencoder) cargandola dinamicamente desde model.py.
34
+ - Anade encima la maquinaria geometrica nueva en GrassmannianTracker.
35
+ - Entrenamiento orquestado por TopoGPT3Trainer que itera 4 tiers en orden,
36
+ con seq_len progresivo y evaluacion en holdout disjunto.
37
+
38
+ CLI
39
+ ---
40
+ python train.py --prepare-data descarga + tokeniza los 4 tiers
41
+ python train.py --train entrena el curriculum completo
42
+ python train.py --train --start-tier 2 arranca desde un tier concreto
43
+ python train.py --eval-holdout evalua sobre holdouts disjuntos
44
+ python train.py --resume reanuda desde el ultimo checkpoint
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import argparse
50
+ import json
51
+ import logging
52
+ import math
53
+ import os
54
+ import sys
55
+ import time
56
+ from collections import deque
57
+ from dataclasses import asdict, dataclass, field
58
+ from datetime import datetime
59
+ from pathlib import Path
60
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
61
+
62
+ import numpy as np
63
+ import torch
64
+ import torch.nn as nn
65
+ import torch.nn.functional as F
66
+
67
+ from .model import (
68
+ BPETokenizer,
69
+ QuaternionSpectralLayer,
70
+ SpectralAutoencoder,
71
+ TopoGPT2,
72
+ TopoGPT2Config,
73
+ set_seed,
74
+ setup_logger,
75
+ )
76
+ from .exploitgym_loader import ExploitGymDataLoader, ExploitGymLoaderConfig
77
+ from .exploitgym_config import TopoExploitConfig
78
+ from .merged_config import TopoMergedConfig
79
+ from .ewc import EWCRegularizer, ReplayBuffer
80
+
81
+
82
+ # ============================================================================
83
+ # CONFIGURACION
84
+ # ============================================================================
85
+
86
+ @dataclass
87
+ class TopoGPT3Config:
88
+ """Configuracion del pipeline TopoGPT3 (Grassmanniana + curriculum)."""
89
+
90
+ # --- Modelo: hereda TopoGPT2 ---
91
+ SCALE: str = "small" # micro | small | medium | gpt2
92
+ DEVICE: str = "cuda" if torch.cuda.is_available() else "cpu"
93
+ RANDOM_SEED: int = 42
94
+ USE_AMP: bool = True
95
+ GRADIENT_CHECKPOINTING: bool = True
96
+
97
+ # --- Hyperparams de entrenamiento ---
98
+ BATCH_SIZE: int = 4
99
+ GRAD_ACCUM_STEPS: int = 8
100
+ LEARNING_RATE: float = 3e-4
101
+ WEIGHT_DECAY: float = 0.1
102
+ GRADIENT_CLIP_NORM: float = 1.0
103
+ WARMUP_RATIO: float = 0.05
104
+
105
+ # --- Curriculum (tiers x epocas) ---
106
+ TIER_EPOCHS: Tuple[int, int, int, int] = (2, 2, 3, 3)
107
+ TIER_SEQ_LEN: Tuple[int, int, int, int] = (128, 192, 256, 256)
108
+ TIER_NAMES: Tuple[str, str, str, str] = (
109
+ "codealpaca",
110
+ "code_feedback",
111
+ "magicoder_evol",
112
+ "tiny_the_stack",
113
+ )
114
+
115
+ # --- Datasets (HuggingFace IDs) ---
116
+ HF_CODEALPACA: str = "sahil2801/CodeAlpaca-20k"
117
+ HF_CODE_FEEDBACK: str = "m-a-p/CodeFeedback-Filtered-Instruction"
118
+ HF_MAGICODER: str = "ise-uiuc/Magicoder-Evol-Instruct-110K"
119
+ HF_TINY_STACK: str = "bigcode/the-stack-smol"
120
+ # Cadena de fallbacks publicos (sin autenticacion HF) si bigcode/the-stack-smol
121
+ # esta gated. Se prueban en orden hasta que uno funcione.
122
+ HF_TINY_STACK_FALLBACKS: Tuple[str, ...] = (
123
+ "codeparrot/codeparrot-clean-valid",
124
+ "flytech/python-codes-25k",
125
+ "iamtarun/python_code_instructions_18k_alpaca",
126
+ )
127
+
128
+ # --- Splits ---
129
+ VAL_FRACTION: float = 0.05 # validacion durante entrenamiento
130
+ HOLDOUT_FRACTION: float = 0.05 # NUNCA visto, medicion de generalizacion
131
+ SPLIT_SEED: int = 1337 # determinismo del split por dataset
132
+
133
+ # --- Tokens por tier (limites de seguridad) ---
134
+ MAX_TOKENS_PER_TIER: Tuple[int, int, int, int] = (
135
+ 20_000_000, # CodeAlpaca chico
136
+ 80_000_000, # CodeFeedback medio
137
+ 100_000_000, # Magicoder
138
+ 500_000_000, # Tiny-The-Stack ~1GB
139
+ )
140
+
141
+ # --- Grassmanniana ---
142
+ GRASS_TRACK_EVERY: int = 200 # steps entre snapshots de U_r
143
+ GRASS_MAX_RANK: int = 16 # tope para r (elbow se calcula <=)
144
+ GRASS_ELBOW_RATIO: float = 0.05 # corte relativo del elbow
145
+ GRASS_FISHER_BATCHES: int = 40 # batches para estimar Sigma_F (>= r_max+2)
146
+ GRASS_FISHER_GRADS: int = 64 # gradientes muestrales para Sigma_F
147
+
148
+ # --- Optimizacion: Gauss para multiplicacion compleja (3 mults en vez de 4) ---
149
+ USE_GAUSS_COMPLEX_MULT: bool = True
150
+
151
+ # --- Mediciones de eficiencia ---
152
+ EFFICIENCY_THROUGHPUT_BATCHES: int = 20 # batches para cronometrar tok/s
153
+ # Factor heuristico para activaciones por token (multiplo de D_MODEL guardado en memoria);
154
+ # 12 corresponde a Q,K,V,attn-out + 2 FFN + residuales por capa.
155
+ GRADIENT_CHECKPOINTING_FACTOR: int = 12
156
+
157
+ # --- Progressive Window Sizes (Sliding Window Attention) ---
158
+ PROGRESSIVE_WINDOW: Tuple[int, int, int, int] = (64, 96, 128, 128)
159
+
160
+ # --- Paths ---
161
+ DATA_CACHE: str = "data_topogpt3"
162
+ CHECKPOINT_DIR: str = "checkpoints_topogpt3"
163
+ LOG_FILE: str = "topogpt3_history.jsonl"
164
+
165
+ # --- Logging / checkpoint ---
166
+ LOG_INTERVAL_STEPS: int = 50
167
+ CHECKPOINT_EVERY_MIN: int = 15
168
+ MAX_CHECKPOINTS: int = 5
169
+
170
+ # --- Limites de seguridad ---
171
+ MAX_TRAIN_BATCHES_PER_EPOCH: int = 0 # 0 = sin limite
172
+ MAX_EVAL_BATCHES: int = 200 # cap a evaluacion para no saturar
173
+
174
+ # --- Anti-forgetting: EWC + Replay ---
175
+ USE_EWC: bool = True # Elastic Weight Consolidation
176
+ EWC_LAMBDA: float = 5000.0 # penalty strength
177
+ EWC_FISHER_BATCHES: int = 50 # batches para estimar Fisher
178
+ USE_REPLAY: bool = True # Experience Replay entre tiers
179
+ REPLAY_RATIO: float = 0.25 # fraccion de replay en batch mixto
180
+ REPLAY_MAX_TIERS: int = 3 # tiers recordados para replay
181
+
182
+ def build_topogpt2_config(self, max_seq_len: int,
183
+ attn_window: Optional[int] = None) -> TopoGPT2Config:
184
+ cfg = TopoGPT2Config(
185
+ DEVICE=self.DEVICE,
186
+ RANDOM_SEED=self.RANDOM_SEED,
187
+ USE_AMP=self.USE_AMP,
188
+ SCALE=self.SCALE,
189
+ BATCH_SIZE=self.BATCH_SIZE,
190
+ GRAD_ACCUM_STEPS=self.GRAD_ACCUM_STEPS,
191
+ LEARNING_RATE=self.LEARNING_RATE,
192
+ WEIGHT_DECAY=self.WEIGHT_DECAY,
193
+ GRADIENT_CLIP_NORM=self.GRADIENT_CLIP_NORM,
194
+ WARMUP_RATIO=self.WARMUP_RATIO,
195
+ GRADIENT_CHECKPOINTING=self.GRADIENT_CHECKPOINTING,
196
+ CHECKPOINT_DIR=self.CHECKPOINT_DIR,
197
+ DATA_DIR=self.DATA_CACHE,
198
+ )
199
+ cfg.MAX_SEQ_LEN = max_seq_len
200
+ if attn_window is not None and attn_window > 0:
201
+ cfg.ATTN_WINDOW = attn_window
202
+ return cfg
203
+
204
+
205
+ # ============================================================================
206
+ # GRASSMANNIAN TRACKER
207
+ # ============================================================================
208
+
209
+ class GrassmannianTracker:
210
+ """
211
+ Observables geometricos sobre la trayectoria SGD.
212
+
213
+ En cada snapshot:
214
+ - Apila los kernels espectrales (kr_*, ki_*) del modelo en
215
+ K(theta) en C^{N_f x N_c}.
216
+ - SVD truncada -> U_r(theta) en St(r,N).
217
+ - Rango r dinamico por elbow de los valores singulares.
218
+ - Gap funcional Delta_F estimado por covarianza de gradientes
219
+ muestrales (proxy de la matriz de Fisher).
220
+ - Conexion de Berry discreta entre snapshots consecutivos:
221
+ A_n = i * U_n^dagger (U_{n+1} - U_n)
222
+ Holonomia acumulada U_Gamma = P prod_n exp(-i A_n) en U(r).
223
+ - Distancia de conjugacion en SU(2) (r=1 efectivo cuaternionico).
224
+ - Winding W como proxy barato.
225
+
226
+ Todos los calculos viven en CPU/float32 para no contaminar AMP.
227
+ """
228
+
229
+ def __init__(self, config: TopoGPT3Config, logger: logging.Logger):
230
+ self.config = config
231
+ self.logger = logger
232
+ self.U_prev: Optional[torch.Tensor] = None
233
+ self.U_holo: Optional[torch.Tensor] = None # holonomia acumulada
234
+ self.U_init: Optional[torch.Tensor] = None # subespacio inicial
235
+ self.winding_accum: float = 0.0
236
+ self.history: List[Dict[str, Any]] = []
237
+
238
+ # ------------------------------------------------------------------
239
+ # Construccion del tensor de kernels K(theta)
240
+ # ------------------------------------------------------------------
241
+
242
+ @staticmethod
243
+ def _stack_spectral_kernels(model: nn.Module) -> torch.Tensor:
244
+ """
245
+ Devuelve K(theta) en C^{N_f x N_c}:
246
+ - filas = frecuencias planas (todos los modos espaciales de todos los kernels)
247
+ - columnas = canales (in_q * out_q por componente cuaternionico, sumados)
248
+ """
249
+ rows: List[torch.Tensor] = []
250
+ with torch.no_grad():
251
+ for name, mod in model.named_modules():
252
+ if isinstance(mod, QuaternionSpectralLayer):
253
+ in_q = mod.in_q
254
+ out_q = mod.out_q
255
+ for c in ("w", "x", "y", "z"):
256
+ kr = getattr(mod, f"kr_{c}").detach().cpu().float()
257
+ ki = getattr(mod, f"ki_{c}").detach().cpu().float()
258
+ K = torch.complex(kr, ki) # [in_q, out_q, H, W']
259
+ K = K.permute(2, 3, 0, 1).contiguous()
260
+ H, Wp, Iq, Oq = K.shape
261
+ K = K.reshape(H * Wp, Iq * Oq)
262
+ rows.append(K)
263
+ if not rows:
264
+ return torch.zeros(1, 1, dtype=torch.complex64)
265
+ max_cols = max(r.shape[1] for r in rows)
266
+ padded = []
267
+ for r in rows:
268
+ if r.shape[1] < max_cols:
269
+ pad = torch.zeros(
270
+ r.shape[0], max_cols - r.shape[1], dtype=r.dtype
271
+ )
272
+ r = torch.cat([r, pad], dim=1)
273
+ padded.append(r)
274
+ return torch.cat(padded, dim=0)
275
+
276
+ # ------------------------------------------------------------------
277
+ # SVD truncada + eleccion de rango por elbow
278
+ # ------------------------------------------------------------------
279
+
280
+ def _elbow_rank(self, sigmas: torch.Tensor) -> int:
281
+ """Punto donde el valor singular cae por debajo de elbow_ratio * sigma_max."""
282
+ s = sigmas.float()
283
+ if s.numel() == 0:
284
+ return 1
285
+ cutoff = self.config.GRASS_ELBOW_RATIO * float(s[0])
286
+ r = int((s > cutoff).sum().item())
287
+ return max(1, min(r, self.config.GRASS_MAX_RANK))
288
+
289
+ def _dominant_subspace(self, K: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor, int]:
290
+ """
291
+ SVD compacta y truncada.
292
+ Devuelve (U_r, sigmas, r) con U_r en C^{N_f x r} ortonormal.
293
+ """
294
+ try:
295
+ U, S, _Vh = torch.linalg.svd(K, full_matrices=False)
296
+ except RuntimeError as exc:
297
+ self.logger.warning(f"SVD fallida en K({tuple(K.shape)}): {exc}")
298
+ return torch.eye(K.shape[0], 1, dtype=K.dtype), torch.tensor([1.0]), 1
299
+ r = self._elbow_rank(S.abs())
300
+ return U[:, :r].contiguous(), S[:r].abs(), r
301
+
302
+ # ------------------------------------------------------------------
303
+ # Fisher gap por covarianza de gradientes (proxy de Sigma_F)
304
+ # ------------------------------------------------------------------
305
+
306
+ @staticmethod
307
+ def _flatten_grads(model: nn.Module, max_per_tensor: int = 256) -> Optional[torch.Tensor]:
308
+ """Concatena un sub-sample de gradientes para mantener costo acotado."""
309
+ parts: List[torch.Tensor] = []
310
+ for p in model.parameters():
311
+ if p.grad is None or not p.is_floating_point():
312
+ continue
313
+ g = p.grad.detach()
314
+ if torch.isnan(g).any() or torch.isinf(g).any():
315
+ continue
316
+ flat = g.flatten().float().cpu()
317
+ if flat.numel() > max_per_tensor:
318
+ step = max(1, flat.numel() // max_per_tensor)
319
+ flat = flat[::step][:max_per_tensor]
320
+ parts.append(flat)
321
+ if not parts:
322
+ return None
323
+ return torch.cat(parts)
324
+
325
+ def estimate_fisher_gap(
326
+ self,
327
+ model: nn.Module,
328
+ dataloader: Iterable,
329
+ vocab_size: int,
330
+ r_target: int,
331
+ ) -> Tuple[float, torch.Tensor, int]:
332
+ """
333
+ Sigma_F ~= (1/M) sum_m g_m g_m^T (covarianza muestral de gradientes).
334
+ Delta_F = lambda_{r_eff} - lambda_{r_eff+1}, donde r_eff = min(r_target, M-2)
335
+ para no salir del rango efectivo del estimador con M gradientes.
336
+ Devuelve (gap, eigs_desc, r_eff).
337
+ """
338
+ device = next(model.parameters()).device
339
+ was_training = model.training
340
+ model.train()
341
+ samples: List[torch.Tensor] = []
342
+ n_batches = 0
343
+ for batch in dataloader:
344
+ if n_batches >= self.config.GRASS_FISHER_BATCHES:
345
+ break
346
+ if len(samples) >= self.config.GRASS_FISHER_GRADS:
347
+ break
348
+ bx, by = batch
349
+ bx = bx.to(device, non_blocking=True)
350
+ by = by.to(device, non_blocking=True)
351
+ model.zero_grad(set_to_none=True)
352
+ try:
353
+ logits, _, _ = model(bx)
354
+ loss = F.cross_entropy(
355
+ logits.view(-1, vocab_size), by.view(-1)
356
+ )
357
+ loss.backward()
358
+ except RuntimeError as exc:
359
+ self.logger.warning(f"Fisher backward fallido: {exc}")
360
+ model.zero_grad(set_to_none=True)
361
+ continue
362
+ g = self._flatten_grads(model)
363
+ if g is not None:
364
+ samples.append(g)
365
+ n_batches += 1
366
+ model.zero_grad(set_to_none=True)
367
+ if not was_training:
368
+ model.eval()
369
+ M = len(samples)
370
+ if M < 3:
371
+ empty = torch.zeros(1)
372
+ return float("nan"), empty, 0
373
+ D = min(s.numel() for s in samples)
374
+ G = torch.stack([s[:D] for s in samples], dim=0)
375
+ gram = (G @ G.t()) / max(G.shape[0] - 1, 1)
376
+ try:
377
+ eigs = torch.linalg.eigvalsh(gram).real.clamp(min=0)
378
+ except RuntimeError:
379
+ empty = torch.zeros(1)
380
+ return float("nan"), empty, 0
381
+ eigs, _ = torch.sort(eigs, descending=True)
382
+ r_eff = max(1, min(r_target, M - 2))
383
+ if r_eff >= eigs.numel():
384
+ r_eff = max(1, eigs.numel() - 1)
385
+ gap = float((eigs[r_eff - 1] - eigs[r_eff]).item())
386
+ return gap, eigs, r_eff
387
+
388
+ # ------------------------------------------------------------------
389
+ # Berry connection y holonomia discreta
390
+ # ------------------------------------------------------------------
391
+
392
+ @staticmethod
393
+ def _project_unitary(M: torch.Tensor) -> torch.Tensor:
394
+ """Proyeccion a U(r) por descomposicion polar (M ~= U H -> retorna U)."""
395
+ U_l, _S, Vh = torch.linalg.svd(M, full_matrices=False)
396
+ return U_l @ Vh
397
+
398
+ def update_holonomy(self, U_new: torch.Tensor) -> Optional[torch.Tensor]:
399
+ """
400
+ Holonomia discreta:
401
+ T_n = U_n^dagger U_{n+1} en C^{r x r} (transporte paralelo discreto)
402
+ U_Gamma <- T_n * U_Gamma (acumulado)
403
+ Tras cada paso, U_Gamma se proyecta a U(r) para evitar deriva numerica.
404
+ """
405
+ if self.U_prev is None:
406
+ self.U_prev = U_new
407
+ self.U_init = U_new
408
+ self.U_holo = torch.eye(U_new.shape[1], dtype=U_new.dtype)
409
+ return self.U_holo
410
+ # Asegurar misma forma (kernel puede haber cambiado de tamano si r vario)
411
+ r_min = min(self.U_prev.shape[1], U_new.shape[1])
412
+ rows = min(self.U_prev.shape[0], U_new.shape[0])
413
+ Up = self.U_prev[:rows, :r_min]
414
+ Un = U_new[:rows, :r_min]
415
+ T = Up.conj().transpose(0, 1) @ Un
416
+ T = self._project_unitary(T)
417
+ if self.U_holo is None or self.U_holo.shape != T.shape:
418
+ self.U_holo = torch.eye(T.shape[0], dtype=T.dtype)
419
+ self.U_holo = self._project_unitary(T @ self.U_holo)
420
+ self.U_prev = U_new
421
+ return self.U_holo
422
+
423
+ @staticmethod
424
+ def conjugation_distance_su2(U1: torch.Tensor, U2: torch.Tensor) -> float:
425
+ """
426
+ Para U1, U2 en U(1)/U(2): d_conj(U1, U2) = min_g || U1 - g U2 g^{-1} ||_F.
427
+ En U(1) coincide con |U1 - U2|.
428
+ En SU(2) se reduce a comparar |Tr(U1)| con |Tr(U2)| (clase de conjugacion).
429
+ """
430
+ n = U1.shape[0]
431
+ if n == 1:
432
+ return float(torch.linalg.norm(U1 - U2).real.item())
433
+ t1 = torch.trace(U1)
434
+ t2 = torch.trace(U2)
435
+ return float(abs(abs(t1) - abs(t2)).item())
436
+
437
+ # ------------------------------------------------------------------
438
+ # Winding heuristico
439
+ # ------------------------------------------------------------------
440
+
441
+ def _accumulate_winding(self, U_new: torch.Tensor) -> float:
442
+ """W += (1/2pi) * arg det <U_prev | U_new> acumulado sobre la trayectoria."""
443
+ if self.U_prev is None:
444
+ return self.winding_accum
445
+ r_min = min(self.U_prev.shape[1], U_new.shape[1])
446
+ rows = min(self.U_prev.shape[0], U_new.shape[0])
447
+ overlap = self.U_prev[:rows, :r_min].conj().transpose(0, 1) @ U_new[:rows, :r_min]
448
+ det = torch.linalg.det(overlap)
449
+ phase = float(torch.atan2(det.imag, det.real).item())
450
+ self.winding_accum += phase / (2 * math.pi)
451
+ return self.winding_accum
452
+
453
+ # ------------------------------------------------------------------
454
+ # Snapshot completo
455
+ # ------------------------------------------------------------------
456
+
457
+ def snapshot(
458
+ self,
459
+ model: nn.Module,
460
+ step: int,
461
+ dataloader: Optional[Iterable] = None,
462
+ vocab_size: Optional[int] = None,
463
+ ) -> Dict[str, Any]:
464
+ K = self._stack_spectral_kernels(model)
465
+ U_r, sigmas, r = self._dominant_subspace(K)
466
+ prev_U = self.U_prev
467
+ winding = self._accumulate_winding(U_r)
468
+ holo = self.update_holonomy(U_r)
469
+ d_conj_init = (
470
+ self.conjugation_distance_su2(self.U_init.conj().transpose(0, 1) @ self.U_init,
471
+ U_r.conj().transpose(0, 1) @ U_r)
472
+ if self.U_init is not None else 0.0
473
+ )
474
+
475
+ # Berry phase = arg det(U_Gamma)
476
+ if holo is not None and holo.numel() > 0:
477
+ det_holo = torch.linalg.det(holo)
478
+ berry = float(torch.atan2(det_holo.imag, det_holo.real).item())
479
+ holo_norm = float(torch.linalg.norm(holo).real.item())
480
+ else:
481
+ berry = 0.0
482
+ holo_norm = 0.0
483
+
484
+ delta_f = float("nan")
485
+ r_eff_fisher = 0
486
+ if dataloader is not None and vocab_size is not None:
487
+ delta_f, eigs, r_eff_fisher = self.estimate_fisher_gap(
488
+ model, dataloader, vocab_size, r
489
+ )
490
+ sigma_max = float(eigs[0].item()) if eigs.numel() > 0 else 0.0
491
+ sigma_min = float(eigs[eigs > 1e-12].min().item()) if (eigs > 1e-12).any() else 0.0
492
+ kappa_F = (sigma_max / sigma_min) if sigma_min > 0 else float("inf")
493
+ else:
494
+ kappa_F = float("inf")
495
+
496
+ snap = {
497
+ "step": step,
498
+ "rank_r": int(r),
499
+ "fisher_r_eff": int(r_eff_fisher),
500
+ "sigma_max": float(sigmas[0].item()) if sigmas.numel() > 0 else 0.0,
501
+ "sigma_min": float(sigmas[-1].item()) if sigmas.numel() > 0 else 0.0,
502
+ "fisher_gap": delta_f,
503
+ "fisher_kappa": kappa_F,
504
+ "berry_phase": berry,
505
+ "winding": winding,
506
+ "holo_frobenius": holo_norm,
507
+ "d_conj_to_init": d_conj_init,
508
+ }
509
+ self.history.append(snap)
510
+ return snap
511
+
512
+ def format_log(self, snap: Dict[str, Any]) -> str:
513
+ df = snap.get("fisher_gap", float("nan"))
514
+ r_eff = snap.get("fisher_r_eff", 0)
515
+ if r_eff <= 0:
516
+ df_str = "skip"
517
+ r_str = "-"
518
+ elif math.isfinite(df):
519
+ df_str = f"{df:.3e}"
520
+ r_str = str(r_eff)
521
+ else:
522
+ df_str = "nan"
523
+ r_str = str(r_eff)
524
+ return (
525
+ f"r={snap['rank_r']:>2d} "
526
+ f"sig=[{snap['sigma_max']:.3e},{snap['sigma_min']:.3e}] "
527
+ f"Delta_F@r{r_str}={df_str} "
528
+ f"berry={snap['berry_phase']:+.3f} "
529
+ f"W={snap['winding']:+.3f} "
530
+ f"||U_G||={snap['holo_frobenius']:.3f} "
531
+ f"d_conj={snap['d_conj_to_init']:.3e}"
532
+ )
533
+
534
+ def save(self, path: str) -> None:
535
+ with open(path, "w", encoding="utf-8") as f:
536
+ for s in self.history:
537
+ f.write(json.dumps(s) + "\n")
538
+
539
+
540
+ # ============================================================================
541
+ # GAUSS / KARATSUBA: 3 multiplicaciones reales por multiplicacion compleja
542
+ # ============================================================================
543
+
544
+ def _gauss_complex_contract(self, W: torch.Tensor, X: torch.Tensor) -> torch.Tensor:
545
+ """Sustituye QuaternionSpectralLayer._contract usando el truco de Gauss.
546
+
547
+ Para (Wr + i Wi)(Xr + i Xi) la version naive requiere 4 productos reales:
548
+ Yr = Wr Xr - Wi Xi
549
+ Yi = Wr Xi + Wi Xr
550
+ Gauss (Karatsuba) baja a 3 productos reales:
551
+ m1 = Wr * Xr
552
+ m2 = Wi * Xi
553
+ m3 = (Wr + Wi) * (Xr + Xi)
554
+ Yr = m1 - m2
555
+ Yi = m3 - m1 - m2
556
+
557
+ Importante (AMP): el _contract original opera sobre complex64 y PyTorch no
558
+ autocastea operaciones complejas; el resultado es complex64. Si dejamos que
559
+ autocast convierta nuestros einsums reales a fp16, la dtype de salida cambia
560
+ y rompe el scatter_add_ corriente abajo en QuaternionTorusBrain. Por eso
561
+ desactivamos autocast aqui y forzamos fp32 para preservar la semantica.
562
+ """
563
+ device_type = W.device.type if W.device.type in ("cuda", "cpu") else "cpu"
564
+ with torch.amp.autocast(device_type=device_type, enabled=False):
565
+ Wr = W.real.float()
566
+ Wi = W.imag.float()
567
+ Xr = X.real.float()
568
+ Xi = X.imag.float()
569
+ m1 = torch.einsum("iohw,bihw->bohw", Wr, Xr)
570
+ m2 = torch.einsum("iohw,bihw->bohw", Wi, Xi)
571
+ m3 = torch.einsum("iohw,bihw->bohw", Wr + Wi, Xr + Xi)
572
+ Yr = m1 - m2
573
+ Yi = m3 - m1 - m2
574
+ return torch.complex(Yr, Yi)
575
+
576
+
577
+ _GAUSS_PATCH_APPLIED = False
578
+
579
+
580
+ def apply_gauss_patch(logger: Optional[logging.Logger] = None) -> None:
581
+ """Activa la version Gauss de _contract en QuaternionSpectralLayer.
582
+ Idempotente: solo parchea una vez por proceso."""
583
+ global _GAUSS_PATCH_APPLIED
584
+ if _GAUSS_PATCH_APPLIED:
585
+ return
586
+ QuaternionSpectralLayer._contract_original = QuaternionSpectralLayer._contract # type: ignore[attr-defined]
587
+ QuaternionSpectralLayer._contract = _gauss_complex_contract # type: ignore[assignment]
588
+ _GAUSS_PATCH_APPLIED = True
589
+ if logger is not None:
590
+ logger.info("Gauss complex-mult patch aplicado: 3 productos reales por contraccion.")
591
+
592
+
593
+ # ============================================================================
594
+ # EFFICIENCY METRICS: performance / param, / FLOP, / bandwidth
595
+ # ============================================================================
596
+
597
+ class EfficiencyMetrics:
598
+ """
599
+ Mide y calcula los tres ratios pedidos:
600
+
601
+ perf_per_param = (1 / val_ppl) / params_M
602
+ perf_per_FLOP = tokens_per_sec / FLOPs_per_sec_aprox
603
+ perf_per_BW = tokens_per_sec / bytes_moved_per_sec_aprox
604
+
605
+ FLOPs estimados con la heuristica de Kaplan/Hoffmann:
606
+ FLOPs_forward_per_token ~= 2 * N_no_embed
607
+ FLOPs_total_per_token ~= 6 * N_no_embed (forward + backward)
608
+ Bandwidth estimada como params_bytes leidos + activations_bytes movidas por step.
609
+ tokens_per_sec se cronometra empiricamente sobre el dataloader.
610
+ """
611
+
612
+ def __init__(self, model: nn.Module, config: TopoGPT3Config,
613
+ logger: logging.Logger, gauss_enabled: bool):
614
+ self.model = model
615
+ self.config = config
616
+ self.logger = logger
617
+ self.gauss_enabled = gauss_enabled
618
+ self.total_params = sum(p.numel() for p in model.parameters())
619
+ self.embed_params = self._embed_params(model)
620
+ self.non_embed_params = self.total_params - self.embed_params
621
+
622
+ @staticmethod
623
+ def _embed_params(model: nn.Module) -> int:
624
+ n = 0
625
+ for name, mod in model.named_modules():
626
+ if isinstance(mod, nn.Embedding):
627
+ n += sum(p.numel() for p in mod.parameters())
628
+ return n
629
+
630
+ @torch.no_grad()
631
+ def measure_throughput(self, dataloader, vocab_size: int) -> Tuple[float, float]:
632
+ """Devuelve (tokens_por_segundo, segundos_por_step)."""
633
+ self.model.eval()
634
+ device = self.config.DEVICE
635
+ n_batches = self.config.EFFICIENCY_THROUGHPUT_BATCHES
636
+ # Warmup
637
+ warmup_done = False
638
+ for bx, by in dataloader:
639
+ bx = bx.to(device, non_blocking=True)
640
+ self.model(bx)
641
+ if "cuda" in device:
642
+ torch.cuda.synchronize()
643
+ warmup_done = True
644
+ break
645
+ if not warmup_done:
646
+ return 0.0, 0.0
647
+ total_tokens = 0
648
+ t0 = time.time()
649
+ seen = 0
650
+ for bx, by in dataloader:
651
+ if seen >= n_batches:
652
+ break
653
+ bx = bx.to(device, non_blocking=True)
654
+ self.model(bx)
655
+ total_tokens += int(bx.numel())
656
+ seen += 1
657
+ if "cuda" in device:
658
+ torch.cuda.synchronize()
659
+ dt = max(time.time() - t0, 1e-6)
660
+ self.model.train()
661
+ return total_tokens / dt, dt / max(seen, 1)
662
+
663
+ def estimate_flops_per_step(self, batch_size: int, seq_len: int) -> int:
664
+ """Heuristica: 6 * N_no_embed * tokens (forward + backward)."""
665
+ tokens = batch_size * seq_len
666
+ return int(6 * self.non_embed_params * tokens)
667
+
668
+ def estimate_bytes_per_step(self, batch_size: int, seq_len: int,
669
+ dtype_bytes: int = 2) -> int:
670
+ """Bandwidth aproximada: lectura de pesos + activaciones por step.
671
+ Asume AMP fp16 (2 bytes); pesos fp32 (4 bytes) leidos una vez."""
672
+ weight_bytes = self.total_params * 4
673
+ activation_bytes = batch_size * seq_len * self.config.GRADIENT_CHECKPOINTING_FACTOR * dtype_bytes
674
+ return weight_bytes + activation_bytes
675
+
676
+ def compute(self, dataloader, vocab_size: int, val_loss: float,
677
+ val_ppl: float, val_acc: float, batch_size: int,
678
+ seq_len: int) -> Dict[str, Any]:
679
+ tok_per_sec, sec_per_step = self.measure_throughput(dataloader, vocab_size)
680
+ flops_step = self.estimate_flops_per_step(batch_size, seq_len)
681
+ bytes_step = self.estimate_bytes_per_step(batch_size, seq_len)
682
+ flops_per_sec = flops_step / max(sec_per_step, 1e-9)
683
+ bytes_per_sec = bytes_step / max(sec_per_step, 1e-9)
684
+
685
+ params_M = self.total_params / 1e6
686
+ perf_quality = (1.0 / max(val_ppl, 1e-6))
687
+
688
+ return {
689
+ "params_total": self.total_params,
690
+ "params_non_embed": self.non_embed_params,
691
+ "params_embed": self.embed_params,
692
+ "gauss_complex": self.gauss_enabled,
693
+ "tokens_per_sec": tok_per_sec,
694
+ "sec_per_step": sec_per_step,
695
+ "flops_per_step": flops_step,
696
+ "flops_per_sec": flops_per_sec,
697
+ "bytes_per_step": bytes_step,
698
+ "bytes_per_sec": bytes_per_sec,
699
+ "val_loss": val_loss,
700
+ "val_ppl": val_ppl,
701
+ "val_acc": val_acc,
702
+ # Los tres ratios pedidos:
703
+ "perf_per_param": perf_quality / max(params_M, 1e-9),
704
+ "perf_per_GFLOP_sec": tok_per_sec / max(flops_per_sec / 1e9, 1e-9),
705
+ "perf_per_GBs": tok_per_sec / max(bytes_per_sec / 1e9, 1e-9),
706
+ }
707
+
708
+ def format_log(self, m: Dict[str, Any]) -> str:
709
+ return (
710
+ f"params={m['params_total']/1e6:.2f}M (non-embed {m['params_non_embed']/1e6:.2f}M) "
711
+ f"gauss={m['gauss_complex']} "
712
+ f"tok/s={m['tokens_per_sec']:.1f} "
713
+ f"GFLOPS={m['flops_per_sec']/1e9:.2f} "
714
+ f"GB/s={m['bytes_per_sec']/1e9:.2f}\n"
715
+ f" ratios: 1/ppl_per_Mparam={m['perf_per_param']:.4e} | "
716
+ f"tok/s_per_GFLOP={m['perf_per_GFLOP_sec']:.3e} | "
717
+ f"tok/s_per_GB={m['perf_per_GBs']:.3e}"
718
+ )
719
+
720
+
721
+ # ============================================================================
722
+ # CURRICULUM DATA LOADER
723
+ # ============================================================================
724
+
725
+ class CodeCurriculumLoader:
726
+ """
727
+ Carga los 4 datasets, normaliza cada ejemplo a una unica cadena de texto,
728
+ tokeniza con BPE y produce splits train / val / holdout disjuntos.
729
+
730
+ Politica de normalizacion por dataset:
731
+ - CodeAlpaca: "### Instruction\n{i}\n### Input\n{x}\n### Response\n{o}"
732
+ - Code-Feedback: concat de turnos: "<usr> ... </usr>\n<asst> ... </asst>"
733
+ - Magicoder-Evol: "### Problem\n{p}\n### Solution\n{s}"
734
+ - Tiny-The-Stack: texto crudo del archivo (truncado a 32k chars/file)
735
+
736
+ Cache en disco: tokens_{tier}_{split}.bin (int32 memmap) + manifest .json.
737
+ El HOLDOUT se separa con seed fija antes de tokenizar para garantizar
738
+ que la misma muestra nunca aparezca en train o val entre corridas.
739
+ """
740
+
741
+ def __init__(
742
+ self,
743
+ config: TopoGPT3Config,
744
+ tokenizer: BPETokenizer,
745
+ logger: logging.Logger,
746
+ ):
747
+ self.config = config
748
+ self.tokenizer = tokenizer
749
+ self.logger = logger
750
+ self.cache_dir = Path(config.DATA_CACHE)
751
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
752
+
753
+ # ------------------------------------------------------------------
754
+ # Normalizacion por dataset
755
+ # ------------------------------------------------------------------
756
+
757
+ @staticmethod
758
+ def _format_codealpaca(ex: Dict[str, Any]) -> Optional[str]:
759
+ instr = (ex.get("instruction") or "").strip()
760
+ inp = (ex.get("input") or "").strip()
761
+ out = (ex.get("output") or "").strip()
762
+ if not instr or not out:
763
+ return None
764
+ if inp:
765
+ return f"### Instruction\n{instr}\n### Input\n{inp}\n### Response\n{out}\n"
766
+ return f"### Instruction\n{instr}\n### Response\n{out}\n"
767
+
768
+ @staticmethod
769
+ def _format_code_feedback(ex: Dict[str, Any]) -> Optional[str]:
770
+ # m-a-p/CodeFeedback-Filtered-Instruction usa columnas query/answer
771
+ q = (ex.get("query") or ex.get("question") or ex.get("instruction") or "").strip()
772
+ a = (ex.get("answer") or ex.get("response") or ex.get("output") or "").strip()
773
+ if q and a:
774
+ return f"<|user|>\n{q}\n<|assistant|>\n{a}\n"
775
+ # Fallback: forma multi-turno (chat)
776
+ msgs = ex.get("messages") or ex.get("conversations") or []
777
+ parts: List[str] = []
778
+ for m in msgs:
779
+ role = (m.get("role") or m.get("from") or "user").strip().lower()
780
+ content = (m.get("content") or m.get("value") or "").strip()
781
+ if not content:
782
+ continue
783
+ tag = "user" if role in ("user", "human") else "assistant"
784
+ parts.append(f"<|{tag}|>\n{content}\n")
785
+ joined = "".join(parts).strip()
786
+ return joined or None
787
+
788
+ @staticmethod
789
+ def _format_magicoder(ex: Dict[str, Any]) -> Optional[str]:
790
+ prob = (ex.get("instruction") or ex.get("problem") or "").strip()
791
+ sol = (ex.get("response") or ex.get("solution") or ex.get("output") or "").strip()
792
+ if not prob or not sol:
793
+ return None
794
+ return f"### Problem\n{prob}\n### Solution\n{sol}\n"
795
+
796
+ @staticmethod
797
+ def _format_tiny_stack(ex: Dict[str, Any]) -> Optional[str]:
798
+ # bigcode/the-stack-smol usa 'content'; nampdn-ai/tiny-codes usa prompt/response
799
+ content = ex.get("content") or ex.get("text") or ex.get("code") or ""
800
+ if isinstance(content, str) and len(content) >= 30:
801
+ return content[:32_000]
802
+ prompt = (ex.get("prompt") or "").strip()
803
+ response = (ex.get("response") or "").strip()
804
+ if prompt and response:
805
+ return f"### Prompt\n{prompt}\n### Response\n{response}\n"[:32_000]
806
+ return None
807
+
808
+ @classmethod
809
+ def _get_formatter(cls, tier: str):
810
+ return {
811
+ "codealpaca": cls._format_codealpaca,
812
+ "code_feedback": cls._format_code_feedback,
813
+ "magicoder_evol": cls._format_magicoder,
814
+ "tiny_the_stack": cls._format_tiny_stack,
815
+ }[tier]
816
+
817
+ # Por dataset: (attr_de_id, kwargs_extra_para_load_dataset)
818
+ _HF_KWARGS: Dict[str, Tuple[str, Dict[str, Any]]] = {
819
+ "codealpaca": ("HF_CODEALPACA", {}),
820
+ "code_feedback": ("HF_CODE_FEEDBACK", {}),
821
+ "magicoder_evol": ("HF_MAGICODER", {}),
822
+ # Tiny-The-Stack: bigcode/the-stack-smol esta organizado por
823
+ # data/<lenguaje>; tomamos python como muestra por defecto.
824
+ "tiny_the_stack": ("HF_TINY_STACK", {"data_dir": "data/python"}),
825
+ }
826
+
827
+ # ------------------------------------------------------------------
828
+ # Tokenizacion + memmap por tier/split
829
+ # ------------------------------------------------------------------
830
+
831
+ def _tier_paths(self, tier: str) -> Dict[str, Path]:
832
+ return {
833
+ split: self.cache_dir / f"tokens_{tier}_{split}.bin"
834
+ for split in ("train", "val", "holdout")
835
+ }
836
+
837
+ def _manifest_path(self, tier: str) -> Path:
838
+ return self.cache_dir / f"manifest_{tier}.json"
839
+
840
+ def _already_prepared(self, tier: str) -> bool:
841
+ """True solo si los 3 splits existen, son no-vacios y el manifest concuerda."""
842
+ paths = self._tier_paths(tier)
843
+ manifest = self._manifest_path(tier)
844
+ if not manifest.exists():
845
+ return False
846
+ if not all(p.exists() for p in paths.values()):
847
+ return False
848
+ try:
849
+ with open(manifest, "r", encoding="utf-8") as f:
850
+ meta = json.load(f)
851
+ sizes = meta.get("sizes", {})
852
+ except (json.JSONDecodeError, OSError):
853
+ return False
854
+ for split, p in paths.items():
855
+ disk_n = p.stat().st_size // 4
856
+ if disk_n == 0:
857
+ self.logger.warning(
858
+ f"[{tier}] cache invalido: {p.name} esta vacio; re-preparando."
859
+ )
860
+ return False
861
+ expected = sizes.get(split, 0)
862
+ if expected > 0 and abs(disk_n - expected) > 4:
863
+ self.logger.warning(
864
+ f"[{tier}] cache inconsistente: {p.name} disk={disk_n} "
865
+ f"manifest={expected}; re-preparando."
866
+ )
867
+ return False
868
+ return True
869
+
870
+ def _load_hf_with_fallback(self, tier: str):
871
+ """Carga el dataset HF; para tiny_the_stack prueba una cadena de fallbacks
872
+ publicos hasta que uno funcione."""
873
+ try:
874
+ from datasets import load_dataset
875
+ except ImportError as exc:
876
+ raise RuntimeError(
877
+ "Requiere `datasets` de HuggingFace: pip install datasets"
878
+ ) from exc
879
+
880
+ attr, extra_kwargs = self._HF_KWARGS[tier]
881
+ hf_id = getattr(self.config, attr)
882
+ candidates: List[Tuple[str, Dict[str, Any]]] = [(hf_id, extra_kwargs)]
883
+ if tier == "tiny_the_stack":
884
+ for alt in self.config.HF_TINY_STACK_FALLBACKS:
885
+ candidates.append((alt, {}))
886
+
887
+ last_exc: Optional[Exception] = None
888
+ for cand_id, cand_kwargs in candidates:
889
+ self.logger.info(f"[{tier}] intentando {cand_id} {cand_kwargs} ...")
890
+ try:
891
+ return load_dataset(cand_id, split="train", **cand_kwargs)
892
+ except Exception as exc:
893
+ last_exc = exc
894
+ msg = str(exc).splitlines()[0] if str(exc) else type(exc).__name__
895
+ self.logger.warning(
896
+ f"[{tier}] {cand_id} no accesible: {msg}; probando siguiente."
897
+ )
898
+
899
+ raise RuntimeError(
900
+ f"[{tier}] todos los datasets candidates fallaron. Ultimo error: {last_exc}"
901
+ )
902
+
903
+ def prepare_tier(self, tier_index: int, force: bool = False) -> Dict[str, int]:
904
+ tier = self.config.TIER_NAMES[tier_index]
905
+ if not force and self._already_prepared(tier):
906
+ with open(self._manifest_path(tier), "r", encoding="utf-8") as f:
907
+ return json.load(f)["sizes"]
908
+
909
+ ds = self._load_hf_with_fallback(tier)
910
+ attr, extra_kwargs = self._HF_KWARGS[tier]
911
+ hf_id = getattr(self.config, attr)
912
+ formatter = self._get_formatter(tier)
913
+ rng = np.random.default_rng(self.config.SPLIT_SEED + tier_index)
914
+ n = len(ds)
915
+ idx = np.arange(n)
916
+ rng.shuffle(idx)
917
+ n_hold = max(1, int(n * self.config.HOLDOUT_FRACTION))
918
+ n_val = max(1, int(n * self.config.VAL_FRACTION))
919
+ hold_idx = set(idx[:n_hold].tolist())
920
+ val_idx = set(idx[n_hold:n_hold + n_val].tolist())
921
+ # train_idx implicito = el resto
922
+
923
+ eot = self.tokenizer.eot_token()
924
+ max_tokens = self.config.MAX_TOKENS_PER_TIER[tier_index]
925
+ sizes = {"train": 0, "val": 0, "holdout": 0}
926
+ files = {
927
+ split: open(self._tier_paths(tier)[split], "wb")
928
+ for split in ("train", "val", "holdout")
929
+ }
930
+
931
+ batch_tokens: Dict[str, List[int]] = {k: [] for k in files}
932
+ CHUNK = 1_000_000
933
+
934
+ def flush(split: str) -> None:
935
+ arr = np.array(batch_tokens[split], dtype=np.int32)
936
+ files[split].write(arr.tobytes())
937
+ sizes[split] += arr.size
938
+ batch_tokens[split] = []
939
+
940
+ train_cap_hit = False
941
+ for i, ex in enumerate(ds):
942
+ if i in hold_idx:
943
+ split = "holdout"
944
+ elif i in val_idx:
945
+ split = "val"
946
+ else:
947
+ if train_cap_hit:
948
+ continue
949
+ split = "train"
950
+
951
+ text = formatter(ex)
952
+ if not text:
953
+ continue
954
+ try:
955
+ ids = self.tokenizer.encode(text)
956
+ except Exception:
957
+ continue
958
+ batch_tokens[split].extend(ids)
959
+ batch_tokens[split].append(eot)
960
+
961
+ if len(batch_tokens[split]) >= CHUNK:
962
+ flush(split)
963
+ if sizes["train"] >= max_tokens > 0:
964
+ train_cap_hit = True
965
+
966
+ for split in files:
967
+ if batch_tokens[split]:
968
+ flush(split)
969
+ files[split].close()
970
+
971
+ if sizes["train"] == 0:
972
+ for p in self._tier_paths(tier).values():
973
+ try:
974
+ p.unlink()
975
+ except OSError:
976
+ pass
977
+ raise RuntimeError(
978
+ f"[{tier}] el formateador no produjo ejemplos validos. "
979
+ f"Revisa el esquema del dataset (columnas esperadas vs reales)."
980
+ )
981
+
982
+ manifest = {
983
+ "tier": tier,
984
+ "hf_id": hf_id,
985
+ "hf_kwargs": extra_kwargs,
986
+ "sizes": sizes,
987
+ "split_seed": self.config.SPLIT_SEED + tier_index,
988
+ "saved_at": datetime.utcnow().isoformat(),
989
+ }
990
+ with open(self._manifest_path(tier), "w", encoding="utf-8") as f:
991
+ json.dump(manifest, f, indent=2)
992
+ self.logger.info(
993
+ f"[{tier}] train={sizes['train']:,} val={sizes['val']:,} "
994
+ f"holdout={sizes['holdout']:,} tokens"
995
+ )
996
+ return sizes
997
+
998
+ def open_memmap(self, tier: str, split: str) -> np.memmap:
999
+ path = self._tier_paths(tier)[split]
1000
+ if not path.exists():
1001
+ raise FileNotFoundError(
1002
+ f"Falta {path}. Corre --prepare-data primero."
1003
+ )
1004
+ n = path.stat().st_size // 4
1005
+ return np.memmap(path, dtype=np.int32, mode="r", shape=(n,))
1006
+
1007
+
1008
+ # ============================================================================
1009
+ # DATASET DE BLOQUES SOBRE TOKEN STREAM
1010
+ # ============================================================================
1011
+
1012
+ class BlockTokenDataset(torch.utils.data.Dataset):
1013
+ """
1014
+ Dataset autoregresivo sobre un stream de tokens.
1015
+ Cada item es (x, y) con shape [seq_len].
1016
+ """
1017
+
1018
+ def __init__(self, tokens: np.memmap, seq_len: int):
1019
+ self.tokens = tokens
1020
+ self.seq_len = seq_len
1021
+ self.n_blocks = max(0, (len(tokens) - 1) // seq_len)
1022
+
1023
+ def __len__(self) -> int:
1024
+ return self.n_blocks
1025
+
1026
+ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
1027
+ start = idx * self.seq_len
1028
+ end = start + self.seq_len + 1
1029
+ block = np.array(self.tokens[start:end], dtype=np.int64)
1030
+ x = torch.from_numpy(block[:-1])
1031
+ y = torch.from_numpy(block[1:])
1032
+ return x, y
1033
+
1034
+
1035
+ # ============================================================================
1036
+ # CHECKPOINTING
1037
+ # ============================================================================
1038
+
1039
+ class CheckpointStore:
1040
+ """Persiste pesos del modelo + estado del trainer (sin AMP scaler para portabilidad)."""
1041
+
1042
+ def __init__(self, root: str, max_keep: int, logger: logging.Logger):
1043
+ self.root = Path(root)
1044
+ self.root.mkdir(parents=True, exist_ok=True)
1045
+ self.max_keep = max_keep
1046
+ self.logger = logger
1047
+ self.last_save_time = time.time()
1048
+
1049
+ def save(self, tag: str, model: nn.Module, optimizer: torch.optim.Optimizer,
1050
+ state: Dict[str, Any]) -> None:
1051
+ """Guarda checkpoint atomico en <root>/last/ sobreescribiendo el anterior.
1052
+
1053
+ El argumento `tag` se conserva por compatibilidad pero se ignora: solo
1054
+ existe un checkpoint llamado `last` y los pesos en safetensors.
1055
+ """
1056
+ from safetensors.torch import save_file as st_save
1057
+ del tag
1058
+ out = self.root / "last"
1059
+ out.mkdir(parents=True, exist_ok=True)
1060
+ model_path = out / "model.safetensors"
1061
+ optim_path = out / "optim.pt"
1062
+ state_path = out / "state.json"
1063
+ tmp_model = model_path.with_suffix(".safetensors.tmp")
1064
+ tmp_optim = optim_path.with_suffix(".pt.tmp")
1065
+ tmp_state = state_path.with_suffix(".json.tmp")
1066
+
1067
+ st_save({k: v.detach().cpu() for k, v in model.state_dict().items()},
1068
+ str(tmp_model))
1069
+ torch.save(optimizer.state_dict(), tmp_optim)
1070
+ with open(tmp_state, "w", encoding="utf-8") as f:
1071
+ json.dump(state, f, indent=2, default=str)
1072
+ os.replace(tmp_model, model_path)
1073
+ os.replace(tmp_optim, optim_path)
1074
+ os.replace(tmp_state, state_path)
1075
+
1076
+ self.last_save_time = time.time()
1077
+ size_mb = model_path.stat().st_size / 1024 / 1024
1078
+ self.logger.info(
1079
+ f"[checkpoint] guardado en {model_path} "
1080
+ f"({size_mb:.1f} MB, step={state.get('global_step', '?')})"
1081
+ )
1082
+
1083
+ def load_latest(self, model: nn.Module, optimizer: torch.optim.Optimizer
1084
+ ) -> Optional[Dict[str, Any]]:
1085
+ from safetensors.torch import load_file as st_load
1086
+ latest = self.root / "last"
1087
+ if not (latest / "model.safetensors").exists():
1088
+ legacy = sorted(
1089
+ [p for p in self.root.iterdir()
1090
+ if p.is_dir() and p.name.startswith("step_")
1091
+ and (p / "model.safetensors").exists()],
1092
+ key=lambda p: p.stat().st_mtime,
1093
+ )
1094
+ if not legacy:
1095
+ return None
1096
+ latest = legacy[-1]
1097
+ self.logger.info(f"No existe 'last/', cargando legacy: {latest.name}")
1098
+ sd = st_load(str(latest / "model.safetensors"))
1099
+ model.load_state_dict(sd, strict=False)
1100
+ if (latest / "optim.pt").exists():
1101
+ optimizer.load_state_dict(torch.load(latest / "optim.pt", map_location="cpu", weights_only=True))
1102
+ with open(latest / "state.json", "r", encoding="utf-8") as f:
1103
+ state = json.load(f)
1104
+ self.logger.info(f"Reanudado desde {latest}")
1105
+ return state
1106
+
1107
+ def should_save(self, interval_min: int) -> bool:
1108
+ return (time.time() - self.last_save_time) >= interval_min * 60
1109
+
1110
+
1111
+ # ============================================================================
1112
+ # TRAINER CURRICULAR
1113
+ # ============================================================================
1114
+
1115
+ class TopoGPT3Trainer:
1116
+ """
1117
+ Orquesta el curriculum sobre los 4 tiers.
1118
+
1119
+ Pipeline por tier:
1120
+ 1. Abre memmap de tokens (train/val/holdout).
1121
+ 2. Construye DataLoaders con seq_len(tier).
1122
+ 3. Entrena TIER_EPOCHS[tier] epocas con AMP + grad accum.
1123
+ 4. Cada GRASS_TRACK_EVERY steps: snapshot Grassmanniano.
1124
+ 5. Al final de cada epoca: eval en VAL.
1125
+ 6. Al final del tier: eval en HOLDOUT (datos nunca vistos).
1126
+ 7. Checkpoint y avanza al siguiente tier.
1127
+
1128
+ Al final del pipeline: eval en HOLDOUT *combinado* de los 4 tiers.
1129
+ """
1130
+
1131
+ def __init__(self, config: TopoGPT3Config, start_tier: Optional[int] = None,
1132
+ exploitgym_loader: Optional['ExploitGymDataLoader'] = None,
1133
+ topogpt3_loader: Optional['CodeCurriculumLoader'] = None,
1134
+ merged_config: Optional['TopoMergedConfig'] = None):
1135
+ self.config = config
1136
+ self.exploitgym_loader = exploitgym_loader
1137
+ self.topogpt3_loader = topogpt3_loader
1138
+ self.merged_config = merged_config
1139
+ self.logger = setup_logger("TopoGPT3", "INFO")
1140
+ self.start_tier_explicit = start_tier is not None
1141
+ self.start_tier = 0 if start_tier is None else int(start_tier)
1142
+ if not (0 <= self.start_tier < len(config.TIER_NAMES)):
1143
+ raise ValueError(
1144
+ f"start_tier fuera de rango: {self.start_tier} "
1145
+ f"(valido 0..{len(config.TIER_NAMES) - 1})"
1146
+ )
1147
+ set_seed(config.RANDOM_SEED, config.DEVICE)
1148
+
1149
+ # Activa el truco de Gauss ANTES de instanciar el modelo para que
1150
+ # todas las QuaternionSpectralLayer ya usen 3 productos reales.
1151
+ if config.USE_GAUSS_COMPLEX_MULT:
1152
+ apply_gauss_patch(self.logger)
1153
+
1154
+ # max_seq fijo sobre TODOS los tiers: la forma del embedding posicional
1155
+ # queda estable y los safetensors guardados se cargan sin mismatch
1156
+ # aunque se cambie start_tier entre runs.
1157
+ max_seq = max(config.TIER_SEQ_LEN)
1158
+ max_window = max(config.PROGRESSIVE_WINDOW)
1159
+ self.base_cfg = config.build_topogpt2_config(max_seq, attn_window=max_window)
1160
+ self.tokenizer = BPETokenizer()
1161
+ self.model = TopoGPT2(self.base_cfg).to(config.DEVICE)
1162
+ self.optimizer = torch.optim.AdamW(
1163
+ self.model.parameters(),
1164
+ lr=config.LEARNING_RATE,
1165
+ weight_decay=config.WEIGHT_DECAY,
1166
+ betas=(0.9, 0.95),
1167
+ )
1168
+
1169
+ dev = config.DEVICE
1170
+ self.amp_dtype = torch.float16 if "cuda" in dev else torch.bfloat16
1171
+ self.scaler = torch.amp.GradScaler(
1172
+ dev.split(":")[0],
1173
+ enabled=config.USE_AMP and "cuda" in dev,
1174
+ )
1175
+
1176
+ self.loader = CodeCurriculumLoader(config, self.tokenizer, self.logger)
1177
+ self.tracker = GrassmannianTracker(config, self.logger)
1178
+ self.ckpt = CheckpointStore(config.CHECKPOINT_DIR, config.MAX_CHECKPOINTS, self.logger)
1179
+ self.efficiency = EfficiencyMetrics(
1180
+ self.model, config, self.logger,
1181
+ gauss_enabled=config.USE_GAUSS_COMPLEX_MULT,
1182
+ )
1183
+
1184
+ self.global_step = 0
1185
+ self.completed_tiers: List[str] = []
1186
+ self.tier_metrics: Dict[str, Dict[str, float]] = {}
1187
+
1188
+ # Anti-forgetting: EWC + Replay
1189
+ self.ewc = EWCRegularizer(
1190
+ self.model,
1191
+ lambda_ewc=config.EWC_LAMBDA,
1192
+ fisher_num_samples=config.EWC_FISHER_BATCHES,
1193
+ ) if config.USE_EWC else None
1194
+ self.replay = ReplayBuffer(
1195
+ max_tiers=config.REPLAY_MAX_TIERS,
1196
+ replay_ratio=config.REPLAY_RATIO,
1197
+ ) if config.USE_REPLAY else None
1198
+
1199
+ # ------------------------------------------------------------------
1200
+ # Preparacion de datos
1201
+ # ------------------------------------------------------------------
1202
+
1203
+ def prepare_all(self, force: bool = False) -> None:
1204
+ if self.merged_config is not None:
1205
+ # Merged mode: prepare both data sources
1206
+ for i, tier in enumerate(self.config.TIER_NAMES):
1207
+ self.logger.info(
1208
+ f"=== preparing tier {i+1}/{len(self.config.TIER_NAMES)}: {tier} ==="
1209
+ )
1210
+ try:
1211
+ if self.merged_config.is_code_tier(i):
1212
+ self.loader.prepare_tier(i, force=force)
1213
+ else:
1214
+ self.exploitgym_loader.prepare_tier(
1215
+ self.merged_config.exploit_tier_index(i), force=force
1216
+ )
1217
+ except RuntimeError as exc:
1218
+ self.logger.error(f"[{tier}] preparation failed: {exc}")
1219
+ return
1220
+ if self.exploitgym_loader is not None:
1221
+ for i, tier in enumerate(self.config.TIER_NAMES):
1222
+ self.logger.info(
1223
+ f"=== preparando exploitgym tier {i+1}/{len(self.config.TIER_NAMES)}: {tier} ==="
1224
+ )
1225
+ try:
1226
+ self.exploitgym_loader.prepare_tier(i, force=force)
1227
+ except RuntimeError as exc:
1228
+ self.logger.error(f"[{tier}] preparacion fallida: {exc}")
1229
+ return
1230
+ for i, tier in enumerate(self.config.TIER_NAMES):
1231
+ self.logger.info(
1232
+ f"=== preparando tier {i+1}/{len(self.config.TIER_NAMES)}: {tier} ==="
1233
+ )
1234
+ try:
1235
+ self.loader.prepare_tier(i, force=force)
1236
+ except RuntimeError as exc:
1237
+ self.logger.error(f"[{tier}] preparacion fallida: {exc}")
1238
+ self.logger.error(f"[{tier}] se omite; otros tiers continuan.")
1239
+
1240
+ # ------------------------------------------------------------------
1241
+ # Loaders por tier
1242
+ # ------------------------------------------------------------------
1243
+
1244
+ def _build_loaders(self, tier_index: int) -> Tuple[
1245
+ torch.utils.data.DataLoader,
1246
+ torch.utils.data.DataLoader,
1247
+ torch.utils.data.DataLoader,
1248
+ ]:
1249
+ tier = self.config.TIER_NAMES[tier_index]
1250
+ seq_len = self.config.TIER_SEQ_LEN[tier_index]
1251
+
1252
+ # Determine data source: merged mode uses per-tier source selection
1253
+ use_exploitgym = False
1254
+ if self.merged_config is not None and self.merged_config.is_exploit_tier(tier_index):
1255
+ use_exploitgym = True
1256
+ elif self.exploitgym_loader is not None and self.topogpt3_loader is None:
1257
+ use_exploitgym = True
1258
+
1259
+ if use_exploitgym:
1260
+ loader = self.exploitgym_loader
1261
+ # exploitgym tiers are 0-indexed within exploitgym data
1262
+ egym_idx = tier_index
1263
+ if self.merged_config is not None:
1264
+ egym_idx = self.merged_config.exploit_tier_index(tier_index)
1265
+ train_tok = loader.open_memmap(self.config.TIER_NAMES[egym_idx], "train")
1266
+ val_tok = loader.open_memmap(self.config.TIER_NAMES[egym_idx], "val")
1267
+ hold_tok = loader.open_memmap(self.config.TIER_NAMES[egym_idx], "holdout")
1268
+ else:
1269
+ loader = self.topogpt3_loader if self.topogpt3_loader is not None else self.loader
1270
+ train_tok = loader.open_memmap(tier, "train")
1271
+ val_tok = loader.open_memmap(tier, "val")
1272
+ hold_tok = loader.open_memmap(tier, "holdout")
1273
+
1274
+ train_ds = BlockTokenDataset(train_tok, seq_len)
1275
+ val_ds = BlockTokenDataset(val_tok, seq_len)
1276
+ hold_ds = BlockTokenDataset(hold_tok, seq_len)
1277
+
1278
+ bs = self.config.BATCH_SIZE
1279
+ train_dl = torch.utils.data.DataLoader(
1280
+ train_ds, batch_size=bs, shuffle=True, drop_last=True, num_workers=0
1281
+ )
1282
+ val_dl = torch.utils.data.DataLoader(
1283
+ val_ds, batch_size=bs, shuffle=False, drop_last=True, num_workers=0
1284
+ )
1285
+ hold_dl = torch.utils.data.DataLoader(
1286
+ hold_ds, batch_size=bs, shuffle=False, drop_last=True, num_workers=0
1287
+ )
1288
+ return train_dl, val_dl, hold_dl
1289
+
1290
+ # ------------------------------------------------------------------
1291
+ # LR schedule (cosine con warmup)
1292
+ # ------------------------------------------------------------------
1293
+
1294
+ def _cosine_lr(self, step: int, total_steps: int) -> float:
1295
+ warmup = max(1, int(total_steps * self.config.WARMUP_RATIO))
1296
+ if step < warmup:
1297
+ return self.config.LEARNING_RATE * step / warmup
1298
+ t = (step - warmup) / max(total_steps - warmup, 1)
1299
+ return self.config.LEARNING_RATE * 0.5 * (1.0 + math.cos(math.pi * t))
1300
+
1301
+ def _set_lr(self, lr: float) -> None:
1302
+ for pg in self.optimizer.param_groups:
1303
+ pg["lr"] = lr
1304
+
1305
+ # ------------------------------------------------------------------
1306
+ # Train loop por tier
1307
+ # ------------------------------------------------------------------
1308
+
1309
+ def _train_one_tier(self, tier_index: int) -> Optional[Dict[str, float]]:
1310
+ tier = self.config.TIER_NAMES[tier_index]
1311
+ epochs = self.config.TIER_EPOCHS[tier_index]
1312
+ seq_len = self.config.TIER_SEQ_LEN[tier_index]
1313
+ try:
1314
+ train_dl, val_dl, hold_dl = self._build_loaders(tier_index)
1315
+ except (FileNotFoundError, ValueError) as exc:
1316
+ self.logger.warning(
1317
+ f"[{tier}] saltado: no hay datos preparados ({exc})."
1318
+ )
1319
+ return None
1320
+ if len(train_dl.dataset) == 0:
1321
+ self.logger.warning(f"[{tier}] saltado: train_blocks=0.")
1322
+ return None
1323
+
1324
+ steps_per_epoch = len(train_dl) // self.config.GRAD_ACCUM_STEPS
1325
+ if self.config.MAX_TRAIN_BATCHES_PER_EPOCH > 0:
1326
+ steps_per_epoch = min(
1327
+ steps_per_epoch,
1328
+ self.config.MAX_TRAIN_BATCHES_PER_EPOCH // self.config.GRAD_ACCUM_STEPS,
1329
+ )
1330
+ total_steps = steps_per_epoch * epochs
1331
+ self.logger.info(
1332
+ f"### TIER {tier_index+1}/{len(self.config.TIER_NAMES)}: {tier} "
1333
+ f"| epochs={epochs} seq_len={seq_len} "
1334
+ f"| train_blocks={len(train_dl.dataset)} val={len(val_dl.dataset)} "
1335
+ f"hold={len(hold_dl.dataset)} optim_steps={total_steps}"
1336
+ )
1337
+
1338
+ step_in_tier = 0
1339
+ last_val_loss = float("inf")
1340
+ last_val_acc = 0.0
1341
+
1342
+ for epoch in range(1, epochs + 1):
1343
+ self.model.train()
1344
+ self.optimizer.zero_grad(set_to_none=True)
1345
+ running_loss = 0.0
1346
+ running_correct = 0
1347
+ running_total = 0
1348
+ seen = 0
1349
+ batches_since_step = 0
1350
+
1351
+ for batch_idx, (bx, by) in enumerate(train_dl):
1352
+ if (self.config.MAX_TRAIN_BATCHES_PER_EPOCH > 0
1353
+ and batch_idx >= self.config.MAX_TRAIN_BATCHES_PER_EPOCH):
1354
+ break
1355
+ bx = bx.to(self.config.DEVICE, non_blocking=True)
1356
+ by = by.to(self.config.DEVICE, non_blocking=True)
1357
+
1358
+ # Replay: mix in data from previous tiers
1359
+ if self.replay is not None and tier_index > 0:
1360
+ replay_batch = self.replay.sample_batch(bx.shape[0])
1361
+ if replay_batch is not None:
1362
+ rx, ry = replay_batch
1363
+ rx = rx.to(self.config.DEVICE, non_blocking=True)
1364
+ ry = ry.to(self.config.DEVICE, non_blocking=True)
1365
+ # Pad replay to match current seq_len if needed
1366
+ cur_len = bx.shape[1]
1367
+ if rx.shape[1] < cur_len:
1368
+ pad_size = cur_len - rx.shape[1]
1369
+ rx = F.pad(rx, (0, pad_size), value=0)
1370
+ ry = F.pad(ry, (0, pad_size), value=0)
1371
+ elif rx.shape[1] > cur_len:
1372
+ rx = rx[:, :cur_len]
1373
+ ry = ry[:, :cur_len]
1374
+ bx = torch.cat([bx, rx], dim=0)
1375
+ by = torch.cat([by, ry], dim=0)
1376
+
1377
+ lr = self._cosine_lr(step_in_tier, total_steps)
1378
+ self._set_lr(lr)
1379
+
1380
+ with torch.amp.autocast(
1381
+ device_type=self.config.DEVICE.split(":")[0],
1382
+ dtype=self.amp_dtype,
1383
+ enabled=self.config.USE_AMP,
1384
+ ):
1385
+ logits, recon_loss, _ = self.model(bx)
1386
+ lm_loss = F.cross_entropy(
1387
+ logits.view(-1, self.base_cfg.VOCAB_SIZE),
1388
+ by.view(-1),
1389
+ )
1390
+ loss = (lm_loss + self.base_cfg.AE_RECON_WEIGHT * recon_loss
1391
+ ) / self.config.GRAD_ACCUM_STEPS
1392
+
1393
+ # EWC: penalize deviation from previous tier's optimal params
1394
+ # Dynamic lambda: full for Tier 2, reduced for Tier 3+
1395
+ # Phase boundary: reduced EWC at code→exploit transition
1396
+ if self.ewc is not None and tier_index > 0:
1397
+ if (self.merged_config is not None
1398
+ and tier_index == self.merged_config.PHASE_BOUNDARY):
1399
+ # Phase transition: allow more plasticity
1400
+ ewc_scale = self.merged_config.EWC_PHASE2_SCALE
1401
+ elif tier_index == 1:
1402
+ ewc_scale = 1.0
1403
+ else:
1404
+ ewc_scale = 0.5
1405
+ ewc_loss = (self.ewc.penalty(self.model) * ewc_scale
1406
+ / self.config.GRAD_ACCUM_STEPS)
1407
+ loss = loss + ewc_loss
1408
+
1409
+ self.scaler.scale(loss).backward()
1410
+ running_loss += float(lm_loss.item())
1411
+ with torch.no_grad():
1412
+ preds = logits.argmax(dim=-1)
1413
+ running_correct += int((preds == by).sum().item())
1414
+ running_total += int(by.numel())
1415
+ seen += 1
1416
+ batches_since_step += 1
1417
+
1418
+ if batches_since_step >= self.config.GRAD_ACCUM_STEPS:
1419
+ self.scaler.unscale_(self.optimizer)
1420
+ torch.nn.utils.clip_grad_norm_(
1421
+ self.model.parameters(), self.config.GRADIENT_CLIP_NORM
1422
+ )
1423
+ self.scaler.step(self.optimizer)
1424
+ self.scaler.update()
1425
+ self.optimizer.zero_grad(set_to_none=True)
1426
+ self.global_step += 1
1427
+ step_in_tier += 1
1428
+ batches_since_step = 0
1429
+
1430
+ if self.global_step % self.config.GRASS_TRACK_EVERY == 0:
1431
+ snap = self.tracker.snapshot(
1432
+ self.model,
1433
+ self.global_step,
1434
+ dataloader=val_dl,
1435
+ vocab_size=self.base_cfg.VOCAB_SIZE,
1436
+ )
1437
+ self.logger.info(
1438
+ f"[grass step={self.global_step}] "
1439
+ + self.tracker.format_log(snap)
1440
+ )
1441
+
1442
+ if self.global_step % self.config.LOG_INTERVAL_STEPS == 0:
1443
+ train_acc = running_correct / max(running_total, 1)
1444
+ self.logger.info(
1445
+ f"[tier={tier} ep={epoch} step={self.global_step}] "
1446
+ f"lm={running_loss/max(seen,1):.4f} "
1447
+ f"acc={train_acc*100:.2f}% "
1448
+ f"lr={lr:.2e}"
1449
+ )
1450
+
1451
+ if self.ckpt.should_save(self.config.CHECKPOINT_EVERY_MIN):
1452
+ self.ckpt.save(
1453
+ f"step_{self.global_step:07d}",
1454
+ self.model, self.optimizer,
1455
+ self._state_dict(),
1456
+ )
1457
+
1458
+ avg_train = running_loss / max(seen, 1)
1459
+ train_acc = running_correct / max(running_total, 1)
1460
+ val_loss, val_ppl, val_acc = self._evaluate(val_dl)
1461
+ last_val_loss = val_loss
1462
+ last_val_acc = val_acc
1463
+ self.logger.info(
1464
+ f"[tier={tier} epoch {epoch}/{epochs}] "
1465
+ f"train_lm={avg_train:.4f} train_acc={train_acc*100:.2f}% | "
1466
+ f"val_lm={val_loss:.4f} val_acc={val_acc*100:.2f}% val_ppl={val_ppl:.2f}"
1467
+ )
1468
+
1469
+ # Holdout: datos del tier que nunca se vieron durante entrenamiento ni en val
1470
+ hold_loss, hold_ppl, hold_acc = self._evaluate(hold_dl)
1471
+ self.logger.info(
1472
+ f"[tier={tier} HOLDOUT-no-visto] "
1473
+ f"hold_lm={hold_loss:.4f} hold_acc={hold_acc*100:.2f}% hold_ppl={hold_ppl:.2f}"
1474
+ )
1475
+
1476
+ # Mediciones de eficiencia (perf/param, perf/FLOP, perf/BW)
1477
+ eff = self.efficiency.compute(
1478
+ dataloader=val_dl, vocab_size=self.base_cfg.VOCAB_SIZE,
1479
+ val_loss=last_val_loss, val_ppl=math.exp(min(last_val_loss, 20.0)),
1480
+ val_acc=last_val_acc,
1481
+ batch_size=self.config.BATCH_SIZE, seq_len=seq_len,
1482
+ )
1483
+ self.logger.info(f"[tier={tier} EFFICIENCY] " + self.efficiency.format_log(eff))
1484
+
1485
+ self.ckpt.save(
1486
+ f"step_{self.global_step:07d}_end_{tier}",
1487
+ self.model, self.optimizer,
1488
+ self._state_dict(),
1489
+ )
1490
+
1491
+ # Anti-forgetting: snapshot Fisher + register tier for replay
1492
+ if self.ewc is not None:
1493
+ self.logger.info(
1494
+ f"[{tier}] computing EWC fisher for next tier..."
1495
+ )
1496
+ self.ewc.compute_fisher(
1497
+ train_dl, self.base_cfg.VOCAB_SIZE,
1498
+ torch.device(self.config.DEVICE),
1499
+ max_batches=self.config.EWC_FISHER_BATCHES,
1500
+ )
1501
+ ewc_state_path = Path(self.config.CHECKPOINT_DIR) / "last" / "ewc_state.pt"
1502
+ self.ewc.save_state(str(ewc_state_path))
1503
+
1504
+ if self.replay is not None:
1505
+ self.replay.add_tier(tier_index, train_dl)
1506
+ self.logger.info(
1507
+ f"[{tier}] registered for replay (buffer: {len(self.replay._tiers)} tiers)"
1508
+ )
1509
+
1510
+ # Anti-forgetting check: evaluate ALL previous tiers' holdouts
1511
+ self._check_forgetting(tier_index)
1512
+
1513
+ return {
1514
+ "tier": tier,
1515
+ "val_loss": last_val_loss,
1516
+ "val_acc": last_val_acc,
1517
+ "holdout_loss": hold_loss,
1518
+ "holdout_acc": hold_acc,
1519
+ "holdout_ppl": hold_ppl,
1520
+ "efficiency": eff,
1521
+ }
1522
+
1523
+ # ------------------------------------------------------------------
1524
+ # Anti-forgetting: check previous tiers after each tier
1525
+ # ------------------------------------------------------------------
1526
+
1527
+ def _check_forgetting(self, completed_tier_index: int) -> None:
1528
+ """Evaluate holdout of ALL previous tiers to detect catastrophic forgetting."""
1529
+ if completed_tier_index == 0:
1530
+ return # No previous tiers to check
1531
+
1532
+ self.logger.info("=" * 70)
1533
+ self.logger.info(
1534
+ f"ANTI-FORGETTING CHECK: evaluating holdouts of tiers 0..{completed_tier_index-1}"
1535
+ )
1536
+ for prev_idx in range(completed_tier_index):
1537
+ prev_tier = self.config.TIER_NAMES[prev_idx]
1538
+ try:
1539
+ _, _, hold_tok = self._open_memmaps(prev_idx)
1540
+ prev_seq_len = self.config.TIER_SEQ_LEN[prev_idx]
1541
+ hold_ds = BlockTokenDataset(hold_tok, prev_seq_len)
1542
+ hold_dl = torch.utils.data.DataLoader(
1543
+ hold_ds, batch_size=self.config.BATCH_SIZE,
1544
+ shuffle=False, drop_last=True, num_workers=0,
1545
+ )
1546
+ hold_loss, hold_ppl, hold_acc = self._evaluate(hold_dl)
1547
+ status = "OK" if hold_acc > 0.20 else "FORGOTTEN"
1548
+ self.logger.info(
1549
+ f" [{prev_tier}] hold_loss={hold_loss:.4f} "
1550
+ f"hold_acc={hold_acc*100:.2f}% hold_ppl={hold_ppl:.2f} "
1551
+ f"[{status}]"
1552
+ )
1553
+ except Exception as exc:
1554
+ self.logger.warning(f" [{prev_tier}] check failed: {exc}")
1555
+ self.logger.info("=" * 70)
1556
+
1557
+ def _open_memmaps(self, tier_index: int):
1558
+ """Open train/val/holdout memmaps for a given tier."""
1559
+ tier = self.config.TIER_NAMES[tier_index]
1560
+ if self.merged_config is not None and self.merged_config.is_exploit_tier(tier_index):
1561
+ egym_idx = self.merged_config.exploit_tier_index(tier_index)
1562
+ return (
1563
+ self.exploitgym_loader.open_memmap(self.config.TIER_NAMES[egym_idx], "train"),
1564
+ self.exploitgym_loader.open_memmap(self.config.TIER_NAMES[egym_idx], "val"),
1565
+ self.exploitgym_loader.open_memmap(self.config.TIER_NAMES[egym_idx], "holdout"),
1566
+ )
1567
+ if self.exploitgym_loader is not None and self.topogpt3_loader is None:
1568
+ return (
1569
+ self.exploitgym_loader.open_memmap(tier, "train"),
1570
+ self.exploitgym_loader.open_memmap(tier, "val"),
1571
+ self.exploitgym_loader.open_memmap(tier, "holdout"),
1572
+ )
1573
+ loader = self.topogpt3_loader if self.topogpt3_loader is not None else self.loader
1574
+ return (
1575
+ loader.open_memmap(tier, "train"),
1576
+ loader.open_memmap(tier, "val"),
1577
+ loader.open_memmap(tier, "holdout"),
1578
+ )
1579
+
1580
+ # ------------------------------------------------------------------
1581
+ # Eval
1582
+ # ------------------------------------------------------------------
1583
+
1584
+ @torch.no_grad()
1585
+ def _evaluate(self, dl: torch.utils.data.DataLoader) -> Tuple[float, float, float]:
1586
+ """Devuelve (avg_loss, perplexity, token_accuracy)."""
1587
+ self.model.eval()
1588
+ total_loss = 0.0
1589
+ n_batches = 0
1590
+ n_correct = 0
1591
+ n_tokens = 0
1592
+ for i, (bx, by) in enumerate(dl):
1593
+ if i >= self.config.MAX_EVAL_BATCHES:
1594
+ break
1595
+ bx = bx.to(self.config.DEVICE, non_blocking=True)
1596
+ by = by.to(self.config.DEVICE, non_blocking=True)
1597
+ with torch.amp.autocast(
1598
+ device_type=self.config.DEVICE.split(":")[0],
1599
+ dtype=self.amp_dtype,
1600
+ enabled=self.config.USE_AMP,
1601
+ ):
1602
+ logits, _, _ = self.model(bx)
1603
+ loss = F.cross_entropy(
1604
+ logits.view(-1, self.base_cfg.VOCAB_SIZE),
1605
+ by.view(-1),
1606
+ )
1607
+ total_loss += float(loss.item())
1608
+ preds = logits.argmax(dim=-1)
1609
+ n_correct += int((preds == by).sum().item())
1610
+ n_tokens += int(by.numel())
1611
+ n_batches += 1
1612
+ self.model.train()
1613
+ if n_batches == 0:
1614
+ return float("inf"), float("inf"), 0.0
1615
+ avg = total_loss / n_batches
1616
+ acc = n_correct / max(n_tokens, 1)
1617
+ return avg, math.exp(min(avg, 20.0)), acc
1618
+
1619
+ # ------------------------------------------------------------------
1620
+ # Estado / checkpoint
1621
+ # ------------------------------------------------------------------
1622
+
1623
+ def _state_dict(self) -> Dict[str, Any]:
1624
+ return {
1625
+ "global_step": self.global_step,
1626
+ "completed_tiers": self.completed_tiers,
1627
+ "tier_metrics": self.tier_metrics,
1628
+ "config": asdict(self.config),
1629
+ "saved_at": datetime.utcnow().isoformat(),
1630
+ }
1631
+
1632
+ # ------------------------------------------------------------------
1633
+ # Loops publicos
1634
+ # ------------------------------------------------------------------
1635
+
1636
+ def run(self) -> None:
1637
+ if self.start_tier_explicit:
1638
+ # El usuario fija el tier inicial: se honra tal cual y se recortan
1639
+ # los completed_tiers a [0, start_tier) para que el rerun no quede
1640
+ # marcado como "ya hecho" en el state.json del checkpoint.
1641
+ effective_start = self.start_tier
1642
+ allowed = set(self.config.TIER_NAMES[:effective_start])
1643
+ dropped = [t for t in self.completed_tiers if t not in allowed]
1644
+ if dropped:
1645
+ self.logger.info(
1646
+ f"--start-tier={effective_start} explicito: "
1647
+ f"se reentrenan tiers previamente completados: {dropped}"
1648
+ )
1649
+ self.completed_tiers = [t for t in self.completed_tiers if t in allowed]
1650
+ else:
1651
+ # Comportamiento por defecto: si hubo auto-resume, avanza
1652
+ # start_tier mas alla de los tiers ya completados.
1653
+ effective_start = self.start_tier
1654
+ for i, name in enumerate(self.config.TIER_NAMES):
1655
+ if name in self.completed_tiers and i == effective_start:
1656
+ effective_start = i + 1
1657
+
1658
+ self.logger.info("=" * 70)
1659
+ self.logger.info("TopoGPT3 - curriculum sobre Grassmanniana")
1660
+ p = self.model.count_params()
1661
+ self.logger.info(f"Parametros: {p['total']:,}")
1662
+ self.logger.info(f"Device: {self.config.DEVICE} | AMP: {self.config.USE_AMP}")
1663
+ if self.completed_tiers:
1664
+ self.logger.info(f"Tiers ya completados: {self.completed_tiers}")
1665
+ self.logger.info(
1666
+ f"Tiers a entrenar: {list(self.config.TIER_NAMES[effective_start:])}"
1667
+ )
1668
+ self.logger.info("=" * 70)
1669
+
1670
+ # Snapshot inicial geometrico (referencia U_ref)
1671
+ snap0 = self.tracker.snapshot(self.model, step=0)
1672
+ self.logger.info("[grass init] " + self.tracker.format_log(snap0))
1673
+
1674
+ for tier_index in range(effective_start, len(self.config.TIER_NAMES)):
1675
+ res = self._train_one_tier(tier_index)
1676
+ tier = self.config.TIER_NAMES[tier_index]
1677
+ if res is None:
1678
+ self.logger.warning(f"[{tier}] tier saltado por falta de datos.")
1679
+ continue
1680
+ if tier not in self.completed_tiers:
1681
+ self.completed_tiers.append(tier)
1682
+ self.tier_metrics[tier] = res
1683
+
1684
+ # Generalizacion combinada: concatenamos todos los holdouts
1685
+ self._eval_combined_holdout()
1686
+ self.tracker.save(str(Path(self.config.CHECKPOINT_DIR) / "grass_history.jsonl"))
1687
+ with open(Path(self.config.CHECKPOINT_DIR) / self.config.LOG_FILE, "w") as f:
1688
+ json.dump({
1689
+ "tier_metrics": self.tier_metrics,
1690
+ "global_step": self.global_step,
1691
+ }, f, indent=2)
1692
+
1693
+ def _eval_combined_holdout(self) -> None:
1694
+ self.logger.info("=" * 70)
1695
+ self.logger.info("EVALUACION FINAL: holdout combinado (datos NUNCA vistos)")
1696
+ results: Dict[str, Tuple[float, float, float]] = {}
1697
+ for i, tier in enumerate(self.config.TIER_NAMES):
1698
+ try:
1699
+ if self.exploitgym_loader is not None:
1700
+ hold = self.exploitgym_loader.open_memmap(tier, "holdout")
1701
+ else:
1702
+ hold = self.loader.open_memmap(tier, "holdout")
1703
+ except (FileNotFoundError, ValueError):
1704
+ continue
1705
+ seq_len = self.config.TIER_SEQ_LEN[i]
1706
+ ds = BlockTokenDataset(hold, seq_len)
1707
+ if len(ds) == 0:
1708
+ continue
1709
+ dl = torch.utils.data.DataLoader(
1710
+ ds, batch_size=self.config.BATCH_SIZE,
1711
+ shuffle=False, drop_last=True, num_workers=0,
1712
+ )
1713
+ loss, ppl, acc = self._evaluate(dl)
1714
+ results[tier] = (loss, ppl, acc)
1715
+ self.logger.info(
1716
+ f" {tier:<20s} loss={loss:.4f} acc={acc*100:.2f}% ppl={ppl:.2f}"
1717
+ )
1718
+ self.tier_metrics["__combined_holdout__"] = {
1719
+ t: {"loss": l, "ppl": p, "acc": a} for t, (l, p, a) in results.items()
1720
+ }
1721
+ self.logger.info("=" * 70)
1722
+
1723
+
1724
+ # ============================================================================
1725
+ # CLI
1726
+ # ============================================================================
1727
+
1728
+ def parse_args() -> argparse.Namespace:
1729
+ p = argparse.ArgumentParser(description="TopoGPT3/TopoExploit training")
1730
+ p.add_argument("--prepare-data", action="store_true",
1731
+ help="Descarga y tokeniza los 4 datasets de codigo.")
1732
+ p.add_argument("--prepare-exploitgym", action="store_true",
1733
+ help="Descarga y tokeniza los datos de ExploitGym.")
1734
+ p.add_argument("--train", action="store_true",
1735
+ help="Entrena el pipeline curricular completo.")
1736
+ p.add_argument("--eval-holdout", action="store_true",
1737
+ help="Evalua solo en los holdouts (datos nunca vistos).")
1738
+ p.add_argument("--exploitgym", action="store_true",
1739
+ help="Usar configuracion TopoExploit (122M, ExploitGym data).")
1740
+ p.add_argument("--merged", action="store_true",
1741
+ help="Usar configuracion TopoMerged (6-tier: 3 code + 3 exploit).")
1742
+ p.add_argument("--start-tier", type=int, default=None,
1743
+ help="Tier inicial explicito.")
1744
+ p.add_argument("--scale", type=str, default=None,
1745
+ choices=["micro", "small", "medium", "large", "xlarge", "gpt2"],
1746
+ help="Escala del modelo TopoGPT2 base (default: small, o 'large' si --exploitgym, 'xlarge' si --merged).")
1747
+ p.add_argument("--resume", action="store_true",
1748
+ help="(legacy) Equivalente al comportamiento por defecto.")
1749
+ p.add_argument("--from-scratch", action="store_true",
1750
+ help="Ignora el checkpoint existente y arranca desde cero.")
1751
+ p.add_argument("--force-prepare", action="store_true",
1752
+ help="Fuerza re-tokenizacion ignorando cache.")
1753
+ p.add_argument("--device", type=str, default=None,
1754
+ help="Override de device (cuda|cpu|cuda:0|...)")
1755
+ p.add_argument("--ewc-lambda", type=float, default=None,
1756
+ help="Override de EWC_LAMBDA (fuerza de penalizacion EWC).")
1757
+ p.add_argument("--replay-ratio", type=float, default=None,
1758
+ help="Override de REPLAY_RATIO (fraccion de replay en batch).")
1759
+ p.add_argument("--fisher-batches", type=int, default=None,
1760
+ help="Override de EWC_FISHER_BATCHES (batches para estimar Fisher).")
1761
+ return p.parse_args()
1762
+
1763
+
1764
+ def main() -> None:
1765
+ args = parse_args()
1766
+
1767
+ if args.exploitgym:
1768
+ cfg_exploit = TopoExploitConfig()
1769
+ if args.device:
1770
+ cfg_exploit.DEVICE = args.device
1771
+ if args.scale:
1772
+ cfg_exploit.SCALE = args.scale
1773
+ cfg = TopoGPT3Config(SCALE=cfg_exploit.SCALE)
1774
+ cfg.DEVICE = cfg_exploit.DEVICE
1775
+ cfg.USE_AMP = cfg_exploit.USE_AMP
1776
+ cfg.GRADIENT_CHECKPOINTING = cfg_exploit.GRADIENT_CHECKPOINTING
1777
+ cfg.BATCH_SIZE = cfg_exploit.BATCH_SIZE
1778
+ cfg.GRAD_ACCUM_STEPS = cfg_exploit.GRAD_ACCUM_STEPS
1779
+ cfg.LEARNING_RATE = cfg_exploit.LEARNING_RATE
1780
+ cfg.WEIGHT_DECAY = cfg_exploit.WEIGHT_DECAY
1781
+ cfg.GRADIENT_CLIP_NORM = cfg_exploit.GRADIENT_CLIP_NORM
1782
+ cfg.WARMUP_RATIO = cfg_exploit.WARMUP_RATIO
1783
+ cfg.TIER_EPOCHS = cfg_exploit.TIER_EPOCHS
1784
+ cfg.TIER_SEQ_LEN = cfg_exploit.TIER_SEQ_LEN
1785
+ cfg.TIER_NAMES = cfg_exploit.TIER_NAMES
1786
+ cfg.PROGRESSIVE_WINDOW = cfg_exploit.PROGRESSIVE_WINDOW
1787
+ cfg.CHECKPOINT_DIR = cfg_exploit.CHECKPOINT_DIR
1788
+ cfg.LOG_FILE = cfg_exploit.LOG_FILE
1789
+ cfg.GRASS_TRACK_EVERY = cfg_exploit.GRASS_TRACK_EVERY
1790
+ cfg.USE_GAUSS_COMPLEX_MULT = cfg_exploit.USE_GAUSS_COMPLEX_MULT
1791
+ cfg.DATA_CACHE = cfg_exploit.DATA_CACHE
1792
+ cfg.USE_EWC = cfg_exploit.USE_EWC
1793
+ cfg.EWC_LAMBDA = cfg_exploit.EWC_LAMBDA
1794
+ cfg.EWC_FISHER_BATCHES = cfg_exploit.EWC_FISHER_BATCHES
1795
+ cfg.USE_REPLAY = cfg_exploit.USE_REPLAY
1796
+ cfg.REPLAY_RATIO = cfg_exploit.REPLAY_RATIO
1797
+ cfg.REPLAY_MAX_TIERS = cfg_exploit.REPLAY_MAX_TIERS
1798
+ cfg.MAX_TRAIN_BATCHES_PER_EPOCH = cfg_exploit.MAX_TRAIN_BATCHES_PER_EPOCH
1799
+ # Overrides CLI opcionales
1800
+ if args.ewc_lambda is not None:
1801
+ cfg.EWC_LAMBDA = args.ewc_lambda
1802
+ if args.replay_ratio is not None:
1803
+ cfg.REPLAY_RATIO = args.replay_ratio
1804
+ if args.fisher_batches is not None:
1805
+ cfg.EWC_FISHER_BATCHES = args.fisher_batches
1806
+ exploitgym_cfg = cfg_exploit
1807
+ merged_cfg = None
1808
+ elif args.merged:
1809
+ cfg_merged = TopoMergedConfig()
1810
+ if args.device:
1811
+ cfg_merged.DEVICE = args.device
1812
+ if args.scale:
1813
+ cfg_merged.SCALE = args.scale
1814
+ cfg = TopoGPT3Config(SCALE=cfg_merged.SCALE)
1815
+ cfg.DEVICE = cfg_merged.DEVICE
1816
+ cfg.USE_AMP = cfg_merged.USE_AMP
1817
+ cfg.GRADIENT_CHECKPOINTING = cfg_merged.GRADIENT_CHECKPOINTING
1818
+ cfg.BATCH_SIZE = cfg_merged.BATCH_SIZE
1819
+ cfg.GRAD_ACCUM_STEPS = cfg_merged.GRAD_ACCUM_STEPS
1820
+ cfg.LEARNING_RATE = cfg_merged.LEARNING_RATE
1821
+ cfg.WEIGHT_DECAY = cfg_merged.WEIGHT_DECAY
1822
+ cfg.GRADIENT_CLIP_NORM = cfg_merged.GRADIENT_CLIP_NORM
1823
+ cfg.WARMUP_RATIO = cfg_merged.WARMUP_RATIO
1824
+ cfg.TIER_EPOCHS = cfg_merged.TIER_EPOCHS
1825
+ cfg.TIER_SEQ_LEN = cfg_merged.TIER_SEQ_LEN
1826
+ cfg.TIER_NAMES = cfg_merged.TIER_NAMES
1827
+ cfg.PROGRESSIVE_WINDOW = cfg_merged.PROGRESSIVE_WINDOW
1828
+ cfg.CHECKPOINT_DIR = cfg_merged.CHECKPOINT_DIR
1829
+ cfg.LOG_FILE = cfg_merged.LOG_FILE
1830
+ cfg.GRASS_TRACK_EVERY = cfg_merged.GRASS_TRACK_EVERY
1831
+ cfg.USE_GAUSS_COMPLEX_MULT = cfg_merged.USE_GAUSS_COMPLEX_MULT
1832
+ cfg.DATA_CACHE = cfg_merged.DATA_CACHE_TOPOGPT3
1833
+ cfg.USE_EWC = cfg_merged.USE_EWC
1834
+ cfg.EWC_LAMBDA = cfg_merged.EWC_LAMBDA
1835
+ cfg.EWC_FISHER_BATCHES = cfg_merged.EWC_FISHER_BATCHES
1836
+ cfg.USE_REPLAY = cfg_merged.USE_REPLAY
1837
+ cfg.REPLAY_RATIO = cfg_merged.REPLAY_RATIO
1838
+ cfg.REPLAY_MAX_TIERS = cfg_merged.REPLAY_MAX_TIERS
1839
+ cfg.MAX_TRAIN_BATCHES_PER_EPOCH = cfg_merged.MAX_TRAIN_BATCHES_PER_EPOCH
1840
+ if args.ewc_lambda is not None:
1841
+ cfg.EWC_LAMBDA = args.ewc_lambda
1842
+ if args.replay_ratio is not None:
1843
+ cfg.REPLAY_RATIO = args.replay_ratio
1844
+ if args.fisher_batches is not None:
1845
+ cfg.EWC_FISHER_BATCHES = args.fisher_batches
1846
+ exploitgym_cfg = None
1847
+ merged_cfg = cfg_merged
1848
+ else:
1849
+ cfg = TopoGPT3Config(SCALE=args.scale or "small")
1850
+ if args.device:
1851
+ cfg.DEVICE = args.device
1852
+ exploitgym_cfg = None
1853
+ merged_cfg = None
1854
+
1855
+ if not (args.prepare_data or args.prepare_exploitgym or args.train or args.eval_holdout):
1856
+ print("Nada que hacer. Pasa --prepare-data, --prepare-exploitgym, --train o --eval-holdout.",
1857
+ file=sys.stderr)
1858
+ sys.exit(1)
1859
+
1860
+ exploitgym_loader = None
1861
+ topogpt3_loader = None
1862
+ if merged_cfg is not None:
1863
+ # Merged mode: set up both loaders
1864
+ _tmp_tokenizer = BPETokenizer()
1865
+ _tmp_logger = setup_logger("MergedLoader", "INFO")
1866
+ # TopoGPT3 code loader (tiers 0-2)
1867
+ topogpt3_loader = CodeCurriculumLoader(cfg, _tmp_tokenizer, _tmp_logger)
1868
+ # ExploitGym loader (tiers 3-5)
1869
+ egym_loader_cfg = merged_cfg.build_exploit_loader_config()
1870
+ exploitgym_loader = ExploitGymDataLoader(egym_loader_cfg, _tmp_tokenizer, _tmp_logger)
1871
+ elif exploitgym_cfg is not None:
1872
+ loader_cfg = exploitgym_cfg.build_loader_config()
1873
+ _tmp_tokenizer = BPETokenizer()
1874
+ _tmp_logger = setup_logger("ExploitGymLoader", "INFO")
1875
+ exploitgym_loader = ExploitGymDataLoader(loader_cfg, _tmp_tokenizer, _tmp_logger)
1876
+
1877
+ trainer = TopoGPT3Trainer(cfg, start_tier=args.start_tier,
1878
+ exploitgym_loader=exploitgym_loader,
1879
+ topogpt3_loader=topogpt3_loader,
1880
+ merged_config=merged_cfg)
1881
+
1882
+ if args.prepare_exploitgym and exploitgym_cfg is not None:
1883
+ loader_cfg = exploitgym_cfg.build_loader_config()
1884
+ egym_loader = ExploitGymDataLoader(loader_cfg, trainer.tokenizer, trainer.logger)
1885
+ for i in range(len(exploitgym_cfg.TIER_NAMES)):
1886
+ try:
1887
+ trainer.logger.info(
1888
+ f"=== preparing exploitgym tier {i+1}/{len(exploitgym_cfg.TIER_NAMES)} ==="
1889
+ )
1890
+ egym_loader.prepare_tier(i, force=args.force_prepare)
1891
+ except RuntimeError as exc:
1892
+ trainer.logger.error(f"ExploitGym tier {i} failed: {exc}")
1893
+ return
1894
+
1895
+ if args.prepare_data:
1896
+ trainer.prepare_all(force=args.force_prepare)
1897
+
1898
+ ckpt_exists = (Path(cfg.CHECKPOINT_DIR) / "last" / "model.safetensors").exists()
1899
+ legacy_exists = any(
1900
+ (Path(cfg.CHECKPOINT_DIR) / d.name / "model.safetensors").exists()
1901
+ for d in Path(cfg.CHECKPOINT_DIR).iterdir()
1902
+ if d.is_dir() and d.name.startswith("step_")
1903
+ ) if Path(cfg.CHECKPOINT_DIR).exists() else False
1904
+
1905
+ should_resume = (not args.from_scratch) and (ckpt_exists or legacy_exists)
1906
+ if args.from_scratch and (ckpt_exists or legacy_exists):
1907
+ trainer.logger.info(
1908
+ "--from-scratch: ignorando checkpoints existentes y arrancando random."
1909
+ )
1910
+ if exploitgym_cfg is not None:
1911
+ transferred_ckpt = (Path(exploitgym_cfg.CHECKPOINT_DIR) / "last" / "model.safetensors")
1912
+ if transferred_ckpt.exists():
1913
+ trainer.logger.info(
1914
+ f"Cargando pesos transferidos desde {transferred_ckpt}..."
1915
+ )
1916
+ from safetensors.torch import load_file as st_load
1917
+ sd = st_load(str(transferred_ckpt))
1918
+ trainer.model.load_state_dict(sd, strict=False)
1919
+ trainer.logger.info("Pesos transferidos cargados correctamente.")
1920
+
1921
+ if should_resume:
1922
+ state = trainer.ckpt.load_latest(trainer.model, trainer.optimizer)
1923
+ if state is not None:
1924
+ trainer.global_step = state.get("global_step", 0)
1925
+ trainer.completed_tiers = state.get("completed_tiers", [])
1926
+ trainer.logger.info(
1927
+ f"Auto-resume desde step={trainer.global_step} "
1928
+ f"completed_tiers={trainer.completed_tiers}"
1929
+ )
1930
+
1931
+ if args.train:
1932
+ trainer.run()
1933
+
1934
+ if args.eval_holdout:
1935
+ trainer._eval_combined_holdout()
1936
+
1937
+
1938
+ if __name__ == "__main__":
1939
+ main()