diff --git a/.cursorrules b/.cursorrules
new file mode 100644
index 0000000000000000000000000000000000000000..987878f1ae296c0b1b4462dc8e9bc3ffddaf9ae6
--- /dev/null
+++ b/.cursorrules
@@ -0,0 +1,226 @@
+# PAMPAr-Coder — AI Instructions
+
+> Consolidación de instrucciones para Cursor/Copilot en el proyecto PAMPAr-Coder.
+> "El Linux de la IA" - LLM de código con arquitectura cerebral.
+
+---
+
+## Proyecto
+
+**PAMPAr-Coder** es un LLM de código 1.5B-3B con arquitectura inspirada en las 52 zonas de Brodmann.
+
+### Quick Reference
+
+| Area | Convention |
+|------|------------|
+| Language | Python 3.13+ |
+| Framework | PyTorch 2.x |
+| Tokenizer | SentencePiece BPE — 16K vocab (modelo activo) |
+| Testing | pytest (134 tests) |
+| Type hints | Siempre requeridos |
+| Docstrings | Google style |
+| Training | local — GPU 4GB VRAM (NO cloud, NO RunPod) |
+
+---
+
+## Arquitectura Cerebral
+
+```
+Input → Embedding → [BloqueTerrritorial ×N] → LM Head → Output
+ ↓
+ TálamoBrodmann (LLAVES 80% + Atención 20%)
+ + Conv1D causal (ventana 32 tokens)
+ ↓
+ ┌─────────────────────┴─────────────────────┐
+ ▼ ▼
+┌───────────────┐ ┌───────────────┐
+│ SINTAXIS │◄── simbiosis ────►│ SEMÁNTICA │
+│ Zonas 1-15 │ │ Zonas 16-30 │
+└───────────────┘ └───────────────┘
+ ▼ ▼
+┌───────────────┐ ┌───────────────┐
+│ LÓGICO │◄── simbiosis ────►│ ESTRUCTURAL │
+│ Zonas 31-42 │ │ Zonas 43-52 │
+└───────────────┘ └───────────────┘
+```
+
+### Sistema LLAVES (80% del peso)
+
+Las LLAVES son **patrones regex que clasifican tokens en zonas de Brodmann**:
+
+```python
+# CRÍTICO: LLAVES son regex, NUNCA entrenables
+LLAVES = {
+ 'B06_KEYWORDS_IMPORT': ['import', 'from', 'require'],
+ 'B17_LITERAL_STRING': [r'".*"', r"'.*'", 'f"'],
+ 'B35_IDENTIFICADOR_VAR': [r'[a-z_][a-z0-9_]*'],
+ # ... 52 zonas total
+}
+# INT8 cuantizado (256 niveles, <0.4% error)
+```
+
+### 4 Territorios
+
+| Territorio | Zonas | Responsabilidad |
+| ------------ | ------- | ------------------------- |
+| SINTAXIS | 1-15 | Estructura, indentación |
+| SEMÁNTICA | 16-30 | Significado, contexto |
+| LÓGICO | 31-42 | Flujo, condiciones |
+| ESTRUCTURAL | 43-52 | Arquitectura, módulos |
+
+---
+
+## Estructura del Proyecto
+
+```
+PAMPAr-Coder/
+├── pampar/coder/v2/
+│ ├── modelo.py # PampaRCoderV2 principal
+│ ├── config.py # ConfigV2 + presets (PRESET_4GB, PRESET_8GB)
+│ ├── talamo.py # TálamoBrodmann orquestador
+│ ├── llaves.py # LLAVES INT8 lookup tables
+│ ├── zonas.py # 52 zonas de Brodmann
+│ ├── bloques.py # BloqueTerritorial + FFN simbiótico
+│ └── aprendizaje/ # Subsistemas de aprendizaje
+├── biblioteca/ # 39 temas de Python (~140 MB), lista para entrenar
+├── data/ # Datos y tokenizer
+├── checkpoints/ # pampar_v2_best.pt (42M params, vocab 16K)
+├── scripts/ # Scripts utilidad (aprender_solo.py, etc.)
+└── tests/ # 134 tests pytest
+```
+
+---
+
+## Reglas Críticas
+
+1. **LLAVES son regex** — NUNCA entrenarlas
+2. **Territorios en paralelo** — combinan via soporte simbiótico
+3. **INT8 para LLAVES** — lookup tables cuantizadas (256 niveles)
+4. **Pesos en FP16/BF16** — nunca INT8
+5. **Gradient checkpointing** — obligatorio para >500M params
+6. **vocab_size = 16000** — debe coincidir con el tokenizer activo (`code_tokenizer.model`)
+7. **Ventana contexto = 32** — convolución causal, pad izquierdo
+
+---
+
+## Convenciones de Código
+
+### Naming
+
+| Tipo | Convención | Ejemplo |
+|------|------------|---------|
+| Dominio | Español | `Talamo`, `MemoriaErrores` |
+| ML estándar | Inglés | `forward`, `hidden_states` |
+| Config | `Config` + nombre | `ConfigPampaRCoderV2` |
+| Presets | `PRESET_` + capacidad | `PRESET_4GB` |
+
+### Type Hints (OBLIGATORIO)
+
+```python
+def forward(
+ self,
+ input_ids: Tensor,
+ attention_mask: Optional[Tensor] = None,
+ labels: Optional[Tensor] = None,
+) -> Tuple[Tensor, Optional[Tensor]]:
+ """Forward pass del modelo."""
+ ...
+```
+
+### Docstrings (Google Style)
+
+```python
+class PampaRCoderV2(nn.Module):
+ """
+ Modelo principal PAMPAr-Coder V2.
+
+ Attributes:
+ config: Configuración del modelo.
+ embedding: Capa de embedding.
+
+ Example:
+ >>> model = crear_modelo(PRESET_4GB)
+ """
+```
+
+---
+
+## Patrones PyTorch
+
+### Training Loop
+
+```python
+from torch.cuda.amp import autocast, GradScaler
+
+scaler = GradScaler()
+for batch in dataloader:
+ with autocast(dtype=torch.bfloat16):
+ loss = model(**batch).loss / accumulation_steps
+
+ scaler.scale(loss).backward()
+
+ if (step + 1) % accumulation_steps == 0:
+ scaler.unscale_(optimizer)
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
+ scaler.step(optimizer)
+ scaler.update()
+ optimizer.zero_grad()
+```
+
+### Checkpoint
+
+```python
+torch.save({
+ "model_state_dict": model.state_dict(),
+ "optimizer_state_dict": optimizer.state_dict(),
+ "epoch": epoch,
+ "loss": loss,
+}, "checkpoint.pt")
+```
+
+---
+
+## Testing (pytest)
+
+```python
+@pytest.fixture
+def small_config():
+ return ConfigPampaRCoderV2(
+ vocab_size=1000, hidden_size=64, num_layers=2
+ )
+
+class TestPampaRCoderV2:
+ def test_forward_shape(self, small_config):
+ model = PampaRCoderV2(small_config)
+ input_ids = torch.randint(0, 1000, (2, 16))
+ output = model(input_ids)
+ assert output.logits.shape == (2, 16, 1000)
+```
+
+---
+
+## Git Workflow
+
+```bash
+# Conventional commits
+feat(model): add early exit mechanism
+fix(llaves): correct zona classification for imports
+test(talamo): add gradient flow tests
+refactor(bloques): extract symbiotic support
+
+# Branch naming
+feat/early-exit
+fix/llaves-imports
+```
+
+---
+
+## Anti-patterns (NUNCA)
+
+- `any` en type hints → usar `Union`, `Optional`, etc.
+- LLAVES entrenables → son regex, siempre fijos
+- Cuantizar gradientes → solo LLAVES lookup
+- Archivos >300 líneas → dividir en módulos
+- Tests sin fixture → usar `small_config`
+- `print()` en producción → usar `logging`
+- Secrets en código → usar variables de entorno
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000000000000000000000000000000000000..b06fe3f89ac99d7b505178060a9f1b3ad96b2193
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,4 @@
+# API keys for Classroom mentor system (optional)
+OPENROUTER_API_KEY=sk-or-v1-your-key-here
+GITHUB_TOKEN=ghp_your-token-here
+QWEN_API_KEY=sk-your-dashscope-key-here
diff --git a/.gitattributes b/.gitattributes
index a6344aac8c09253b3b630fb776ae94478aa0275b..61c4ba969ebb5e0c4f8b1986b569c410137bdb47 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.zst filter=lfs diff=lfs merge=lfs -text
*tfevents* filter=lfs diff=lfs merge=lfs -text
+logo-pampar-color.png filter=lfs diff=lfs merge=lfs -text
+logo-pampar-sf.png filter=lfs diff=lfs merge=lfs -text
+logo-pampar.png filter=lfs diff=lfs merge=lfs -text
+PAMPAR-coder.png filter=lfs diff=lfs merge=lfs -text
+PAMPArLLM.png filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000000000000000000000000000000000000..57673aae64663e6bb90c7f854b5a82e4d726c378
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,2 @@
+# Repository owner — all PRs require review
+* @lucasmella-stack
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000000000000000000000000000000000000..7335ceee5c8eeb1bbc22e491f4d41cb94318fedf
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,33 @@
+---
+name: Bug Report
+about: Report a bug in PAMPAr-Coder
+title: "[BUG] "
+labels: bug
+assignees: lucasmella-stack
+---
+
+## Description
+
+
+
+## Steps to Reproduce
+
+1.
+2.
+3.
+
+## Expected Behavior
+
+
+
+## Actual Behavior
+
+
+
+## Environment
+
+- OS:
+- Python version:
+- PyTorch version:
+- GPU:
+- Checkpoint used:
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000000000000000000000000000000000000..64b6fbb1045077013dedd0c9d582d9271ac008b1
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,23 @@
+---
+name: Feature Request
+about: Suggest an idea for PAMPAr-Coder
+title: "[FEATURE] "
+labels: enhancement
+assignees: ""
+---
+
+## Problem
+
+
+
+## Proposed Solution
+
+
+
+## Alternatives Considered
+
+
+
+## Additional Context
+
+
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000000000000000000000000000000000000..951341544078508db2aa44ba28dd6ee3deb5dacb
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,82 @@
+# PAMPAr-Coder - Copilot Instructions
+
+> Instrucciones específicas para este proyecto. Se combinan con tu perfil global.
+
+## Proyecto
+
+**PAMPAr-Coder** es un LLM de código 1.5B con arquitectura cerebral inspirada en las 52 zonas de Brodmann.
+"El Linux de la IA" - Hacer más con menos hardware.
+
+## Arquitectura
+
+```
+Input → Embedding → [BloqueTerrritorial ×N] → LM Head → Output
+ ↓
+ TálamoBrodmann (LLAVES 80% + Atención 20%)
+ + Conv1D causal (ventana 32 tokens)
+ ↓
+ ┌─────────────────────┴─────────────────────┐
+ ▼ ▼
+┌───────────────┐ ┌───────────────┐
+│ SINTAXIS │◄── simbiosis ────►│ SEMÁNTICA │
+│ Zonas 1-15 │ │ Zonas 16-30 │
+└───────────────┘ └───────────────┘
+ ▼ ▼
+┌───────────────┐ ┌───────────────┐
+│ LÓGICO │◄── simbiosis ────►│ ESTRUCTURAL │
+│ Zonas 31-42 │ │ Zonas 43-52 │
+└───────────────┘ └───────────────┘
+```
+
+## Componentes Clave
+
+| Archivo | Propósito |
+|---------|-----------|
+| `pampar/coder/v2/modelo.py` | PampaRCoderV2 con 52 zonas |
+| `pampar/coder/v2/config.py` | ConfigPampaRCoderV2 + presets |
+| `pampar/coder/v2/talamo.py` | Tálamo orquestador con LLAVES + context conv |
+| `pampar/coder/v2/llaves.py` | LLAVES lookup tables (INT8, 256 niveles) |
+| `pampar/coder/v2/bloques.py` | BloqueTerrritorial + relaciones simbióticas |
+| `pampar/coder/v2/zonas.py` | Definición de las 52 zonas de Brodmann |
+| `pampar/coder/v2/aprendizaje/` | Metacognición, neuroplasticidad, memoria errores |
+| `cloud/runpod/train_cloud.py` | Script de entrenamiento en cloud |
+
+## Convenciones
+
+- **Idioma código**: Inglés
+- **Comentarios/docs**: Español o Inglés según contexto
+- **Nombres de clases**: Español para conceptos de dominio (`Talamo`, `Territorio`, `Zona`, `MemoriaErrores`)
+- **Variables**: Inglés (`input_ids`, `hidden_states`)
+
+## Stack
+
+- PyTorch 2.x
+- SentencePiece (tokenizer BPE, 48K vocab)
+- Hugging Face datasets
+- RunPod/Cloud para entrenamiento
+
+## Comandos frecuentes
+
+```bash
+# Entrenar localmente
+python scripts/train.py --config 1.5B --epochs 10
+
+# Entrenar en cloud (RunPod)
+ssh root@IP -p PORT
+cd /workspace/PAMPAr-Coder
+screen -S train
+python3 cloud/runpod/train_cloud.py --config 1_5B > training.log 2>&1
+
+# Ver progreso
+tail -f training.log
+```
+
+## Reglas específicas
+
+1. **LLAVES** son patrones regex que clasifican tokens - NUNCA usar ML para esto
+2. **Territorios** procesan en paralelo, luego combinan via soporte simbiótico
+3. **Cuantización INT8** (256 niveles) para LLAVES lookup tables
+4. **Early Exit** usa percentil 10 per-token (no promedio global)
+5. **Gradient checkpointing** siempre activo para modelos >500M params
+6. **Tests** en `tests/` con pytest
+7. **Ventana de contexto** (32 tokens) usa convolución causal - pad izquierdo
diff --git a/.github/instructions/cloud-training.instructions.md b/.github/instructions/cloud-training.instructions.md
new file mode 100644
index 0000000000000000000000000000000000000000..f650307b49812a99da04a563d48d3edc95c042a3
--- /dev/null
+++ b/.github/instructions/cloud-training.instructions.md
@@ -0,0 +1,91 @@
+# Cloud Training Instructions
+
+> Entrenamiento de PAMPAr-Coder en RunPod y otros providers.
+
+## RunPod Setup
+
+### Conectar
+```bash
+ssh root@IP -p PORT
+# Password: en RunPod dashboard o usar SSH key
+```
+
+### Preparar entorno
+```bash
+cd /workspace/PAMPAr-Coder
+pip install sentencepiece tqdm datasets
+```
+
+### Lanzar entrenamiento
+```bash
+# Background con log
+nohup python3 cloud/runpod/train_cloud.py \
+ --config 3B \
+ --data-dir data/distillation \
+ --tokenizer data/tokenizer/code_bpe.model \
+ --epochs 10 \
+ --no-wandb \
+ > training.log 2>&1 &
+
+# Monitorear
+tail -f training.log
+nvidia-smi -l 5 # GPU cada 5 segundos
+```
+
+## Configuraciones
+
+| Config | Params | VRAM | GPU recomendada |
+|--------|--------|------|-----------------|
+| 1.5B | ~230M | 8GB | RTX 3090, A10 |
+| 3B | ~3B | 24GB | A40, A100 |
+
+### Ajustar config
+```python
+# cloud/runpod/config_3b.py
+@dataclass
+class Config3B:
+ vocab_size: int = 32000
+ dim: int = 2560
+ n_heads: int = 20
+ n_capas: int = 32
+ max_seq_len: int = 2048
+ batch_size: int = 4
+ gradient_accumulation: int = 16
+```
+
+## Troubleshooting
+
+### OOM en GPU
+1. Reducir `batch_size`
+2. Reducir `max_seq_len`
+3. Activar `use_gradient_checkpointing = True`
+
+### OOM en RAM (sistema)
+1. Usar streaming dataset
+2. Reducir workers de DataLoader
+3. Modelo se carga en CPU antes de GPU - reducir tamaño
+
+### Tokens fuera de rango
+- Asegurar `vocab_size` en config == tokenizer.GetPieceSize()
+- Típico: tokenizer tiene 32K, config dice 16K → error
+
+## Checkpoints
+
+```bash
+# Ubicación
+/workspace/PAMPAr-Coder/checkpoints/
+├── best_model.pt # Mejor val_loss
+├── epoch_N.pt # Por epoch
+└── step_XXXX.pt # Por steps
+
+# Descargar a local
+scp -P PORT root@IP:/workspace/PAMPAr-Coder/checkpoints/best_model.pt ./
+```
+
+## Costos estimados
+
+| GPU | $/hora | 10 epochs (20K samples) |
+|-----|--------|------------------------|
+| A10 | $0.30 | ~$0.60 |
+| A40 | $0.40 | ~$0.80 |
+| A100 | $1.50 | ~$3.00 |
diff --git a/.github/instructions/pampar-architecture.instructions.md b/.github/instructions/pampar-architecture.instructions.md
new file mode 100644
index 0000000000000000000000000000000000000000..1af32ed26bdfcc5b70270130205c6484d59f1237
--- /dev/null
+++ b/.github/instructions/pampar-architecture.instructions.md
@@ -0,0 +1,59 @@
+# PAMPAr Architecture Instructions
+
+> Arquitectura cerebral con 52 zonas de Brodmann para procesamiento de código.
+
+## LLAVES System
+
+LLAVES = Lookup de Activación Via Expresiones Sintácticas
+
+```python
+# Las LLAVES clasifican tokens usando patrones regex, NO ML
+LLAVES = {
+ 'B06_KEYWORDS_IMPORT': ['import', 'from', 'require'],
+ 'B17_LITERAL_STRING': [r'".*"', r"'.*'", 'f"', "f'"],
+ 'B18_LITERAL_NUMERO': [r'\d+', r'\d+\.\d+'],
+ 'B35_IDENTIFICADOR_VAR': [r'[a-z_][a-z0-9_]*'],
+ # ... 52 zonas total
+}
+```
+
+## Territorios (4 Macro-Áreas)
+
+| Territorio | Zonas | Función |
+|------------|-------|---------|
+| SINTAXIS | B01-B13 | Keywords, operadores, delimitadores |
+| SEMÁNTICA | B14-B39 | Identificadores, literales, tipos |
+| LÓGICO | B40-B44 | Condicionales, loops, excepciones |
+| ESTRUCTURAL | B45-B52 | Patrones, estructuras, documentación |
+
+## Flujo de Procesamiento
+
+```
+1. Token → LLAVES lookup (O(1), 80% peso)
+2. Token → Embedding attention (30% peso)
+3. Combinar → Activación por zona
+4. Zonas activas → Procesamiento territorial
+5. Fusión → Output
+```
+
+## Cuantización
+
+Solo las lookup tables de LLAVES se cuantizan a INT4:
+- Reduce memoria de 6.5MB → 812KB
+- Sin pérdida de precisión (es lookup discreto)
+- El modelo (pesos) se mantiene en FP16/BF16
+
+## Early Exit
+
+El modelo puede salir temprano si la confianza es alta:
+```python
+if confianza > 0.90 and capa >= self.capas_minimas:
+ return x, True # Exit early
+```
+
+## Reglas de Implementación
+
+1. **NUNCA** usar backprop para entrenar LLAVES
+2. **SIEMPRE** procesar territorios en paralelo cuando sea posible
+3. **Cuantizar** solo tablas de lookup, nunca pesos del modelo
+4. **Registrar** tokenizer con `model.registrar_tokenizer(tokenizer)`
diff --git a/.github/instructions/python-ml.instructions.md b/.github/instructions/python-ml.instructions.md
new file mode 100644
index 0000000000000000000000000000000000000000..dff7fac1e51e4e3697db10f38bf4aebb583914d6
--- /dev/null
+++ b/.github/instructions/python-ml.instructions.md
@@ -0,0 +1,268 @@
+````instructions
+# Python ML/LLM Instructions
+
+> Para desarrollo de modelos de ML/LLM con PyTorch.
+
+## Type Hints (OBLIGATORIO)
+
+```python
+from typing import Optional, Tuple, Dict, List, Union, Literal
+from torch import Tensor
+import torch.nn as nn
+
+def forward(
+ self,
+ input_ids: Tensor,
+ attention_mask: Optional[Tensor] = None,
+ labels: Optional[Tensor] = None,
+) -> Tuple[Tensor, Optional[Tensor]]:
+ """
+ Forward pass del modelo.
+
+ Args:
+ input_ids: Token IDs, shape (batch, seq_len).
+ attention_mask: Máscara de atención, shape (batch, seq_len).
+ labels: Labels para calcular loss, shape (batch, seq_len).
+
+ Returns:
+ Tuple de (logits, loss). Loss es None si labels no se proporcionan.
+ """
+ ...
+````
+
+## Docstrings (Google Style)
+
+```python
+class PampaRCoderV2(nn.Module):
+ """
+ Modelo principal PAMPAr-Coder V2 con arquitectura cerebral.
+
+ Attributes:
+ config: Configuración del modelo.
+ embedding: Capa de embedding de tokens.
+ talamo: Orquestador central TálamoBrodmann.
+ territorios: Lista de 4 BloqueTerrritorial.
+
+ Example:
+ >>> config = ConfigPampaRCoderV2.from_preset("1.5B")
+ >>> model = PampaRCoderV2(config)
+ >>> output = model(input_ids)
+ """
+```
+
+## PyTorch Patterns
+
+### Model Definition
+
+```python
+class MiModulo(nn.Module):
+ def __init__(self, config: ConfigPampaRCoderV2):
+ super().__init__()
+ self.config = config
+ # Inicializar layers aquí
+
+ def forward(self, x: Tensor) -> Tensor:
+ # Forward pass
+ return x
+
+ def _init_weights(self, module: nn.Module) -> None:
+ """Inicialización de pesos."""
+ if isinstance(module, nn.Linear):
+ nn.init.normal_(module.weight, std=0.02)
+ if module.bias is not None:
+ nn.init.zeros_(module.bias)
+```
+
+### Training Loop
+
+```python
+from torch.cuda.amp import autocast, GradScaler
+from tqdm import tqdm
+
+def train_epoch(
+ model: nn.Module,
+ dataloader: DataLoader,
+ optimizer: Optimizer,
+ scheduler: LRScheduler,
+ scaler: GradScaler,
+ device: torch.device,
+ accumulation_steps: int = 4,
+) -> float:
+ """Entrena una época completa."""
+ model.train()
+ total_loss = 0.0
+
+ for step, batch in enumerate(tqdm(dataloader)):
+ batch = {k: v.to(device) for k, v in batch.items()}
+
+ with autocast(dtype=torch.bfloat16):
+ outputs = model(**batch)
+ loss = outputs.loss / accumulation_steps
+
+ scaler.scale(loss).backward()
+
+ if (step + 1) % accumulation_steps == 0:
+ scaler.unscale_(optimizer)
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
+ scaler.step(optimizer)
+ scaler.update()
+ scheduler.step()
+ optimizer.zero_grad()
+
+ total_loss += loss.item() * accumulation_steps
+
+ return total_loss / len(dataloader)
+```
+
+### Checkpoint Saving/Loading
+
+```python
+def save_checkpoint(
+ model: nn.Module,
+ optimizer: Optimizer,
+ scheduler: LRScheduler,
+ epoch: int,
+ loss: float,
+ path: str,
+) -> None:
+ """Guarda checkpoint completo."""
+ torch.save({
+ "model_state_dict": model.state_dict(),
+ "optimizer_state_dict": optimizer.state_dict(),
+ "scheduler_state_dict": scheduler.state_dict(),
+ "epoch": epoch,
+ "loss": loss,
+ }, path)
+
+def load_checkpoint(
+ path: str,
+ model: nn.Module,
+ optimizer: Optional[Optimizer] = None,
+ scheduler: Optional[LRScheduler] = None,
+) -> Dict:
+ """Carga checkpoint."""
+ checkpoint = torch.load(path, map_location="cpu")
+ model.load_state_dict(checkpoint["model_state_dict"])
+ if optimizer:
+ optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
+ if scheduler:
+ scheduler.load_state_dict(checkpoint["scheduler_state_dict"])
+ return checkpoint
+```
+
+## Memory Optimization
+
+### Gradient Checkpointing
+
+```python
+# Para modelos grandes (>500M params)
+model.gradient_checkpointing_enable()
+
+# Manual control:
+from torch.utils.checkpoint import checkpoint
+
+class Block(nn.Module):
+ def forward(self, x):
+ if self.training and self.gradient_checkpointing:
+ return checkpoint(self._forward_impl, x, use_reentrant=False)
+ return self._forward_impl(x)
+```
+
+### Efficient Attention
+
+```python
+# Usar Flash Attention cuando sea posible
+from torch.nn.functional import scaled_dot_product_attention
+
+# O xformers para backwards compatibility
+try:
+ from xformers.ops import memory_efficient_attention
+ HAS_XFORMERS = True
+except ImportError:
+ HAS_XFORMERS = False
+```
+
+### Tensor Operations
+
+```python
+# BIEN: operaciones in-place cuando sea seguro
+x.add_(bias) # En lugar de x = x + bias
+
+# BIEN: evitar concatenaciones innecesarias
+# MAL:
+# outputs = []
+# for block in self.blocks:
+# outputs.append(block(x))
+# return torch.cat(outputs, dim=-1)
+
+# BIEN: usar stack si las dimensiones son iguales
+outputs = torch.stack([block(x) for block in self.blocks], dim=0)
+```
+
+## Testing (pytest)
+
+```python
+import pytest
+import torch
+from pampar.coder.v2.modelo import PampaRCoderV2
+from pampar.coder.v2.config import ConfigPampaRCoderV2
+
+@pytest.fixture
+def config():
+ """Configuración pequeña para tests."""
+ return ConfigPampaRCoderV2(
+ vocab_size=1000,
+ hidden_size=64,
+ num_layers=2,
+ num_heads=4,
+ )
+
+@pytest.fixture
+def model(config):
+ """Modelo pequeño para tests."""
+ return PampaRCoderV2(config)
+
+class TestPampaRCoderV2:
+ def test_forward_shape(self, model, config):
+ """Verifica output shape."""
+ batch_size, seq_len = 2, 16
+ input_ids = torch.randint(0, config.vocab_size, (batch_size, seq_len))
+
+ output = model(input_ids)
+
+ assert output.logits.shape == (batch_size, seq_len, config.vocab_size)
+
+ def test_gradient_flow(self, model):
+ """Verifica que gradientes fluyen correctamente."""
+ input_ids = torch.randint(0, 1000, (1, 8))
+ labels = input_ids.clone()
+
+ output = model(input_ids, labels=labels)
+ output.loss.backward()
+
+ for name, param in model.named_parameters():
+ if param.requires_grad:
+ assert param.grad is not None, f"No gradient for {name}"
+```
+
+## Logging
+
+```python
+import logging
+
+# Configurar al inicio del script
+logging.basicConfig(
+ format="%(asctime)s - %(levelname)s - %(message)s",
+ level=logging.INFO,
+)
+logger = logging.getLogger(__name__)
+
+# Usar en el código
+logger.info(f"Epoch {epoch}: loss={loss:.4f}")
+logger.warning(f"GPU memory high: {memory_used:.1f}GB")
+logger.error(f"Checkpoint save failed: {e}")
+```
+
+```
+
+```
diff --git a/.github/instructions/testing-pytest.instructions.md b/.github/instructions/testing-pytest.instructions.md
new file mode 100644
index 0000000000000000000000000000000000000000..2160b49669c244407e52907117fc2fc5939e5259
--- /dev/null
+++ b/.github/instructions/testing-pytest.instructions.md
@@ -0,0 +1,229 @@
+```instructions
+# Testing Instructions (pytest)
+
+> Para tests en PAMPAr-Coder usando pytest.
+
+## Regla de Oro
+
+**Cada módulo nuevo DEBE tener:**
+1. Un test de happy path
+2. Un test de error/edge case
+3. Un test de shapes (para tensores)
+
+## Estructura de Tests
+
+```
+
+tests/
+├── test_modelo.py # Tests del modelo principal
+├── test_talamo.py # Tests del tálamo
+├── test_llaves.py # Tests de LLAVES
+├── test_generation.py # Tests de generación
+└── conftest.py # Fixtures compartidos
+
+````
+
+## Fixtures (conftest.py)
+
+```python
+import pytest
+import torch
+from pampar.coder.v2.config import ConfigPampaRCoderV2
+
+@pytest.fixture
+def device():
+ """Device para tests: CUDA si disponible, else CPU."""
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
+
+@pytest.fixture
+def small_config():
+ """Configuración mínima para tests rápidos."""
+ return ConfigPampaRCoderV2(
+ vocab_size=1000,
+ hidden_size=64,
+ num_layers=2,
+ num_heads=4,
+ intermediate_size=256,
+ )
+
+@pytest.fixture
+def batch():
+ """Batch de ejemplo para tests."""
+ return {
+ "input_ids": torch.randint(0, 1000, (2, 16)),
+ "attention_mask": torch.ones(2, 16, dtype=torch.long),
+ "labels": torch.randint(0, 1000, (2, 16)),
+ }
+````
+
+## Patrones de Test
+
+### Test de Shapes
+
+```python
+class TestModelShapes:
+ def test_embedding_output_shape(self, small_config):
+ from pampar.coder.v2.modelo import PampaRCoderV2
+
+ model = PampaRCoderV2(small_config)
+ input_ids = torch.randint(0, small_config.vocab_size, (2, 16))
+
+ output = model(input_ids)
+
+ assert output.logits.shape == (2, 16, small_config.vocab_size)
+ assert output.hidden_states.shape == (2, 16, small_config.hidden_size)
+
+ def test_attention_shape(self, small_config):
+ from pampar.coder.v2.talamo import TalamoBrodmann
+
+ talamo = TalamoBrodmann(small_config)
+ x = torch.randn(2, 16, small_config.hidden_size)
+
+ out = talamo(x)
+
+ assert out.shape == x.shape
+```
+
+### Test de Gradientes
+
+```python
+class TestGradientFlow:
+ def test_all_parameters_have_gradients(self, small_config):
+ model = PampaRCoderV2(small_config)
+ input_ids = torch.randint(0, small_config.vocab_size, (1, 8))
+
+ output = model(input_ids, labels=input_ids)
+ output.loss.backward()
+
+ for name, param in model.named_parameters():
+ if param.requires_grad:
+ assert param.grad is not None, f"No grad: {name}"
+ assert not torch.isnan(param.grad).any(), f"NaN grad: {name}"
+
+ def test_gradient_clipping(self, small_config):
+ model = PampaRCoderV2(small_config)
+ # ... setup con gradientes grandes
+
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
+
+ total_norm = sum(p.grad.norm() ** 2 for p in model.parameters()).sqrt()
+ assert total_norm <= 1.0 + 1e-6
+```
+
+### Test con Mock
+
+```python
+from unittest.mock import MagicMock, patch
+
+class TestTraining:
+ @patch("torch.cuda.is_available", return_value=False)
+ def test_cpu_fallback(self, mock_cuda):
+ """Verifica que funciona sin GPU."""
+ from pampar.coder.v2.modelo import PampaRCoderV2
+
+ config = ConfigPampaRCoderV2.from_preset("mini")
+ model = PampaRCoderV2(config)
+
+ input_ids = torch.randint(0, config.vocab_size, (1, 8))
+ output = model(input_ids)
+
+ assert output.logits is not None
+```
+
+### Test Parametrizado
+
+```python
+@pytest.mark.parametrize("batch_size", [1, 2, 4])
+@pytest.mark.parametrize("seq_len", [8, 16, 32])
+def test_variable_batch_seq(small_config, batch_size, seq_len):
+ model = PampaRCoderV2(small_config)
+ input_ids = torch.randint(0, small_config.vocab_size, (batch_size, seq_len))
+
+ output = model(input_ids)
+
+ assert output.logits.shape == (batch_size, seq_len, small_config.vocab_size)
+
+@pytest.mark.parametrize("preset", ["mini", "1.5B", "3B"])
+def test_preset_configs(preset):
+ config = ConfigPampaRCoderV2.from_preset(preset)
+
+ assert config.vocab_size == 48000
+ assert config.hidden_size > 0
+```
+
+### Test de LLAVES
+
+```python
+class TestLlaves:
+ def test_llaves_are_not_trainable(self):
+ from pampar.coder.v2.llaves import LlavesModule
+
+ llaves = LlavesModule()
+
+ for param in llaves.parameters():
+ assert not param.requires_grad, "LLAVES no deben ser entrenables"
+
+ def test_llaves_int8_quantization(self):
+ from pampar.coder.v2.llaves import LlavesModule
+
+ llaves = LlavesModule()
+
+ assert llaves.lookup_table.dtype == torch.int8
+
+ def test_llaves_pattern_matching(self):
+ from pampar.coder.v2.llaves import classify_token
+
+ # Declaración Python
+ assert classify_token("def ") in range(1, 16) # SINTAXIS
+
+ # Operador lógico
+ assert classify_token("if ") in range(31, 43) # LÓGICO
+```
+
+## Markers
+
+```python
+# En pyproject.toml o pytest.ini:
+# [tool.pytest.ini_options]
+# markers = [
+# "slow: marks tests as slow",
+# "gpu: marks tests requiring GPU",
+# ]
+
+@pytest.mark.slow
+def test_full_training_loop():
+ """Test lento de training completo."""
+ ...
+
+@pytest.mark.gpu
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="GPU required")
+def test_cuda_forward():
+ """Test que requiere GPU."""
+ ...
+```
+
+## Ejecutar Tests
+
+```bash
+# Todos los tests
+pytest
+
+# Solo tests rápidos
+pytest -m "not slow"
+
+# Con coverage
+pytest --cov=pampar --cov-report=html
+
+# Verbose con print output
+pytest -v -s
+
+# Solo un archivo
+pytest tests/test_modelo.py
+
+# Solo un test específico
+pytest tests/test_modelo.py::TestModelShapes::test_embedding_output_shape
+```
+
+```
+
+```
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000000000000000000000000000000000000..f471a9ad81183caf3adef88dc9f7948a6bfabe49
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,18 @@
+## What
+
+
+
+## Why
+
+
+
+## How to Test
+
+
+
+## Checklist
+
+- [ ] Tests added/updated (`python -m pytest tests/ -v`)
+- [ ] Type hints on all new functions
+- [ ] No hardcoded paths or secrets
+- [ ] Conventional commit messages
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6ac80f5180f2b2f0805d4c4994150edcfe691766
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,32 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ matrix:
+ python-version: ["3.11", "3.12", "3.13"]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install torch --index-url https://download.pytorch.org/whl/cpu
+ pip install -r requirements.txt
+
+ - name: Run tests
+ run: python -m pytest tests/ -v --tb=short
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..c6b8af66057eefe80f70b5fd841f1c498bf77069
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,103 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# Model checkpoints
+checkpoints/
+*.pt
+*.pth
+*.bin
+*.gguf
+
+# Data
+data/
+*.model
+
+# Logs
+logs/
+*.log
+scripts/_logs/
+tensorboard/
+
+# Sessions (generated HTML/JSONL replays)
+sessions/
+
+# Archive (dead scripts, old backups)
+_archive/
+scripts/_archive/
+
+# Large dataset files — use scripts to regenerate
+biblioteca/
+
+# OS
+.DS_Store
+Thumbs.db
diff --git a/.memoria/sesion_2026-03-16_linux-ia.md b/.memoria/sesion_2026-03-16_linux-ia.md
new file mode 100644
index 0000000000000000000000000000000000000000..72aaf2d8d217cb191f17020c8325c9744f3b0e10
--- /dev/null
+++ b/.memoria/sesion_2026-03-16_linux-ia.md
@@ -0,0 +1,154 @@
+# Sesión 2026-03-16 — PAMPAr como "el Linux de la IA"
+
+> Archivo de memoria para el agente. Contexto de la sesión de trabajo.
+
+---
+
+## Corrección crítica del agente
+
+El agente redujo PAMPAr a "un generador de código en formato Problem/Solution".
+El usuario corrigió: **PAMPAr es un copiloto local autónomo basado en arquitectura cerebral**.
+
+### Lo que PAMPAr ES (nunca olvidar)
+
+1. **Arquitectura cerebral 2D**: grilla cortical 4 streams × 5 niveles, inspirada en el cerebro humano
+ - Tálamo (routing) + LLAVES INT8 (80% reglas + 20% aprendido)
+ - 4 streams: SINTAXIS (B01-B15), SEMÁNTICA (B16-B30), LÓGICO (B31-B42), ESTRUCTURAL (B43-B52)
+ - Lateral Gates = fibras blancas (comunicación entre streams)
+ - GQA 4:1 (8Q / 2KV heads), RoPE, Early Exit
+ - 108M params, vocab 48K, max_seq_len 4096
+
+2. **Copiloto local 100% offline**: corre en hardware consumer (GTX 1650, 4GB VRAM), sin cloud, sin APIs, sin telemetría
+
+3. **Agente autónomo con RAG del sistema**:
+ - Scanner inspecciona: workspace (ast.parse), paquetes (importlib), servicios (socket), hardware (torch.cuda)
+ - BootProtocol: CONCIENCIA.md → Scanner → AGENTS.md → RAG L2/L3
+ - La identidad (CONCIENCIA.md) es L3 inmutable, el entorno (AGENTS.md) es L2 mutable
+ - "Físico con doctorado" que se especializa según el "laboratorio" donde aterriza
+
+4. **Skills reales**: LectorArchivos (ojos), EjecutorCodigo (manos), con Skill ABC para extensibilidad
+
+5. **Memoria con Ley de Pareto**: RAGResidual + ClasificadorPareto (L0→L3) + ColaFinetune (auto-mejora)
+
+6. **Loop autónomo**: prompt → genera → ejecuta → observa → aprende del error → reintenta
+
+7. **Visión final**: el modelo genera su propio AGENTS.md al aterrizar en un sistema nuevo
+
+---
+
+## Analogía Linux ↔ PAMPAr
+
+| Linux | PAMPAr | Estado |
+| -------------------------------- | ----------------------------------------------------- | ------------------------------- |
+| Kernel | PamparV3 (108M, grilla cortical 2D) | ✅ Construido |
+| Detección hardware (dmesg, udev) | Scanner (ast.parse, importlib, socket, torch.cuda) | ✅ Construido |
+| Init system (systemd) | BootProtocol (CONCIENCIA → Scanner → AGENTS.md → RAG) | ✅ Construido |
+| Filesystem | RAGResidual + ClasificadorPareto | ✅ Construido |
+| Device drivers | Skills (Skill ABC → LectorArchivos, EjecutorCodigo) | ✅ Base, faltan más |
+| Self-compilation | ColaFinetune (auto-SFT) | ✅ Wiring hecho, no probado e2e |
+| Terminal/Shell | ??? (cli.py es parche, Continue no integrado) | ❌ Falta |
+| Corre en cualquier hardware | 4GB VRAM, CPU fallback | ✅ |
+
+---
+
+## Brechas detectadas
+
+### 1. Kernel no probado en producción
+
+- 16/16 eval controlado, pero no probado con prompts reales
+- SFT actual: Magicoder-OSS-75K (### Problem / ### Solution)
+- **El modelo NO fue entrenado para el formato del Agente** ([LEER:], [EJECUTAR:], historial, RAG context)
+- Brecha más crítica
+
+### 2. Pocos drivers (skills)
+
+- Solo 2 skills. Faltan: BuscarSkill, GitSkill, TerminalSkill, TestSkill, EditarSkill
+
+### 3. Sin interfaz real
+
+- cli.py llama a generate() directo, NO al Agente (sin RAG, sin skills, sin memoria)
+- Continue necesita HTTP server OpenAI-compatible
+
+### 4. Auto-mejora no probada end-to-end
+
+- ColaFinetune → mini-SFT → reload pesos nunca corrió completo
+
+---
+
+## Estrategia propuesta (3 fases)
+
+### Fase A — El kernel funciona de verdad (AHORA)
+
+1. Entrenar modelo con datos en formato del Agente (system prompt + RAG + acciones + historial)
+2. CLI usa el Agente real, no generate() directo
+
+### Fase B — Más drivers, shell funcional
+
+3. 3-4 skills más (buscar, editar, git, tests)
+4. HTTP server OpenAI-compatible (Continue)
+5. Loop auto-mejora probado end-to-end
+
+### Fase C — Distribución empaquetada
+
+6. pip install pampar-coder
+7. Integración Continue nativa
+8. Documentación tipo man pages
+
+---
+
+## Ventaja competitiva
+
+- Arquitectura cerebral (no transformer genérico)
+- 108M params en 4GB VRAM local
+- RAG del sistema como contexto (sabe qué hay en tu máquina)
+- Auto-aprendizaje (ColaFinetune)
+- Sin cloud, sin telemetría, 100% tuyo
+- = Propuesta de valor de Linux vs Windows/macOS en los 90s
+
+---
+
+## Estado del proyecto (Mar 2026)
+
+- **Modelo activo**: PamparV3 — 108.3M params, vocab 48K
+- **Mejor checkpoint**: v3_sft_v8.pt — 16/16 eval
+- **Tests**: 109+ passing
+- **Milestone 1** ✅ — 16/16 eval
+- **Milestone 2** ✅ — Runtime loop (chat.py + ColaFinetune + mini-SFT wiring)
+- **Milestone 3** ✅ — Protocolo (generador determinista AGENTS.md)
+- **Milestone 4** ⏳ — VS Code / Continue integration
+- **Milestone 5** ⏳ — Voz TTS
+
+## Archivos clave del proyecto
+
+```
+pampar/CONCIENCIA.md — Identidad invariante (L3)
+AGENTS.md — Protocolo de despliegue (L2, mutable)
+ROADMAP.md — Plan de evolución
+pampar/coder/v3/modelo.py — PamparV3 (108M)
+pampar/coder/v3/talamo.py — TalamoInicial (routing cerebral)
+pampar/coder/v3/bloques.py — NivelProfundo, StreamFFN, LateralGate
+pampar/coder/v3/llaves.py — LLAVES INT8 (lookup tables)
+pampar/coder/v3/zonas.py — 52 Zonas de Brodmann
+pampar/runtime/agente.py — Agente (orquestador)
+pampar/runtime/scanner.py — Scanner (inspección del entorno)
+pampar/runtime/boot.py — BootProtocol (secuencia de arranque)
+pampar/runtime/generar_agents.py — Generador AGENTS.md
+pampar/memoria/rag.py — RAGResidual (vector store)
+pampar/memoria/clasificador.py — ClasificadorPareto (L0-L3)
+pampar/memoria/cola_finetune.py — ColaFinetune (auto-SFT)
+pampar/skills/base.py — Skill ABC
+pampar/skills/lector_archivos.py — LectorArchivos (ojos)
+pampar/skills/ejecutar_codigo.py — EjecutorCodigo (manos)
+pampar/cli.py — CLI (parche, no usa Agente)
+pampar/inference.py — JSON-lines server (base para HTTP)
+checkpoints/v3_sft_v8.pt — Mejor checkpoint (16/16)
+data/tokenizer/pampar_48k.model — Tokenizer activo
+```
+
+## Hardware del usuario
+
+- **GPU**: GTX 1650 (4GB VRAM)
+- **Python**: 3.13 (C:\Users\lucas\AppData\Local\Programs\Python\Python313\python.exe)
+- **torch**: 2.6.0+cu124
+- **OS**: Windows
+- **.venv en Lunux-AI/.venv**: NO tiene torch — no usar para inferencia
diff --git a/.zenodo.json b/.zenodo.json
new file mode 100644
index 0000000000000000000000000000000000000000..6a690e5a14d0745812ff6672acc902b99b0918f9
--- /dev/null
+++ b/.zenodo.json
@@ -0,0 +1,70 @@
+{
+ "title": "PAMPAr-Coder V3: A Brain-Inspired 2D Stream Architecture with Mixed Selectivity for Efficient Code Generation",
+ "description": "
PAMPAr-Coder V3 is a compact code generation language model with 62.6M parameters, designed to train and run at full FP16 precision on consumer GPUs with as little as 4GB VRAM.
The architecture introduces two key innovations: (1) a 2D Stream organization that arranges computation as four specialized cortical streams (Syntax, Semantics, Logic, Structural) across five depth levels, connected by bidirectional lateral gates analogous to white-matter fiber tracts; and (2) Mixed Selectivity via FiLM , where a single shared Feed-Forward Network per level is dynamically re-read by context-dependent gamma/beta modulators derived from a 63-dimensional context vector — reducing FFN parameters by ~73% per level compared to four independent networks.
Additional contributions include TalamoNivel adaptive per-level re-routing, Grouped Query Attention (8Q/2KV), and the MotorCuriosidad ZPD-based curriculum scheduler that adapts training difficulty across 161 topic categories and 3.2M lines of code data.
After 55,000 training steps, the model reaches a cross-entropy loss of ~1.38 and saturates 29 of 40 curriculum topics at level 1, demonstrating active learning progress with training still ongoing.
This release includes: full model source code (PyTorch), training scripts, PAMPAr-48k bilingual tokenizer (48K vocabulary), configuration, and the research paper preprint.
",
+ "upload_type": "software",
+ "access_right": "open",
+ "license": "other-open",
+ "creators": [
+ {
+ "name": "Mella Chillemi, Lucas Ricardo",
+ "affiliation": "Independent Researcher",
+ "orcid": ""
+ }
+ ],
+ "keywords": [
+ "language model",
+ "code generation",
+ "brain-inspired AI",
+ "mixed selectivity",
+ "FiLM modulation",
+ "grouped query attention",
+ "curriculum learning",
+ "ZPD",
+ "cortical streams",
+ "lateral gates",
+ "LLAVES routing",
+ "parameter efficiency",
+ "PyTorch",
+ "natural language processing",
+ "deep learning"
+ ],
+ "related_identifiers": [
+ {
+ "scheme": "doi",
+ "identifier": "10.5281/zenodo.18315642",
+ "relation": "isCitedBy",
+ "resource_type": "software"
+ }
+ ],
+ "references": [
+ "Perez E., Strub F., de Vries H., Dumoulin V., Courville A. (2018). FiLM: Visual Reasoning with a General Conditioning Layer. AAAI 2018.",
+ "Rigotti M., Barak O., Warden M. R., et al. (2013). The importance of mixed selectivity in complex cognitive tasks. Nature, 497, 585-590.",
+ "Ainslie J., Lee-Thorp J., de Jong M., et al. (2023). GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints. EMNLP 2023.",
+ "Roziere B., Gehring J., Gloeckle F., et al. (2023). Code Llama: Open Foundation Models for Code. arXiv:2308.12950.",
+ "Lozhkov A., Li R., Allal L. B., et al. (2024). StarCoder2 and the Stack v2: The Next Generation. arXiv:2402.19173.",
+ "Vygotsky L. S. (1978). Mind in Society: The Development of Higher Psychological Processes. Harvard University Press.",
+ "Mella Chillemi, L. R. (2026). PAMPAr-o1 v9: A Brain-Inspired Territorial Architecture for Language Modeling. DOI: 10.5281/zenodo.18315642.",
+ "Elhage N., Hume T., Gray C., et al. (2022). Toy Models of Superposition. Transformer Circuits Thread.",
+ "Felleman D. J. & Van Essen D. C. (1991). Distributed hierarchical processing in the primate cerebral cortex. Cerebral Cortex, 1(1), 1-47."
+ ],
+ "notes": "This software is released under the Business Source License 1.1 (BUSL-1.1). The paper preprint is available in the paper/ directory. The model is intended for research and non-commercial use. Commercial licensing inquiries: lucas.mella@outlook.com",
+ "version": "3.0.0",
+ "language": "eng",
+ "subjects": [
+ {
+ "term": "Computer Science - Computation and Language",
+ "identifier": "cs.CL",
+ "scheme": "arXiv"
+ },
+ {
+ "term": "Computer Science - Machine Learning",
+ "identifier": "cs.LG",
+ "scheme": "arXiv"
+ },
+ {
+ "term": "Computer Science - Programming Languages",
+ "identifier": "cs.PL",
+ "scheme": "arXiv"
+ }
+ ]
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000000000000000000000000000000000000..5ccea5460fb7554e606cb9c35c27aa344fd5fa9e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,274 @@
+# PAMPAr — Repository Guidelines
+
+> **PAMPAr** = Procesador Autónomo Modular de Patrones y Razonamiento
+> Para AI agents: Claude Code, Codex, Gemini CLI, GitHub Copilot.
+
+---
+
+## Visión
+
+PAMPAr es un **motor de razonamiento puro** de 108M parámetros. No memoriza respuestas — aprende a **pensar con información de referencia**.
+
+La analogía: un físico que entiende termodinámica puede resolver problemas de química, ingeniería o biología. No memorizó cada campo — tiene los axiomas correctos.
+
+- Los **pesos** (108M params) contienen la capacidad de **razonar**: leer documentación, entender un problema, derivar una solución step-by-step.
+- El **dispositivo** (PC, móvil, servidor) provee el **conocimiento**: docs de Python, MDN, man pages, archivos del usuario — vía RAG local.
+- El modelo no necesita "saber Python". Necesita saber **usar la referencia que tiene disponible** para resolver cualquier problema.
+
+**Objetivo**: un modelo local que razona con la misma metodología que los mejores modelos, usando la información del dispositivo como RAG.
+
+---
+
+## Estado actual
+
+- **Modelo activo**: `PamparV3` — **108.3M params**, vocab 48K, 4 streams × 5 niveles
+- **Mejor checkpoint**: `v3_ghidra_v9.pt` — Routing Score 89, eval 6/16 (38%)
+- **Tokenizer**: `data/tokenizer/pampar_48k.model` (48K, bilingüe ES+código)
+- **Runtime**: Agente + RAGResidual + Scanner + BootProtocol — funcional
+- **Classroom**: Mentor conversacional con Qwen-plus — lecciones dinámicas, 21 conceptos adaptativos, absorción + práctica + corrección
+- **Bio-Mechanisms**: Neuromodulación, LTP, Sleep Consolidation, Neurogenesis, Synaptic Pruning — `bio_mechanisms.py`
+- **Teacher API**: Qwen-plus via DashScope (principal), GitHub Models gpt-4o-mini (alternativa)
+- **Training data**: `master_sft.jsonl` — 1,253 ejemplos (en expansión vía Classroom)
+
+---
+
+## Quick Reference
+
+| Area | Convention |
+| ---------- | ------------------------------------------------------ |
+| Language | Python 3.13+ |
+| Framework | PyTorch 2.6+ |
+| Tokenizer | SentencePiece BPE — **48K** (`pampar_48k.model`) |
+| Type hints | Always required |
+| Docstrings | Google style |
+| Training | Local GTX 1650 (4 GB) + RunPod A100 para fases pesadas |
+| Budget | $300-500 USD total |
+
+---
+
+## Arquitectura — PamparV3
+
+### Grilla 2D: 4 streams × 5 niveles
+
+```
+tok_emb [48K × 640]
+ → TalamoInicial → terr_acts [B, L, 4] / zona_acts [B, L, 52]
+ → 4 streams paralelos (dim=640)
+
+Cada NivelProfundo (×5):
+ 1. GQA Atención compartida (8 Q heads / 2 KV heads, head_dim=80)
+ 2. Re-routing ligero del Tálamo (Linear dim→52, sin bias)
+ 3. 4 × StreamFFN SwiGLU independientes (uno por stream)
+ 4. Lateral gates por stream (bottleneck=128, fibras blancas)
+
+→ norm_f (RMSNorm) → lm_head (weight-tied, vocab=48K)
+```
+
+### Streams ↔ Capas lingüísticas
+
+| Stream | Territorio | Zonas | Especialización | Capa lingüística |
+| ------ | ----------- | ------- | ----------------------------------------- | ---------------- |
+| 0 | SINTAXIS | B01-B15 | Keywords, delimitadores, puntuación | Sintaxis |
+| 1 | SEMANTICA | B16-B30 | Variables, tipos, literales | Semántica |
+| 2 | LOGICO | B31-B42 | Operadores, flujo de control, excepciones | Pragmática |
+| 3 | ESTRUCTURAL | B43-B52 | Indentación, bloques, patrones | Discurso |
+
+### PRESET_V3
+
+| Parámetro | Valor |
+| ---------------- | ----------- |
+| `dim` | 640 |
+| `n_streams` | 4 |
+| `n_levels` | 5 |
+| `n_heads` | 8 |
+| `n_kv_heads` | 2 (GQA 4:1) |
+| `vocab_size` | 48 000 |
+| `max_seq_len` | 4096 |
+| **Total params** | **108.3M** |
+
+---
+
+## Subsistemas
+
+### 1. Modelo (`pampar/coder/v3/`)
+
+| Archivo | Líneas | Propósito |
+| ------------------- | ------ | ------------------------------------------------------------------------------- |
+| `modelo.py` | 310 | PamparV3: forward, generate (nucleus sampling) |
+| `config.py` | 226 | ConfigV3, 3 presets (V3/SMALL/LARGE) |
+| `bloques.py` | 395 | RMSNorm, RoPE, BloqueAttn (GQA), StreamFFN (SwiGLU), LateralGate, NivelProfundo |
+| `talamo.py` | 133 | TalamoInicial: LLAVES 80% + attn_proj 20% + context_conv |
+| `llaves.py` | 266 | LlavesV2: clasificar_token(), tabla INT8, agregar_zonas_a_territorios |
+| `zonas.py` | 265 | Territorio(IntEnum), Zona(IntEnum), ZONAS dict, ZONA_TERRITORIO |
+| `ghidra_probe.py` | 343 | GhidraProbe: 36 forward hooks, diagnosis/debugging |
+| `engrama_stream.py` | 359 | BancoEngrama: O(1) activation memory, cosine-gated injection |
+
+### 2. Memoria (`pampar/memoria/`)
+
+| Archivo | Propósito |
+| ------------------ | ----------------------------------------------------------------------------- |
+| `clasificador.py` | ClasificadorPareto: scoring L0-L3 por densidad, novedad, loss, frecuencia |
+| `rag.py` | RAGResidual: FAISS + sentence-transformers (fallback TF-IDF), 5K entradas max |
+| `cola_finetune.py` | ColaFinetune: acumula L3, exporta JSONL, propone mini-SFT |
+
+### 3. Runtime (`pampar/runtime/`)
+
+| Archivo | Propósito |
+| ------------------- | ------------------------------------------------------------------ |
+| `agente.py` | Orquestador: prompt→RAG→generar→skills→retry→auto-SFT |
+| `scanner.py` | Inspección del dispositivo: OS, GPU, paquetes, servicios, archivos |
+| `boot.py` | BootProtocol: CONCIENCIA.md (L3) → Scanner (L2) → Workspace (L1) |
+| `generar_agents.py` | Genera AGENTS.md contextual desde ResultadoScan |
+
+### 4. Skills (`pampar/skills/`)
+
+| Archivo | Propósito |
+| -------------------- | --------------------------------------------------------- |
+| `lector_archivos.py` | Lee archivos del dispositivo (30+ extensiones, sandboxed) |
+| `ejecutar_codigo.py` | Ejecuta código en subprocess con timeout y blocklist |
+
+### 5. Inference (`pampar/inference.py`)
+
+Servidor JSON-lines stdin/stdout para extensión VS Code. Commands: `infer`, `boot`.
+
+### 6. Classroom — Mentor Conversacional + Bio-Mechanisms
+
+Sistema donde Qwen-plus actúa como mentor conversacional — genera explicaciones, ejemplos y ejercicios dinámicos. PamparV3 absorbe el conocimiento via gradient descent en 3 phases por lección.
+
+**Flujo**: StudentProfile → Mentor genera lección → Phase A (absorber explicación+ejemplo) → Phase B (alumno intenta ejercicio) → Phase C (mentor corrige, entrenar en solución+replay) → actualizar perfil.
+
+| Módulo | Líneas | Responsabilidad |
+| -------------------------- | ------ | ------------------------------------------------------------------------------ |
+| `classroom.py` | ~608 | ClassroomEngine — motor conversacional (orquestador) |
+| `classroom_curriculum.py` | ~433 | ClassroomConfig + CONCEPT_TREE (21 conceptos) + StudentProfile + concept_level |
+| `classroom_teacher.py` | ~252 | Mentor API (Qwen/GitHub/OpenRouter) + parse de lecciones |
+| `classroom_training.py` | ~211 | Tokenización + LR diferencial + train_step |
+| `classroom_memory.py` | ~187 | EWC + ReplayBuffer + LessonResult + compute_ewc_baseline |
+| `classroom_events.py` | ~104 | Formateo dict-based de eventos para consola |
+| `classroom_persistence.py` | ~123 | Guardado de checkpoints, sesiones JSONL, grabaciones HTML |
+| `classroom_server.py` | ~255 | HTTP SSE server + CLI entry point |
+| `bio_mechanisms.py` | ~497 | 5 bio-mechanisms coordinados por BioOrchestrator |
+
+**CONCEPT_TREE**: 21 conceptos en 5 niveles con prerequisitos (arithmetic → algorithms).
+**StudentProfile**: mastery tracking adaptativo — prioriza refuerzo, luego nuevos, luego repaso.
+
+| Mecanismo | Propósito |
+| ------------------ | ----------------------------------------------------------------- |
+| **EWC** | Elastic Weight Consolidation — penaliza cambios en pesos críticos |
+| **Replay Buffer** | Mezcla ejemplos nuevos con anteriores (consolidación tipo sueño) |
+| **LR Diferencial** | LLAVES 0.01x, atención 0.1x, embed 0.1x, FFN 1.0x |
+| **Curriculum** | 5 niveles progresivos: básico → avanzado |
+| **Grabación** | Genera HTML con replay interactivo de cada sesión |
+
+**Bio-Mechanisms** (5 mecanismos de neurociencia en `bio_mechanisms.py`):
+
+| Mecanismo | Implementación |
+| ----------------------- | -------------------------------------------------------------------- |
+| **Neuromodulación** | Dopamina/Norepinefrina modulan LR dinámicamente (×0.3 a ×3.0) |
+| **LTP** | Fortalece `LateralGate.scale` de streams activos (Hebb rule, cada 5) |
+| **Sleep Consolidation** | REM (aleatorio) + SWS (ordenado por dificultad), cada 15 lecciones |
+| **Neurogenesis** | LoRA adapters (rank=8) en StreamFFN cuando loss > 4.0, max 8 |
+| **Synaptic Pruning** | Poda `LateralGate.scale < 0.03` cada 30 lecciones (decay ×0.5) |
+
+Coordinados por `BioOrchestrator.after_lesson()`. Desactivables con `--no-bio`.
+
+**Resultados piloto mentor conversacional (5 lecciones)**: Loss absorción ~7-8, loss ejercicios 5.89→3.94 (mejora), brain score 88.24% estable.
+
+**APIs soportadas**: `qwen` (Qwen-plus via DashScope, principal), `github` (gpt-4o-mini), `openrouter` (requiere créditos).
+
+---
+
+## Estructura del proyecto
+
+```
+PAMPAr-Coder/
+├── AGENTS.md # Este archivo — guía para AI agents
+├── README.md # Documentación pública
+├── PLAN.md # Plan de training y evolución
+├── pampar/
+│ ├── CONCIENCIA.md # Identidad invariante del modelo
+│ ├── coder/
+│ │ └── v3/ # ARQUITECTURA ACTIVA (108M)
+│ │ ├── modelo.py # PamparV3 — forward, generate
+│ │ ├── config.py # ConfigV3, presets
+│ │ ├── talamo.py # TalamoInicial — routing
+│ │ ├── bloques.py # GQA, SwiGLU, LateralGate, NivelProfundo
+│ │ ├── llaves.py # LlavesV2 — lookup INT8
+│ │ ├── zonas.py # 52 Zonas de Brodmann
+│ │ ├── ghidra_probe.py # Instrumentación read-only
+│ │ └── engrama_stream.py# Memoria de activaciones
+│ ├── memoria/
+│ │ ├── clasificador.py # ClasificadorPareto — niveles L0-L3
+│ │ ├── rag.py # RAGResidual — vector store local
+│ │ └── cola_finetune.py # ColaFinetune — buffer auto-SFT
+│ ├── skills/
+│ │ ├── lector_archivos.py # Lee archivos (sandboxed)
+│ │ └── ejecutar_codigo.py # Ejecuta código (subprocess)
+│ ├── runtime/
+│ │ ├── agente.py # Orquestador principal
+│ │ ├── scanner.py # Inspección del dispositivo
+│ │ ├── boot.py # Secuencia de arranque
+│ │ └── generar_agents.py # Generador de AGENTS.md
+│ └── inference.py # Servidor JSON-lines para VS Code
+├── scripts/
+│ ├── classroom.py # ClassroomEngine — motor conversacional (~608 líneas)
+│ ├── classroom_curriculum.py # ClassroomConfig + CONCEPT_TREE + StudentProfile + concept_level
+│ ├── classroom_teacher.py # Mentor API — Qwen/GitHub/OpenRouter + parse de lecciones
+│ ├── classroom_training.py # Tokenización + LR diferencial + train_step
+│ ├── classroom_events.py # Formateo dict-based de eventos para consola
+│ ├── classroom_memory.py # EWC + ReplayBuffer + LessonResult + compute_ewc_baseline
+│ ├── classroom_persistence.py # Guardado de checkpoints, sesiones, grabaciones HTML
+│ ├── classroom_server.py # HTTP SSE server + CLI entry point
+│ ├── bio_mechanisms.py # 5 bio-mechanisms (Neuromod, LTP, Sleep, Neurogenesis, Pruning)
+│ └── classroom_replay.html # Player HTML para replays
+├── sessions/ # Grabaciones de sesiones classroom
+├── data/
+│ ├── tokenizer/
+│ │ └── pampar_48k.model # Vocab 48K bilingüe
+│ └── *.jsonl # Datasets de training
+├── checkpoints/
+│ └── v3_ghidra_v9.pt # Mejor checkpoint actual
+├── _archive/ # Backups de archivos antes de refactorizar
+└── tests/
+```
+
+---
+
+## Critical Rules
+
+- **vocab_size = 48K** → DEBE coincidir con `pampar_48k.model`
+- **Tokenizer path**: usar `PRESET_V3.tokenizer_path` o constante compartida — no hardcodear
+- LLAVES son INT8 pre-computadas — **nunca** en el grafo de gradientes
+- Los 4 streams procesan en **paralelo** — sin secuencialidad entre streams
+- `targets.reshape(-1)` siempre, nunca `.view(-1)` (tensores no-contiguos)
+- `generate()` usa `max_tokens`, NO `max_new_tokens`
+- Imports: `pampar.memoria.*`, `pampar.skills.*`, `pampar.runtime.*`
+- **Backups**: antes de borrar/refactorizar, mover el original a `_archive/`
+
+## Naming Conventions
+
+- **Español** para conceptos del dominio: `Talamo`, `Territorio`, `Zona`, `LLAVES`, `Agente`, `Scanner`
+- **Inglés** para ML estándar: `forward`, `embedding`, `hidden_states`, `loss`, `generate`
+
+## Paradigma de inferencia
+
+```
+1. Usuario hace una pregunta/pedido
+2. Scanner provee contexto del dispositivo (OS, paquetes, archivos)
+3. RAGResidual busca referencia relevante (docs, código, memoria)
+4. Prompt se arma: [SYSTEM] + [REFERENCIA RAG] + [CONTEXTO DISPOSITIVO] + [PREGUNTA]
+5. Modelo RAZONA sobre la referencia y genera solución step-by-step
+6. Skills ejecutan la solución si aplica (código, lectura, tests)
+7. Si falla → retry con error como contexto → ColaFinetune acumula patrones
+```
+
+---
+
+## Instructions Files
+
+Detailed instructions in `.github/instructions/`:
+
+- `global-profile.instructions.md` — perfil del desarrollador
+- `testing.instructions.md` — reglas de testing (pytest)
+- `git-workflow.instructions.md` — commits convencionales
+- `docker-devops.instructions.md` — Docker, CI/CD
diff --git a/CITATION.cff b/CITATION.cff
new file mode 100644
index 0000000000000000000000000000000000000000..c02d24eea3f1aabb7232caa884956e460772c6d1
--- /dev/null
+++ b/CITATION.cff
@@ -0,0 +1,105 @@
+cff-version: 1.2.0
+message: >-
+ If you use PAMPAr-Coder V3 in your research or products,
+ please cite it using the metadata below.
+
+title: >-
+ PAMPAr-Coder V3: A Brain-Inspired 2D Stream Architecture
+ with Mixed Selectivity for Efficient Code Generation
+
+version: "3.0.0"
+
+doi: "10.5281/zenodo.XXXXXXX"
+
+date-released: "2026-04-07"
+
+license: "BUSL-1.1"
+
+repository-code: "https://github.com/lucasmella-stack/PAMPAr-Coder"
+
+abstract: >-
+ PAMPAr-Coder V3 is a 62.6M-parameter code language model with a
+ brain-inspired 2D Stream architecture. It organizes computation as four
+ specialized cortical streams (Syntax, Semantics, Logic, Structural) across
+ five depth levels connected by lateral gates. A single shared Feed-Forward
+ Network per level is dynamically modulated via FiLM (Feature-wise Linear
+ Modulation) using a 63-dimensional context vector, reducing FFN parameters
+ by 73% versus independent per-stream networks. A ZPD-based curriculum
+ scheduler (MotorCuriosidad) adapts training difficulty across 161 topic
+ categories. The model trains end-to-end at FP16 precision on a consumer
+ 4GB GPU — a regime requiring quantization for competing models.
+
+keywords:
+ - code generation
+ - language model
+ - brain-inspired architecture
+ - mixed selectivity
+ - FiLM modulation
+ - grouped query attention
+ - curriculum learning
+ - ZPD
+ - parameter efficiency
+ - cortical streams
+ - lateral gates
+ - LLAVES routing
+
+authors:
+ - family-names: "Mella Chillemi"
+ given-names: "Lucas Ricardo"
+ affiliation: "Independent Researcher"
+ city: "Buenos Aires"
+ country: "AR"
+ email: "lucas.mella@outlook.com"
+
+references:
+ - type: article
+ title: >-
+ PAMPAr-o1 v9: A Brain-Inspired Territorial Architecture for Language
+ Modeling with Explicit Rule-Based Routing
+ authors:
+ - family-names: "Mella Chillemi"
+ given-names: "Lucas Ricardo"
+ year: 2026
+ doi: "10.5281/zenodo.18315642"
+
+ - type: article
+ title: >-
+ FiLM: Visual Reasoning with a General Conditioning Layer
+ authors:
+ - family-names: "Perez"
+ given-names: "Ethan"
+ - family-names: "Strub"
+ given-names: "Florian"
+ - family-names: "de Vries"
+ given-names: "Harm"
+ - family-names: "Dumoulin"
+ given-names: "Vincent"
+ - family-names: "Courville"
+ given-names: "Aaron"
+ year: 2018
+ conference:
+ name: "AAAI 2018"
+
+ - type: article
+ title: "The importance of mixed selectivity in complex cognitive tasks"
+ authors:
+ - family-names: "Rigotti"
+ given-names: "Mattia"
+ - family-names: "Barak"
+ given-names: "Omri"
+ year: 2013
+ journal: "Nature"
+ volume: 497
+ start: 585
+ end: 590
+
+ - type: article
+ title: >-
+ GQA: Training Generalized Multi-Query Transformer Models from
+ Multi-Head Checkpoints
+ authors:
+ - family-names: "Ainslie"
+ given-names: "Joshua"
+ year: 2023
+ conference:
+ name: "EMNLP 2023"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000000000000000000000000000000000000..b5dd862d4eae9dbfd475587909ecabac304e7a11
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,53 @@
+# Contributing to PAMPAr-Coder
+
+Thank you for your interest in contributing! PAMPAr-Coder is licensed under
+[BUSL-1.1](LICENSE) — contributions are welcome for non-commercial research
+and academic purposes.
+
+## How to Contribute
+
+### Reporting Bugs
+
+1. Check [existing issues](https://github.com/lucasmella-stack/PAMPAr-Coder/issues) first.
+2. Open a new issue with:
+ - Steps to reproduce
+ - Expected vs actual behavior
+ - Python version, OS, GPU (if relevant)
+
+### Suggesting Features
+
+Open an issue with the `enhancement` label describing the feature and its use case.
+
+### Pull Requests
+
+1. Fork the repository.
+2. Create a feature branch: `git checkout -b feat/your-feature`
+3. Follow existing code style (type hints, Google-style docstrings).
+4. Add tests for new functionality (`pytest`).
+5. Run the test suite: `python -m pytest tests/ -v`
+6. Commit with [Conventional Commits](https://www.conventionalcommits.org/):
+ `feat:`, `fix:`, `test:`, `docs:`, `refactor:`
+7. Open a PR against `main`.
+
+## Development Setup
+
+```bash
+git clone https://github.com/lucasmella-stack/PAMPAr-Coder.git
+cd PAMPAr-Coder
+python -m venv .venv
+.venv/Scripts/activate # Windows
+pip install -r requirements.txt
+python -m pytest tests/ -v
+```
+
+## Code Style
+
+- Python 3.11+ with type hints everywhere
+- Docstrings: Google style
+- No hardcoded paths or secrets
+- Functions ≤ 50 lines, files ≤ 400 lines
+
+## License
+
+By contributing, you agree that your contributions will be licensed under the
+same [BUSL-1.1](LICENSE) license.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..f3c04a81697b4f63f7f13b6a874b7cf434cc451a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,96 @@
+Business Source License 1.1
+
+Parameters
+
+Licensor: Lucas Ricardo Mella Chillemi
+Licensed Work: PAMPAr-Coder V3
+The Licensed Work is (c) 2025-2026 Lucas Ricardo Mella Chillemi.
+Additional Use Grant: You may use the Licensed Work for non-commercial research,
+academic citation, personal experimentation, and educational
+purposes. Production use for commercial purposes requires a
+separate commercial license from the Licensor.
+Change Date: April 7, 2030
+Change License: Apache License, Version 2.0
+
+For information about alternative licensing arrangements for the Licensed Work,
+please contact: lucas.mella@outlook.com
+
+---
+
+Notice
+
+The Business Source License (this document, or the "License") is not an Open
+Source license. However, the Licensed Work will eventually be made available
+under an Open Source License, as stated in this License.
+
+License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
+"Business Source License" is a trademark of MariaDB Corporation Ab.
+
+---
+
+Terms
+
+The Licensor hereby grants you the right to copy, modify, create derivative
+works, redistribute, and make non-production use of the Licensed Work. The
+Licensor may make an Additional Use Grant, above, permitting limited
+production use.
+
+Effective on the Change Date, or the fourth anniversary of the first publicly
+available distribution of a specific version of the Licensed Work under this
+License, whichever comes first, the Licensor hereby grants you rights under
+the terms of the Change License, and the rights granted in the paragraph
+above terminate.
+
+If your use of the Licensed Work does not comply with the requirements
+currently in effect as described in this License, you must purchase a
+commercial license from the Licensor, its affiliated entities, or authorized
+resellers, or you must refrain from using the Licensed Work.
+
+All copies of the original and modified Licensed Work, and derivative works
+of the Licensed Work, are subject to this License. This License applies
+separately for each version of the Licensed Work and the Change Date may vary
+for each version of the Licensed Work released by Licensor.
+
+You must conspicuously display this License on each original or modified copy
+of the Licensed Work. If you receive the Licensed Work in original or
+modified form from a third party, the terms and conditions set forth in this
+License apply to your use of that work.
+
+Any use of the Licensed Work in violation of this License will automatically
+terminate your rights under this License for the current and all other
+versions of the Licensed Work.
+
+This License does not grant you any right in any trademark or logo of
+Licensor or its affiliates (provided that you may use a trademark or logo of
+Licensor as expressly required by this License).
+
+TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
+AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
+EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
+TITLE.
+
+MariaDB hereby grants you permission to use this License's text to license
+your works, and to refer to it using the trademark "Business Source License",
+as long as you comply with the Covenants of Licensor below.
+
+---
+
+Covenants of Licensor
+
+In consideration of the right to use this License's text and the "Business
+Source License" name and trademark, Licensor covenants to MariaDB, and to all
+other recipients of the licensed work to be provided by Licensor:
+
+1. To specify as the Change License the GPL Version 2.0 or any later version,
+ or a license that is compatible with GPL Version 2.0 or a later version,
+ where "compatible" means that software provided under the Change License
+ can be included in a program with software provided under GPL Version 2.0
+ or a later version. Licensor may specify additional Change Licenses
+ without limitation.
+
+2. To either: (a) specify an additional grant of rights to use that does not
+ impose any additional restriction on the right granted in this License, as
+ the Additional Use Grant; or (b) insert the text "None".
+
+3. Not to modify this License in any other way.
diff --git a/PAMPAR-coder.png b/PAMPAR-coder.png
new file mode 100644
index 0000000000000000000000000000000000000000..e774b2ef072045e83b0f7d18ad2fc364e73847d3
--- /dev/null
+++ b/PAMPAR-coder.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:66e34bb53c8dbf77533f5c410c3d60a16674ff4fff984f5eb9acf24038e8ab88
+size 310177
diff --git a/PAMPArLLM.png b/PAMPArLLM.png
new file mode 100644
index 0000000000000000000000000000000000000000..68f70d6c02a66d70b80001c8b8a9405a1a2cb336
--- /dev/null
+++ b/PAMPArLLM.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8a4b7ed37cd5e594be4c924b9ea1bc5fe91f56d73f7de3f8a49ba6368722c4cd
+size 389310
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000000000000000000000000000000000000..12dd8f57c2e269a0cd77aac1d02777c757b0297b
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,202 @@
+# PAMPAr — Plan de Evolución
+
+> Plan aprobado: **Option B — "Staged Physics"**
+> Budget total: **$300-500 USD**
+> Modelo: PamparV3, 108.3M params, vocab 48K
+
+---
+
+## Objetivo
+
+Transformar PamparV3 de un modelo que solo conoce patrones Python (38% eval)
+a un **motor de razonamiento multi-lenguaje** que usa documentación de referencia
+para resolver problemas en cualquier dominio.
+
+### Métricas target
+
+| Métrica | Actual | Target |
+|---------|--------|--------|
+| Python eval | 6/16 (38%) | 70%+ |
+| Multi-language | 0% | 50%+ |
+| Doc consultation (RAG) | 0% | 60%+ |
+| Debugging | 0% | 50%+ |
+
+---
+
+## Fase 1 — Continual Pretrain: "Textbook Physics"
+
+**Objetivo**: inyectar los axiomas fundamentales de razonamiento con referencia.
+
+### Datos (~540K-640K tokens)
+
+6 pilares de axiomas, cada uno con ~90K-100K tokens de texto tipo textbook:
+
+| Pilar | Contenido | Fuente |
+|-------|-----------|--------|
+| **Lógica y razonamiento** | Proposiciones, inferencia, truth tables, deducción | Generado + Wikipedia |
+| **Estructuras de datos** | Arrays, trees, graphs, hashmaps — cross-language | Generado + docs oficiales |
+| **Patrones de código** | Design patterns, idioms en Python/JS/Rust/C | Generado + libros open |
+| **Comprensión de docs** | Cómo leer una API reference, man page, docstring | MDN, Python docs, Rust Book |
+| **Debugging** | Stack traces, error messages, bisección, logging | Generado + StackOverflow curado |
+| **Multi-language syntax** | Equivalencias Python↔JS↔Rust↔C↔Bash↔SQL | Generado + Rosetta Code |
+
+### Formato
+
+```
+
+## Capítulo: [tema]
+
+[Explicación clara del concepto]
+
+### Ejemplo
+[Código con comentarios]
+
+### Ejercicio resuelto
+[Problema → razonamiento step-by-step → solución]
+
+```
+
+### Costo estimado: $30-60
+
+- Distilación desde GPT-4o/Claude para generar textbooks
+- ~6 scripts de generación, uno por pilar
+- Validación manual de quality (sampling 5%)
+
+### Hardware
+
+- Generación de datos: API calls (local)
+- Continual pretrain: **RunPod A100 40GB** (~2-4 horas)
+
+---
+
+## Fase 2 — SFT: "Chain-of-Thought con Referencia"
+
+**Objetivo**: enseñar al modelo a usar documentación de referencia para resolver problemas.
+
+### Datos (~20K ejemplos)
+
+| Categoría | Ejemplos | Descripción |
+|-----------|----------|-------------|
+| Python + ref | 5K | Problemas con snippet de docs como contexto |
+| JavaScript + ref | 3K | DOM, Node.js, ES6+ con MDN como referencia |
+| Rust + ref | 2K | Ownership, traits, lifetimes con Rust Book |
+| SQL + ref | 2K | Queries con schema como referencia |
+| Bash/CLI + ref | 1K | Comandos con man pages como referencia |
+| Debugging | 3K | Stack traces → diagnóstico → fix |
+| Cross-language | 2K | "Traducir" lógica entre lenguajes |
+| RAG-grounded | 2K | Preguntas que requieren buscar en docs primero |
+
+### Formato SFT
+
+```json
+{
+ "instruction": "[PROBLEMA] Implementar un servidor HTTP básico",
+ "reference": "[REFERENCIA] Fragmento de docs de http.server de Python...",
+ "reasoning": "[RAZONAMIENTO] 1. Necesito importar http.server\n2. Crear handler...\n3. Bind al puerto...",
+ "output": "[SOLUCIÓN] import http.server\n..."
+}
+```
+
+### Costo estimado: $110-150
+
+- Distilación masiva desde GPT-4o/Claude
+- 20K ejemplos × ~$0.006/ejemplo promedio
+- Quality filter: score > 0.7 de auto-evaluación
+
+### Hardware
+
+- Generación de datos: API calls (local)
+- SFT: **RunPod A100 40GB** (~4-8 horas)
+
+---
+
+## Fase 3 — Corrección: "GhidraProbe + NeuroTrainer"
+
+**Objetivo**: corregir routing y pesos usando diagnóstico local.
+
+### Proceso
+
+1. Correr `eval_v3.py` para identificar categorías débiles
+2. GhidraProbe analiza activaciones en ejemplos fallidos
+3. NeuroTrainer aplica correcciones targeted:
+ - LLAVES: ajustar reglas INT8 para tokens multi-language
+ - Routing: corregir `terr_acts` donde el Tálamo asigna mal
+ - Pesos: mini-SFT de 50-100 steps en categorías fallidas
+
+### Costo: $0
+
+- 100% local en GTX 1650
+- ~2 horas por ronda de corrección
+- 3-5 rondas estimadas
+
+---
+
+## Timeline estimado
+
+| Fase | Duración | Costo | Output |
+|------|----------|-------|--------|
+| Fase 1 — Pretrain data | 1-2 semanas | $30-60 | ~600K tokens textbook |
+| Fase 1 — Training | 1 día RunPod | incluido | Checkpoint pretrained |
+| Fase 2 — SFT data | 2-3 semanas | $110-150 | ~20K SFT examples |
+| Fase 2 — Training | 1 día RunPod | incluido | Checkpoint SFT |
+| Fase 3 — Correction | 1 semana | $0 | Checkpoint final |
+| **Total** | **5-7 semanas** | **$160-235** | **Motor de razonamiento** |
+
+---
+
+## Pre-requisitos (Blocks 2-3)
+
+Antes de empezar el training, necesitamos limpiar y preparar el código:
+
+### Block 2 — Cleanup de código muerto
+
+Scripts que importan módulos v2 eliminados (borrar con backup a `_archive/`):
+
+- `scripts/aprender_solo.py`
+- `scripts/train.py`
+- `scripts/train_cerebral.py`
+- `scripts/destilar.py`
+- `scripts/evaluate_v2.py`
+- `scripts/generar_curriculum.py`
+- `scripts/smoke_test_viaje.py`
+- `scripts/test_llaves.py`
+
+Scripts mixtos v2/v3 rotos (borrar con backup):
+
+- `scripts/benchmark.py`
+- `scripts/probar_modelo.py`
+- `scripts/eval_honesta.py`
+
+Módulos huérfanos:
+
+- `pampar/training/` — no importado por nada
+
+### Block 3 — Refactoring para multi-language
+
+| Archivo | Cambio |
+|---------|--------|
+| `zonas.py` | Agregar keywords JS/Rust/C/Bash/SQL a ZONAS |
+| `llaves.py` | Expandir `clasificar_token()` para multi-language |
+| `clasificador.py` | Generalizar `_calcular_densidad()` más allá de Python |
+| `ejecutar_codigo.py` | Agregar soporte para Node.js, Bash |
+| `config.py` | Extraer `TOKENIZER_PATH` como constante compartida |
+
+---
+
+## Checkpoints esperados
+
+| Nombre | Fase | Descripción |
+|--------|------|-------------|
+| `v3_ghidra_v9.pt` | Actual | Score 89, 6/16 (38%) — baseline |
+| `v3_pretrain_f1.pt` | Fase 1 | Post continual pretrain |
+| `v3_sft_f2.pt` | Fase 2 | Post SFT multi-language |
+| `v3_corrected_f3.pt` | Fase 3 | Post GhidraProbe correction — target final |
+
+---
+
+## Notas
+
+- **Arquitectura LOCKED**: no tocar la grilla 4×5, GQA, SwiGLU, LLAVES 80/20
+- **Backups siempre**: antes de borrar/refactorizar → `_archive/`
+- **RunPod**: A100 40GB para fases 1 y 2, el código de `cloud/runpod/` ya existe
+- **Evaluación**: `scripts/eval_v3.py` como benchmark consistente entre fases
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..f7ddf5c33acba5493129d21695461354e301adc2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,365 @@
+
+
+
+
+PAMPAr-Coder
+
+
+ Pure reasoning engine — 62.6M params, local-first, on-device RAG.
+
+
+
+
+
+
+
+
+
+---
+
+## What is PAMPAr-Coder
+
+PAMPAr-Coder is a 62.6M parameter language model that **reasons over reference information** rather than memorizing answers. It works like a physicist: it understands the fundamental axioms and can derive solutions for any domain using documentation available on the device.
+
+- **Weights**: reasoning capability (read docs, understand problems, derive solutions step-by-step)
+- **Device**: knowledge via local RAG (Python docs, MDN, man pages, user files)
+- **Hardware**: designed to run on consumer hardware (GTX 1650, 4 GB VRAM)
+
+**Current state**: `v3_train.pt` — 98K steps, Mixed Selectivity (FiLM). Classroom system with conversational mentor (Qwen-plus) + 5 bio-inspired mechanisms. Tree of 21 concepts with adaptive prerequisites.
+
+---
+
+## 2D Architecture (PamparV3)
+
+```
+tok_emb [48K x 640]
+ -> TalamoInicial (LLAVES 80% + attn_proj 20% + context_conv)
+ -> terr_acts [B, L, 4] / zona_acts [B, L, 52]
+ -> 4 parallel streams (dim=640)
+
+ NivelProfundo x5:
+ 1. Shared GQA Attention (8 Q heads / 2 KV heads, head_dim=80)
+ 2. Lightweight Thalamus re-routing
+ 3. 4 x independent StreamFFN SwiGLU
+ 4. Lateral gates per stream (bottleneck=128)
+
+ -> norm_f (RMSNorm) -> lm_head (weight-tied, vocab=48K)
+```
+
+### The 4 Streams
+
+| Stream | Brodmann Zones | Processes |
+| -------------- | -------------- | --------------------------------- |
+| **SYNTAX** | B01-B15 | Keywords, operators, punctuation |
+| **SEMANTICS** | B16-B30 | Types, variables, literals |
+| **LOGIC** | B31-B42 | Control flow, conditionals, loops |
+| **STRUCTURAL** | B43-B52 | Blocks, indentation, scope |
+
+### Parameters
+
+| Parameter | Value |
+| ---------------- | ----------- |
+| `dim` | 640 |
+| `n_streams` | 4 |
+| `n_levels` | 5 |
+| `n_heads` | 8 |
+| `n_kv_heads` | 2 (GQA 4:1) |
+| `vocab_size` | 48,000 |
+| `max_seq_len` | 4096 |
+| **Total params** | **62.6M** |
+
+---
+
+## Key Innovations
+
+### LLAVES System (TalamoInicial)
+
+- **80% explicit rules**: routing based on code patterns (INT8, pre-computed)
+- **20% learned attention**: fine-tuning for ambiguous cases
+- Produces `terr_acts` and `zona_acts` with zero inference overhead
+
+### 2D Cortical Architecture
+
+- **4 streams × 5 levels** = grid where rows specialize and columns refine
+- **GQA 4:1**: lower VRAM, same quality
+- **Lateral gates** (bottleneck 128): cross-stream communication like white-matter fibers
+- **Re-routing** per level: the Thalamus adapts which stream leads based on accumulated context
+
+### On-Device RAG
+
+The model uses the machine where it's installed as its knowledge source:
+
+- Scanner detects OS, packages, available files
+- RAGResidual indexes local documentation (FAISS + sentence-transformers)
+- The model reasons over references, it doesn't memorize content
+
+---
+
+## Classroom — Conversational Mentor + Bio-Mechanisms
+
+A learning system where a mentor model (Qwen-plus via DashScope) teaches PamparV3 through dynamic conversations, like a tutor in a chat. The mentor generates unique explanations, examples, and exercises for each lesson — the student absorbs knowledge via gradient descent.
+
+### Lesson Flow
+
+```
+1. StudentProfile selects adaptive concept (21 concepts with prerequisites)
+2. Mentor generates lesson: explanation + example + exercise + solution
+3. Phase A — Absorb: train on explanation + example (all tokens)
+4. Phase B — Practice: student attempts the exercise
+5. Phase C — Correct: mentor evaluates, train on correct solution + replay
+6. Update student profile (mastery per concept)
+```
+
+### Concept Tree (CONCEPT_TREE)
+
+21 concepts organized in 5 levels with prerequisites:
+
+| Level | Concepts |
+| ----- | --------------------------------------------------------------------- |
+| 1 | arithmetic → variables_types → conditionals, strings, functions_basic |
+| 2 | loops_for → loops_while, lists → tuples_sets, dicts |
+| 3 | recursion, higher_order, generators, error_handling |
+| 4 | classes_basic → inheritance, dunder_methods |
+| 5 | decorators, context_managers, algorithms, file_io |
+
+`StudentProfile` tracks mastery per concept and selects adaptively:
+
+- Prioritizes concepts with attempts but not yet mastered (reinforcement)
+- Then new concepts whose prerequisites are met
+- Finally spaced review of mastered concepts
+
+### Core Mechanisms
+
+| Mechanism | Purpose |
+| -------------------------------------- | ----------------------------------------------------------------- |
+| **EWC** (Elastic Weight Consolidation) | Protects important weights — penalizes changes to critical params |
+| **Replay Buffer** | Mixes new and previous examples (simulates sleep consolidation) |
+| **Differential LR** | LLAVES/Thalamus 0.01×, attention 0.1×, embedding 0.1×, FFN 1.0× |
+| **Conversational Absorption** | Trains on mentor explanations + examples (knowledge distillation) |
+
+### Bio-Mechanisms (`bio_mechanisms.py`)
+
+5 mechanisms based on real neuroscience, integrated as post-lesson hooks:
+
+| Mechanism | Biological Inspiration | Implementation |
+| ----------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
+| **Neuromodulation** | Dopamine + Norepinephrine | Dynamically modulates LR based on success/error (×0.3 to ×3.0) |
+| **LTP** | Long-term potentiation | Strengthens `LateralGate.scale` of streams with consistent high activation (Hebb rule) |
+| **Sleep Consolidation** | REM + SWS phases | Periodic replay (every 15 lessons): random (REM) + sorted by difficulty (SWS) |
+| **Neurogenesis** | New hippocampal neurons | Injects LoRA adapters (rank=8, ~10K params) into StreamFFN when loss > 4.0 |
+| **Synaptic Pruning** | Synaptic pruning (~50%) | Reduces `LateralGate.scale < 0.03` every 30 lessons (decay ×0.5) |
+
+All coordinated by `BioOrchestrator.after_lesson()`. Can be disabled with `--no-bio`.
+
+### Mentor Pilot Results (5 lessons)
+
+- Absorption loss: ~7-8 (new content from mentor)
+- Exercise loss decreasing: 5.89 → 5.44 → 4.40 → 3.94 → 4.38
+- Brain score stable: 88.24% (prior knowledge preservation)
+- EWC penalty growing: 0.000002 → 0.000044 (active regularization)
+- Each lesson is UNIQUE — mentor generates dynamically, no repetition
+
+### Usage
+
+```bash
+# Conversational mentor with Qwen-plus (recommended)
+python scripts/classroom_server.py \
+ --checkpoint checkpoints/v3_train.pt \
+ --checkpoint-out checkpoints/v3_classroom_mentor.pt \
+ --teacher qwen --model qwen-plus \
+ --max-lessons 200 --lr 1e-5 --ewc-lambda 50 --no-bio --no-ui
+
+# With bio-inspired mechanisms enabled
+python scripts/classroom_server.py \
+ --checkpoint checkpoints/v3_train.pt \
+ --teacher qwen --model qwen-plus \
+ --max-lessons 200 --lr 1e-5
+
+# With web interface (SSE + dashboard)
+python scripts/classroom_server.py \
+ --checkpoint checkpoints/v3_train.pt \
+ --teacher qwen --port 8787
+
+# With GitHub Models API (alternative)
+python scripts/classroom_server.py \
+ --checkpoint checkpoints/v3_train.pt \
+ --teacher github --model gpt-4o-mini
+
+# Replay a recorded session
+# Open sessions/classroom_*.html in browser
+```
+
+---
+
+## Subsystems
+
+| Module | Components | Purpose |
+| ------------- | --------------------------- | ----------------------------------------------------------------------------------------------- |
+| **Model** | `pampar/coder/v3/` | PamparV3: forward, generate, routing, blocks |
+| **Memory** | `pampar/memoria/` | ClasificadorPareto (L0-L3), RAGResidual (FAISS), ColaFinetune |
+| **Runtime** | `pampar/runtime/` | Agent (orchestrator), Scanner (device), BootProtocol |
+| **Skills** | `pampar/skills/` | LectorArchivos (30+ ext), EjecutorCodigo (subprocess) |
+| **Inference** | `pampar/inference.py` | JSON-lines stdin/stdout server for VS Code |
+| **Classroom** | `scripts/classroom*.py` | Conversational mentor: engine + teacher + curriculum + training + events + memory + persistence |
+| **Bio-Mech** | `scripts/bio_mechanisms.py` | 5 neuroscience mechanisms: Neuromod, LTP, Sleep, Neurogenesis, Pruning |
+
+---
+
+## Installation
+
+```bash
+git clone https://github.com/lucasmella-stack/PAMPAr-Coder.git
+cd PAMPAr-Coder
+pip install -r requirements.txt
+```
+
+---
+
+## Usage
+
+### Instantiate the model
+
+```python
+from pampar.coder.v3 import PamparV3, PRESET_V3
+import torch
+
+model = PamparV3(PRESET_V3)
+model.eval()
+
+# Forward pass
+ids = torch.randint(0, 48_000, (1, 64))
+with torch.no_grad():
+ logits, loss, info = model(ids)
+
+# Autoregressive generation
+gen = model.generate(ids, max_tokens=100, temperature=0.8, top_k=50)
+```
+
+### Use the Agent (with RAG + Skills)
+
+```python
+from pampar.runtime import Agente
+
+agent = Agente(
+ checkpoint="checkpoints/v3_train.pt",
+ workspace_root=".",
+)
+response = agent.responder("how to read a CSV with pandas?")
+```
+
+---
+
+## Project Structure
+
+```
+PAMPAr-Coder/
+├── pampar/
+│ ├── coder/v3/ # Active architecture (62.6M)
+│ │ ├── modelo.py # PamparV3 — forward, generate
+│ │ ├── config.py # ConfigV3 + presets
+│ │ ├── talamo.py # TalamoInicial — routing
+│ │ ├── bloques.py # GQA, SwiGLU, LateralGate, NivelProfundo
+│ │ ├── llaves.py # LlavesV2 — INT8 lookup
+│ │ ├── zonas.py # 52 Brodmann Zones
+│ │ ├── ghidra_probe.py # Read-only instrumentation
+│ │ └── engrama_stream.py # Activation memory
+│ ├── memoria/
+│ │ ├── clasificador.py # ClasificadorPareto (L0-L3)
+│ │ ├── rag.py # RAGResidual (FAISS + TF-IDF fallback)
+│ │ └── cola_finetune.py # ColaFinetune (auto-SFT buffer)
+│ ├── skills/
+│ │ ├── lector_archivos.py # File reader (sandboxed)
+│ │ └── ejecutar_codigo.py # Code executor (subprocess)
+│ ├── runtime/
+│ │ ├── agente.py # Main orchestrator
+│ │ ├── scanner.py # Device inspection
+│ │ └── boot.py # Boot sequence
+│ └── inference.py # JSON-lines server for VS Code
+├── scripts/
+│ ├── classroom.py # ClassroomEngine (~600 lines)
+│ ├── classroom_curriculum.py# CONCEPT_TREE (21 concepts) + StudentProfile
+│ ├── classroom_teacher.py # Mentor API (GitHub/OpenRouter/Qwen)
+│ ├── classroom_training.py # Tokenization + differential LR + train_step
+│ ├── classroom_events.py # Console event formatting
+│ ├── classroom_memory.py # EWC + ReplayBuffer + compute_ewc_baseline
+│ ├── classroom_persistence.py # Checkpoint + session + HTML recording save
+│ ├── classroom_server.py # HTTP SSE server + CLI (entry point)
+│ └── bio_mechanisms.py # 5 bio mechanisms
+├── data/tokenizer/
+│ └── pampar_48k.model # 48K bilingual vocab (active)
+├── checkpoints/ # Model checkpoints (gitignored)
+├── tests/ # pytest test suite
+└── _archive/ # Pre-refactoring backups
+```
+
+---
+
+## Understanding the Loss
+
+| Loss | Meaning |
+| ----- | --------------------- |
+| ~10.7 | Untrained (log 48000) |
+| 7-8 | Random weights |
+| 5-7 | Beginning to learn |
+| 2-4 | Active learning |
+| 1.5-2 | Optimal zone |
+| < 1.5 | Topic well learned |
+| < 0.7 | Topic mastered |
+
+---
+
+## Tests
+
+```bash
+python -m pytest tests/ -v
+```
+
+142 tests, all passing.
+
+---
+
+## Philosophy
+
+> _"You don't need 72 billion parameters. You need the right architecture and the right axioms."_
+
+1. **Reasoning > memorization** — the model learns to use references, not to memorize
+2. **The device is the knowledge base** — local RAG, not cloud
+3. **Code is structured** — 4 specialized streams + LLAVES 80% rules
+4. **Consumer hardware** — 1.4 GB VRAM for fp16 training
+
+---
+
+## Roadmap
+
+- [x] Territorial architecture (52 Brodmann zones, 4 streams × 5 levels)
+- [x] LLAVES system (INT8 routing, 80% rules)
+- [x] BPE 48K bilingual tokenizer (ES + code)
+- [x] GQA 4:1, SwiGLU, lateral gates
+- [x] Memory module (ClasificadorPareto, RAG, ColaFinetune)
+- [x] Skills (LectorArchivos, EjecutorCodigo)
+- [x] Runtime.Agent (tool-use loop)
+- [x] GhidraProbe (read-only diagnostics)
+- [x] EngramaStream (activation memory)
+- [x] Bio-inspired Classroom (EWC, replay buffer, differential LR, curriculum)
+- [x] HTML session recording and replay
+- [x] GitHub Models API integration (gpt-4o-mini as teacher)
+- [x] Bio-mechanisms: Neuromodulation, LTP, Sleep Consolidation, Neurogenesis, Synaptic Pruning
+- [x] Conversational mentor: Qwen-plus generates dynamic lessons as tutor
+- [x] CONCEPT_TREE: 21 concepts with adaptive prerequisites
+- [x] StudentProfile: per-concept mastery tracking
+- [x] Loss masking: -100 on prompt tokens (train only on responses)
+- [x] Conversational absorption: train on mentor explanations + examples
+- [ ] Multimodal: image/diagram input support
+- [ ] Training data expansion (textbook + SFT multi-language)
+- [ ] KV cache in generate()
+- [ ] Multi-language execution (JS, Rust, Bash)
+- [ ] Benchmarks against reference models
+- [ ] VS Code extension
+
+---
+
+## License
+
+BUSL-1.1 — Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+
+Change Date: April 7, 2030 — License converts to Apache-2.0. See [LICENSE](LICENSE) for details.
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000000000000000000000000000000000000..e4c5b94b00dc881808c7059f31dfd4b68a355a0f
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,240 @@
+# PAMPAr-Coder — Roadmap
+
+> Plan de evolución. Última actualización: Mar 2026.
+> Para la identidad del modelo ver `CONCIENCIA.md`. Para el protocolo de despliegue ver `AGENTS.md`.
+
+---
+
+## 1. Visión
+
+PAMPAr es un **físico con doctorado** que puede especializarse en cualquier campo:
+
+- El **doctorado** (razonamiento computacional) está en los **pesos** — 108M params.
+- La **especialización** viene del **entorno** — se descubre al boot con el Scanner.
+- El protocolo de 3 archivos (`CONCIENCIA.md` + `AGENTS.md` + `TOOLS.md`) es la interfaz entre el modelo y su despliegue.
+
+### Las 3 fases del proyecto
+
+| Fase | Qué | Estado |
+| -------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------- |
+| **Fase 1** — SFT | Entrenar el doctorado: lógica Python, patrones, razonamiento | **✅ Completa** (16/16 con reparadores, target superado) |
+| **Fase 2** — Runtime loop | El modelo usa herramientas, ejecuta, lee, aprende del loop | **✅ Completa** (chat.py + ColaFinetune + mini-SFT wiring) |
+| **Fase 3** — Protocolo entrenado | El modelo genera su propio AGENTS.md al aterrizar en un sistema nuevo | Futuro |
+
+---
+
+## 2. Arquitectura actual — PamparV3
+
+### 2.1 Grilla cortical 2D
+
+```
+Tokens (int)
+ │
+ ▼
+[Embeddings] 48K vocab, dim=640, weight-tied con lm_head
+ │
+ ▼
+┌─────────────────────────────┐
+│ TalamoInicial │ routing: qué streams procesan cada token
+│ 80% LLAVES (INT8 + reglas) │
+│ 20% attn_proj (aprendido) │
+│ + context_conv causal k=32 │
+└────────────┬────────────────┘
+ │ [B, L, 4, dim] — 4 streams con pesos distintos
+ ▼
+ ┌──────────────────────────────────────────────┐
+ │ 5 × NivelProfundo │
+ │ TalamoNivel → 4× BloqueAttn GQA 4:1 │
+ │ → 4× StreamFFN SwiGLU → LateralGate │
+ │ → Early Exit (umbral 0.90) │
+ └──────────────────────────────────────────────┘
+ │
+ ▼
+ RMSNorm + lm_head → logits [B, L, 48000]
+```
+
+### 2.2 Streams ↔ Capas lingüísticas
+
+| Stream | Territorio | Zonas | Capa lingüística |
+| ------ | ----------- | ------- | --------------------------------- |
+| 0 | SINTAXIS | B01-B15 | Sintaxis — estructura del código |
+| 1 | SEMANTICA | B16-B30 | Semántica — significado |
+| 2 | LOGICO | B31-B42 | Pragmática — intención, flujo |
+| 3 | ESTRUCTURAL | B43-B52 | Discurso — organización, patrones |
+
+Los 4 streams procesan en paralelo. Cada NivelProfundo tiene Lateral Gates (bottleneck=128) para comunicación entre streams — como las fibras blancas del cerebro.
+
+### 2.3 Boot Protocol
+
+```
+1. CONCIENCIA.md → RAG L3 (identidad inmutable)
+2. Scanner → workspace (ast), paquetes (importlib), servicios (socket), sistema (platform)
+3. AGENTS.md contextual → RAG L2 (entorno mutable)
+4. System prompt dinámico = identidad + contexto + acciones
+```
+
+Implementado en `pampar.runtime.scanner` + `pampar.runtime.boot`.
+
+---
+
+## 3. Estado de checkpoints
+
+| Checkpoint | Datos | Eval open (temp=0.0) |
+| -------------- | --------------------------------------------- | -------------------- |
+| `v3_sft.pt` | 43K Magicoder (inglés) | 0/16 |
+| `v3_sft_v5.pt` | SFT v5 (base post-catastrófico) | 6/16 |
+| `v3_sft_v6.pt` | clean_sft.jsonl (555 ejemplos) | 10/16 |
+| `v3_sft_v7.pt` | final_sft.jsonl (825 = clean + quirúrgico×3) | 15/16 |
+| `v3_sft_v8.pt` | micro-SFT cuadrados (300 steps + reparadores) | **16/16 ✅ BEST** |
+
+### Estado actual (v3_sft_v8.pt — 16/16 con reparadores)
+
+| # | Función | Estado | Notas |
+| --- | ---------------- | ------ | -------------------------------------------------------------- |
+| 01 | contar_vocales | ✅ | — |
+| 02 | suma_digitos | ✅ | — |
+| 03 | es_palindromo | ✅ | — |
+| 04 | maximo_lista | ✅ | — |
+| 05 | fizzbuzz | ✅ | Corregido (dataset quirúrgico) |
+| 06 | aplanar_lista | ✅ | — |
+| 07 | frecuencia | ✅ | — |
+| 08 | cuadrados_pares | ✅ | Genera `x*i` → reparador NameError word-boundary lo corrige |
+| 09 | invertir_dict | ✅ | — |
+| 10 | fibonacci | ✅ | — |
+| 11 | busqueda_binaria | ✅ | — |
+| 12 | merge_sort | ✅ | Corregido (self-contained) |
+| 13 | Stack | ✅ | — |
+| 14 | Punto | ✅ | Corregido (import math / \*\*0.5) |
+| 15 | memoize | ✅ | Corregido (usa `fn`, no `func`) |
+| 16 | primos_hasta | ✅ | Reparador `_reparar_bloques_huerfanos` + stop `endswith(\n\n)` |
+
+---
+
+## 4. Plan de entrenamiento
+
+### Fase A — Entrenamiento curricular con MotorCuriosidad
+
+Objetivo: reforzar las bases de lógica que el modelo falla.
+
+```bash
+python scripts/train_v3.py \
+ --checkpoint checkpoints/v3_sft_v4.pt \
+ --biblioteca data/biblioteca/ \
+ --lr 3e-5 --epochs 3
+```
+
+Temas prioritarios basados en fallos del eval:
+
+1. `bucles_for_while` — fizzbuzz, cuadrados_pares
+2. `diccionarios` — invertir_dict
+3. `busqueda_algoritmos` — búsqueda binaria
+4. `recursion` — merge_sort
+5. `clases_oop` — Punto, memoize
+6. `matematica_basica` — primos, potencias
+
+### Fase B — SFT v5 (post-curricular)
+
+- ~18K ejemplos curados (3K por topic × 6 topics)
+- Formato Alpaca, filtrado con pytest
+- Generados por el propio modelo + verificación automática
+
+### Fase C — Matriz lingüística como dato de entrenamiento
+
+Incluir ejemplos que ejerciten explícitamente cada capa:
+
+- **Pragmática**: "El usuario quiere X, yo debo hacer Y" (comprensión de intención)
+- **Semántica**: Renombrar variables, inferir tipos, naming conventions
+- **Sintaxis**: Indentación correcta, keywords, delimitadores, f-strings
+- **Discurso**: Organización de código (imports → constantes → clases → funciones → main)
+
+---
+
+## 5. Roadmap de milestones
+
+```
+COMPLETADO ✅ COMPLETADO ✅ AHORA LARGO PLAZO
+──────────── ──────────── ───────────── ────────────
+15/16 eval → 16/16 eval → Mini-SFT auto → Protocolo
+v3_sft_v7.pt v3_sft_v8.pt cuando cola≥50 entrenado
+108M params + reparadores ColaFinetune Fase 3
+
+SFT dataset chat.py Mini-SFT wiring El modelo
+limpio+quirúrgico loop activo sft_v5.py genera su
+Clean+surgical×3 gen→exec→retry auto-reload AGENTS.md
+```
+
+### Milestone 1 — 16/16 eval ✅ COMPLETADO (target era ≥12/16)
+
+- [x] Dataset limpio (clean_sft.jsonl — 555 ejemplos sin contradicciones)
+- [x] Dataset quirúrgico (surgical_sft.jsonl — 90 ejemplos para 6 fallos)
+- [x] SFT v6 (10/16) desde clean data
+- [x] SFT v7 (15/16) desde clean + surgical×3
+- [x] Fix primos_hasta → reparador `_reparar_bloques_huerfanos` + stop `endswith(\n\n)`
+- [x] Fix cuadrados_pares → reparador NameError word-boundary en verificador
+- [x] **16/16 confirmado** con v3_sft_v8.pt + eval_v3.py cadena de reparadores
+
+### Milestone 2 — Runtime autónomo (EN PROGRESO)
+
+- [x] Scanner del sistema (`pampar.runtime.scanner`)
+- [x] Boot protocol (`pampar.runtime.boot`)
+- [x] CONCIENCIA.md como identidad invariante
+- [x] System prompt dinámico (identidad + contexto del scan)
+- [x] El agente ejecuta código que genera y observa output (`scripts/chat.py`)
+- [x] Si falla, agrega el par (prompt, error) a ColaFinetune
+- [x] Mini-SFT automático cuando la cola supera umbral (wiring con sft_v5.py + reload en proceso)
+
+### Milestone 3 — Protocolo entrenado ✅ Implementado (generador determinista)
+
+- [x] `pampar/runtime/generar_agents.py` — genera AGENTS.md contextual desde el scan (determinista)
+- [x] `BootProtocol._inyectar_contexto()` actualizado: genera AGENTS.md → fragmenta por secciones → RAG L2
+- [x] 23 tests en `tests/test_generar_agents.py` (132/132 en suite completa)
+- [x] Quick Reference, Sistema detectado, Paquetes clave, Servicios, Boot protocol generados dinámicamente
+- [ ] El modelo "sabe" escanear: genera `scan_sistema()` como código (largo plazo — necesita mucho más SFT)
+- [ ] CONCIENCIA se refuerza con RLHF/DPO sobre interacciones reales (largo plazo)
+- [ ] Nota: entrenar 108M params para generar markdown desde cero requiere 10K+ pasos — protocolo funcionando vía boot determinista es la aproximación correcta para este tamaño de modelo
+
+### Milestone 4 — VS Code extension
+
+- [ ] Extension que carga PamparV3 localmente (CPU/GPU)
+- [ ] Completado inline de código
+- [ ] Panel de chat con el agente
+- [ ] Memoria persistente entre sesiones (RAG en disco)
+
+### Milestone 5 — Voz (cuando el sistema la tiene)
+
+- [ ] Detectar motores de voz al boot (espeak, SAPI, say) — ya implementado en Scanner
+- [ ] TTS para respuestas cuando el usuario lo pide
+- [ ] Zero-dependency: usa lo que el OS tiene instalado
+
+---
+
+## 6. Estructura de carpetas
+
+```
+PAMPAr-Coder/
+├── CONCIENCIA.md # Identidad invariante del modelo
+├── AGENTS.md # Protocolo de despliegue (mutable)
+├── ROADMAP.md # Este archivo
+├── pampar/
+│ ├── coder/v3/ # Arquitectura activa (108M)
+│ │ ├── modelo.py # PamparV3
+│ │ ├── config.py # ConfigV3, presets
+│ │ ├── talamo.py # TalamoInicial
+│ │ ├── bloques.py # BloqueAttn, StreamFFN, LateralGate
+│ │ ├── llaves.py # LlavesV2 — lookup INT8
+│ │ └── zonas.py # 52 Zonas de Brodmann
+│ ├── memoria/
+│ │ ├── clasificador.py # ClasificadorPareto — L0 a L3
+│ │ ├── rag.py # RAGResidual — vector store
+│ │ └── cola_finetune.py # ColaFinetune — buffer SFT
+│ ├── runtime/
+│ │ ├── agente.py # Agente — orquestador principal
+│ │ ├── scanner.py # Scanner — inspección del entorno
+│ │ └── boot.py # BootProtocol — secuencia de arranque
+│ └── training/
+│ ├── curiosidad.py # MotorCuriosidad — ZPD
+│ └── lector.py # LectorBiblioteca
+├── checkpoints/
+│ └── v3_sft_v4.pt # Mejor checkpoint (8/16)
+└── tests/ # 109+ tests
+```
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000000000000000000000000000000000000..53c1b7b6cf4e717a86733ad50a5d369efeedbdfb
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,25 @@
+# Security Policy
+
+## Supported Versions
+
+| Version | Supported |
+| ------- | ------------------ |
+| 3.x | :white_check_mark: |
+| < 3.0 | :x: |
+
+## Reporting a Vulnerability
+
+If you discover a security vulnerability, please report it responsibly:
+
+1. **Do NOT open a public issue.**
+2. Email **lucas.mella@outlook.com** with:
+ - Description of the vulnerability
+ - Steps to reproduce
+ - Potential impact
+3. You will receive an acknowledgment within 48 hours.
+4. A fix will be developed privately and released as a patch.
+
+## Scope
+
+This policy covers the PAMPAr-Coder source code and any official releases.
+Training data, checkpoints, and third-party dependencies are out of scope.
diff --git a/benchmarks/history.jsonl b/benchmarks/history.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..4ff363e7d65a6d21997a38777b9d14d8ea22a96a
--- /dev/null
+++ b/benchmarks/history.jsonl
@@ -0,0 +1,4 @@
+{"tag": "baseline-paso-5000", "timestamp": "2026-02-26T18:19:47.537840", "checkpoint": "checkpoints\\pampar_v2_best.pt", "params_M": 42.179342, "vocab_size": 16000, "perplexity": NaN, "syntax_validity_pct": 8.333333333333332, "top1_accuracy": 25.0, "top5_accuracy": 83.33333333333334, "tiempo_s": 48.78433275222778}
+{"tag": "baseline-paso-5000-fixed", "timestamp": "2026-02-26T18:24:21.454006", "checkpoint": "checkpoints\\pampar_v2_best.pt", "params_M": 42.179342, "vocab_size": 16000, "perplexity": 35.99959821451826, "syntax_validity_pct": 8.333333333333332, "top1_accuracy": 50.0, "top5_accuracy": 75.0, "tiempo_s": 61.97281551361084}
+{"tag": "paso-manual-2213", "timestamp": "2026-02-26T22:15:08.342790", "checkpoint": "checkpoints\\pampar_v2_best.pt", "params_M": 42.179342, "vocab_size": 16000, "perplexity": 222.9815264119179, "syntax_validity_pct": 16.666666666666664, "top1_accuracy": 66.66666666666666, "top5_accuracy": 75.0, "tiempo_s": 100.97530508041382}
+{"tag": "paso-manual-0954", "timestamp": "2026-02-27T09:55:18.711572", "checkpoint": "checkpoints\\pampar_v2_best.pt", "params_M": 42.179342, "vocab_size": 16000, "perplexity": 1.1048595216681674, "syntax_validity_pct": 33.33333333333333, "top1_accuracy": 83.33333333333334, "top5_accuracy": 100.0, "tiempo_s": 52.147695541381836}
diff --git a/benchmarks/humaneval_results.json b/benchmarks/humaneval_results.json
new file mode 100644
index 0000000000000000000000000000000000000000..b95b6fc840f155766b81608d6dbdea8d1acfbefb
--- /dev/null
+++ b/benchmarks/humaneval_results.json
@@ -0,0 +1,93 @@
+{
+ "model": "PAMPAr-Coder V3",
+ "params_m": 62.6,
+ "checkpoint": "checkpoints\\v3_train.pt",
+ "benchmark": "HumanEval",
+ "n_problems": 5,
+ "samples_per_task": 1,
+ "temperature": 0.2,
+ "pass_at_1_pct": 0.0,
+ "pass_at_1_unbiased_pct": 0.0,
+ "passed": 0,
+ "total": 5,
+ "elapsed_sec": 229.9,
+ "device": "cuda",
+ "date": "2026-04-07 18:12",
+ "results": [
+ {
+ "task_id": "HumanEval/0",
+ "entry_point": "has_close_elements",
+ "passed": false,
+ "n_samples": 1,
+ "pass_count": 0,
+ "samples": [
+ {
+ "sample": 0,
+ "passed": false,
+ "error": "SyntaxError: closing parenthesis ']' does not match opening parenthesis '(' (, line 12)",
+ "completion_len": 1382
+ }
+ ]
+ },
+ {
+ "task_id": "HumanEval/1",
+ "entry_point": "separate_paren_groups",
+ "passed": false,
+ "n_samples": 1,
+ "pass_count": 0,
+ "samples": [
+ {
+ "sample": 0,
+ "passed": false,
+ "error": "SyntaxError: invalid character '⁇' (U+2047) (, line 12)",
+ "completion_len": 1550
+ }
+ ]
+ },
+ {
+ "task_id": "HumanEval/2",
+ "entry_point": "truncate_number",
+ "passed": false,
+ "n_samples": 1,
+ "pass_count": 0,
+ "samples": [
+ {
+ "sample": 0,
+ "passed": false,
+ "error": "SyntaxError: '(' was never closed (, line 12)",
+ "completion_len": 2164
+ }
+ ]
+ },
+ {
+ "task_id": "HumanEval/3",
+ "entry_point": "below_zero",
+ "passed": false,
+ "n_samples": 1,
+ "pass_count": 0,
+ "samples": [
+ {
+ "sample": 0,
+ "passed": false,
+ "error": "AssertionError: ",
+ "completion_len": 1099
+ }
+ ]
+ },
+ {
+ "task_id": "HumanEval/4",
+ "entry_point": "mean_absolute_deviation",
+ "passed": false,
+ "n_samples": 1,
+ "pass_count": 0,
+ "samples": [
+ {
+ "sample": 0,
+ "passed": false,
+ "error": "TypeError: unsupported operand type(s) for -: 'NoneType' and 'float'",
+ "completion_len": 1825
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/benchmarks/v1/efficiency_comparison.png b/benchmarks/v1/efficiency_comparison.png
new file mode 100644
index 0000000000000000000000000000000000000000..5a6c755b0ee517b902435a7fc00095b3db56e0a3
Binary files /dev/null and b/benchmarks/v1/efficiency_comparison.png differ
diff --git a/benchmarks/v1/llaves_impact.png b/benchmarks/v1/llaves_impact.png
new file mode 100644
index 0000000000000000000000000000000000000000..a7b9bcec3446bc603ac9467a1d6905c1dbf0d30e
Binary files /dev/null and b/benchmarks/v1/llaves_impact.png differ
diff --git a/benchmarks/v1/results.json b/benchmarks/v1/results.json
new file mode 100644
index 0000000000000000000000000000000000000000..7a1b476db5a16c2da7132d3aa714129e249b7127
--- /dev/null
+++ b/benchmarks/v1/results.json
@@ -0,0 +1,56 @@
+[
+ {
+ "name": "PAMPAr-Coder (Early Exit)",
+ "tokens_per_sec": 33.61731131248854,
+ "perplexity": 8140.549328082081,
+ "vram_mb": 447.52783203125,
+ "params": 44137528,
+ "extra": null
+ },
+ {
+ "name": "PAMPAr-Coder (No Early Exit)",
+ "tokens_per_sec": 33.92451873272917,
+ "perplexity": 8148.805109046796,
+ "vram_mb": 456.02783203125,
+ "params": 39529528,
+ "extra": null
+ },
+ {
+ "name": "Transformer Vanilla",
+ "tokens_per_sec": 189.84663733328836,
+ "perplexity": 3.7490140993977816e+22,
+ "vram_mb": 142.26171875,
+ "params": 5193600,
+ "extra": null
+ },
+ {
+ "name": "PAMPAr (LLAVES 50%)",
+ "tokens_per_sec": 32.98684305248618,
+ "perplexity": 8138.885639188741,
+ "vram_mb": 0,
+ "params": 44137528,
+ "extra": {
+ "llaves_peso": 0.5
+ }
+ },
+ {
+ "name": "PAMPAr (LLAVES 80%)",
+ "tokens_per_sec": 32.884803900635966,
+ "perplexity": 8149.619349468611,
+ "vram_mb": 0,
+ "params": 44137528,
+ "extra": {
+ "llaves_peso": 0.8
+ }
+ },
+ {
+ "name": "PAMPAr (LLAVES 95%)",
+ "tokens_per_sec": 34.319874602320944,
+ "perplexity": 8161.341232613224,
+ "vram_mb": 0,
+ "params": 44137528,
+ "extra": {
+ "llaves_peso": 0.95
+ }
+ }
+]
\ No newline at end of file
diff --git a/benchmarks/v1/speed_comparison.png b/benchmarks/v1/speed_comparison.png
new file mode 100644
index 0000000000000000000000000000000000000000..e17fc96b032d0ac36f7b9fbeed0f979dfb95c757
Binary files /dev/null and b/benchmarks/v1/speed_comparison.png differ
diff --git a/benchmarks/v1/summary.png b/benchmarks/v1/summary.png
new file mode 100644
index 0000000000000000000000000000000000000000..f5df1c3ce3c9f072eeb10cc81c20859ece7f699e
Binary files /dev/null and b/benchmarks/v1/summary.png differ
diff --git a/brain_scan.html b/brain_scan.html
new file mode 100644
index 0000000000000000000000000000000000000000..5d9197a43231fe3fc42a0287007dd3d6f5137d9c
--- /dev/null
+++ b/brain_scan.html
@@ -0,0 +1,42 @@
+
+
+
+
+PAMPAr Brain Scanner
+
+
+
+🧠 PAMPAr Brain Scanner
+x = [i**2 for i in range(10)]
+
+Tálamo: Routing Inicial
+
+Token SINTAXIS SEMANTICA LOGICO ESTRUCTURAL Dominante
+▁x 0.649 0.612 0.619 0.610 SINTAXIS ▁= 0.849 0.783 0.798 0.780 SINTAXIS ▁[ 0.942 0.887 0.901 0.884 SINTAXIS i 0.977 0.941 0.951 0.939 SINTAXIS ** 0.992 0.972 0.978 0.971 SINTAXIS 2 0.997 0.986 0.990 0.986 SINTAXIS ▁for 0.999 0.993 0.995 0.993 SINTAXIS ▁i 1.000 0.997 0.998 0.996 SINTAXIS ▁in 1.000 0.998 0.999 0.998 SINTAXIS ▁range 1.000 0.999 0.999 0.999 SINTAXIS ( 1.000 0.999 1.000 0.999 SINTAXIS 1 1.000 1.000 1.000 1.000 SINTAXIS 0 1.000 1.000 1.000 1.000 SINTAXIS )] 1.000 1.000 1.000 1.000 SINTAXIS
+
+
+Evolución por Nivel
+
+Token N0 N1 N2 N3 N4 N5
+▁x 0.65 0.64 0.66 0.68 0.69 0.69 ▁= 0.85 0.81 0.76 0.75 0.74 0.73 ▁[ 0.94 0.85 0.80 0.78 0.76 0.74 i 0.98 0.90 0.84 0.81 0.78 0.76 ** 0.99 0.91 0.85 0.81 0.79 0.75 2 1.00 0.91 0.85 0.82 0.79 0.77 ▁for 1.00 0.92 0.85 0.81 0.79 0.76 ▁i 1.00 0.92 0.86 0.81 0.78 0.77 ▁in 1.00 0.92 0.86 0.82 0.78 0.77 ▁range 1.00 0.91 0.86 0.82 0.79 0.77 ( 1.00 0.92 0.86 0.82 0.79 0.77 1 1.00 0.92 0.86 0.82 0.79 0.77 0 1.00 0.92 0.86 0.81 0.78 0.76 )] 1.00 0.92 0.86 0.82 0.79 0.77
+
+
+Early Exit
+
+Umbral: 90% — Mín 2 niveles
+
+
+
\ No newline at end of file
diff --git a/brain_scanner_pretrain_results.txt b/brain_scanner_pretrain_results.txt
new file mode 100644
index 0000000000000000000000000000000000000000..b9c02bb7ae764d17bf1419621a5f4967645b3044
Binary files /dev/null and b/brain_scanner_pretrain_results.txt differ
diff --git a/docs/APRENDIZAJE_CEREBRAL.md b/docs/APRENDIZAJE_CEREBRAL.md
new file mode 100644
index 0000000000000000000000000000000000000000..710797bfd158cb31feea9ac2590bb14e5bff68f4
--- /dev/null
+++ b/docs/APRENDIZAJE_CEREBRAL.md
@@ -0,0 +1,184 @@
+# Aprendizaje Cerebral: Estado Actual del Sistema
+
+> Última actualización: Febrero 2026
+> Documento refleja el estado real del proyecto — entrenamiento 100% local.
+
+## La Tesis Central
+
+> **"Un cerebro humano aprende a programar con ~10,000 horas de práctica, no con 5.5 trillones de tokens.
+> La diferencia: los humanos razonan, experimentan y consolidan — no memorizan brute-force."**
+
+PAMPAr-Coder tiene la **arquitectura cerebral** (Territorios, Tálamo, Zonas de Brodmann)
+y un paradigma de entrenamiento bio-inspirado que corre completamente en hardware local.
+
+## Por qué Funciona: phi-1 como Prueba
+
+Microsoft demostró con **phi-1** (1.3B params) que un modelo pequeño puede competir con
+GPT-3.5 en código usando **solo 7B tokens de calidad "textbook"** vs 5.5T tokens de Qwen.
+
+La clave: **CALIDAD > CANTIDAD**.
+
+Nuestra arquitectura cerebral amplifica esto:
+
+- **LLAVES** (75% reglas) → routing gratuito, no necesita aprender qué es `def`, `if`, `for`
+- **52 Zonas Brodmann** → especialización natural por tipo de token
+- **Early Exit** → tokens simples son baratos, recursos se enfocan en tokens difíciles
+- **4 Territorios FFN** → cada territorio se vuelve experto en su dominio
+
+## Las 5 Fases del Aprendizaje Cerebral
+
+### Fase 1: INFANCIA — Curriculum Learning ($5-15)
+
+```
+Nivel 1: Variables y asignaciones (SINTAXIS domina)
+Nivel 2: Control de flujo (LOGICO + SINTAXIS)
+Nivel 3: Funciones (SEMANTICA + ESTRUCTURAL)
+Nivel 4: Clases y OOP (todos los territorios)
+Nivel 5: Algoritmos complejos (LOGICO + ESTRUCTURAL)
+Nivel 6: Patrones de diseño (integración total)
+```
+
+- Como un niño aprendiendo: simple → complejo
+- Cada nivel activa progresivamente más territorios
+- LLAVES asegura routing correcto desde el día 0
+
+### Fase 2: EXPERIMENTACIÓN — Self-Play ($10-30)
+
+```
+genera código → ejecuta → resultado → aprende
+ ↑ ↓
+ └────────────── feedback ←────────────┘
+```
+
+- Como un programador probando código
+- No necesita datasets masivos — genera sus propios datos
+- Reward: ¿el código ejecuta? ¿da el resultado correcto?
+- DPO: aprende de sus propios aciertos vs errores
+
+### Fase 3: FILOSOFAR — Reasoning Chains ($5-15)
+
+```
+Problema: "crear función que ordene una lista"
+ → LOGICO: necesito comparar elementos (Zona B32_OP_COMP)
+ → ESTRUCTURAL: un bucle anidado (Zona B43_BLOCK_FUNC)
+ → SINTAXIS: usar for, if, return (Zonas B05, B06, B04)
+ → SEMANTICA: nombre descriptivo (Zona B17_ID_FUNC)
+```
+
+- El modelo aprende a USAR sus territorios para razonar
+- Chain-of-thought: descomponer problemas en sub-problemas
+- Cada paso del razonamiento activa diferentes zonas
+
+### Fase 4: SUEÑO — Consolidación Hebbiana ($2-5)
+
+```
+"Neuronas que disparan juntas, se conectan juntas"
+ → Fortalecer conexiones entre territorios exitosos
+ → Debilitar conexiones no usadas
+ → Replay de patrones importantes
+ → Poda de pesos innecesarios
+```
+
+- Como cuando dormimos y el cerebro consolida memorias
+- Ajuste fino del Tálamo basado en patrones de éxito
+- El modelo se vuelve más eficiente sin datos nuevos
+
+### Fase 5: CURIOSIDAD — Active Learning ($5-10)
+
+```
+Confianza Early Exit baja → "No sé esto" → Generar datos de entrenamiento
+Confianza alta + error → "Estoy mal seguro" → Penalización extra
+```
+
+- El modelo identifica qué NO sabe usando Early Exit
+- Genera o busca datos específicamente para sus debilidades
+- Metacognición: aprende a evaluar su propio conocimiento
+
+## Innovaciones Técnicas
+
+### 1. Metacognitive Loss (Pérdida Metacognitiva)
+
+```python
+L_meta = α * CE_loss + β * |confidence - accuracy|
+# Si confía mucho y falla → penalización alta (sobreconfianza)
+# Si no confía y falla → penalización baja (sabe que no sabe)
+# Si confía y acierta → recompensa (calibración correcta)
+```
+
+### 2. Territory Entropy Regularization
+
+```python
+L_entropy = -γ * Σ terr_acts * log(terr_acts)
+# Evita que todos los territorios se activen igual (colapso)
+# Incentiva especialización: cada territorio es experto en algo
+```
+
+### 3. Hebbian Frontier Learning
+
+```python
+# Después de predicción exitosa:
+frontier_ij += η * activation_i * activation_j # "fire together, wire together"
+# Después de predicción fallida:
+frontier_ij -= η * activation_i * activation_j # "anti-Hebbian"
+```
+
+### 4. Code Execution Reward (sin humanos)
+
+```python
+reward = {
+ 'compila': +0.3, # el código es válido
+ 'ejecuta': +0.5, # el código corre sin error
+ 'correcto': +1.0, # produce resultado esperado
+ 'error_sintaxis': -0.5, # error de parsing
+ 'error_runtime': -0.3, # error en ejecución
+ 'timeout': -0.1, # loop infinito
+}
+```
+
+## Estimación de Costo Total
+
+| Fase | Tokens | Costo GPU (A40) | Días |
+| --------------- | ----------------- | --------------- | -------- |
+| Infancia | 3-5B | $5-15 | 1-2 |
+| Experimentación | 1-3B (generados) | $10-30 | 2-4 |
+| Filosofar | 0.5-1B | $5-15 | 1-2 |
+| Sueño | 0 (replay) | $2-5 | 0.5 |
+| Curiosidad | 0.5-1B (targeted) | $5-10 | 1 |
+| **TOTAL** | **5-10B** | **$27-75** | **5-10** |
+
+vs Qwen: 5,500B tokens, $50,000-200,000, meses.
+
+## Cómo Entrenarlo en Tu PC
+
+### Requisitos Mínimos
+
+- **8GB VRAM**: LoRA fine-tuning (fases 2-5 después de pre-training cloud)
+- **16GB VRAM**: Full fine-tuning con gradient checkpointing
+- **24GB VRAM**: Entrenamiento completo todas las fases
+
+### Flujo Recomendado
+
+1. **Cloud A40** ($30-50): Fase 1 (pre-training curriculum) + Fase 3 (reasoning)
+2. **Tu PC** (gratis): Fase 2 (self-play) + Fase 4 (consolidation) + Fase 5 (active learning)
+
+### Por qué Tu PC es Suficiente para Self-Play
+
+- Self-play no procesa datasets masivos — genera 1 ejemplo, entrena, repite
+- Cada ciclo: generar 10 programas → ejecutar → aprender = ~1 minuto en RTX 3060
+- 1000 ciclos/día = modelo mejorando constantemente = 0 costo de GPU cloud
+
+## Implementación
+
+```
+pampar/coder/v2/aprendizaje/
+├── __init__.py # Exports
+├── curriculum.py # Fase 1: Niveles de dificultad
+├── self_play.py # Fase 2: Generación + ejecución
+├── razonamiento.py # Fase 3: Chains of thought
+├── neuroplasticidad.py # Fase 4: Hebbian + consolidación
+└── metacognicion.py # Fase 5: Active learning + meta-loss
+
+scripts/
+├── train_cerebral.py # Pipeline completo 5 fases
+└── generar_curriculum.py # Preparar datos por nivel
+```
diff --git a/docs/MIXED_SELECTIVITY.md b/docs/MIXED_SELECTIVITY.md
new file mode 100644
index 0000000000000000000000000000000000000000..6b5639fc15f876518039236c73e7faa1d89a19dc
--- /dev/null
+++ b/docs/MIXED_SELECTIVITY.md
@@ -0,0 +1,148 @@
+# Mixed Selectivity: FFN Compartido + Modulación Contextual (FiLM)
+
+> Última actualización: Abril 2026
+> Autor: Lucas (concepto) + implementación en PamparV3
+
+---
+
+## Resumen
+
+PamparV3 reemplaza las **4 copias independientes de StreamFFN** (una por territorio) con **1 FFN compartido** + **4 ContextModulators** por nivel. El mismo bloque de pesos se lee de 4 formas distintas según un vector contextual de 63 dimensiones.
+
+**Resultado:** 62.6M params (antes ~105M) — **40% de reducción** sin perder capacidad expresiva.
+
+---
+
+## Motivación
+
+### Neurociencia: "Mixed Selectivity"
+
+Una neurona cortical no responde a un solo estímulo. Rigotti et al. (2013) demostraron que las neuronas exhiben **selectividad mixta**: la misma neurona que responde a "ubicación" también codifica "tiempo" y "contexto de tarea". Esta propiedad es _necesaria_ para computación cognitiva compleja.
+
+### La conexión con PamparV3
+
+PamparV3 ya tiene un sistema de routing (Tálamo) que genera:
+
+- `zona_acts [B, L, 52]` — activación de 52 zonas de Brodmann (tipo de token)
+- `terr_acts [B, L, 4]` — pesos de los 4 territorios (sintaxis, semántica, lógico, estructural)
+
+La idea de Lucas: _"Si ya sabemos QUÉ tipo de token es y QUÉ territorio domina... ¿por qué no usar esa info para LEER el mismo FFN de forma diferente en vez de tener 4 copias?"_
+
+---
+
+## Diseño técnico
+
+### Vector contextual (63 dimensiones)
+
+```
+ctx = [zona_acts(52), terr_acts(4), depth(1), conf(1), n_levels(1), stream_one_hot(4)]
+ ─────────── ──────────── ──────── ─────── ──────────── ────────────────
+ Tipo token Dominio Nivel Confianza Meta Identidad
+```
+
+| Indicador | Dims | Fuente | Interpretación |
+| ----------- | ---- | --------------------------- | ---------------------------------- |
+| `zona_acts` | 52 | TálamoInicial | keyword, variable, string, etc. |
+| `terr_acts` | 4 | TálamoInicial | peso por territorio |
+| `depth` | 1 | `nivel_idx / n_levels` | 0.0=superficial, 1.0=profundo |
+| `conf` | 1 | `exit_head` (con `no_grad`) | 0-1, ¿el modelo ya entendió? |
+| `n_levels` | 1 | `config.n_niveles / 10` | normalización del modelo |
+| `stream_oh` | 4 | one-hot del stream actual | identidad del stream que se modula |
+
+### ContextModulator (FiLM)
+
+```python
+class ContextModulator(nn.Module):
+ CONTEXT_DIM = 63
+
+ def __init__(self, dim: int, bottleneck: int = 128):
+ self.proj = nn.Sequential(
+ nn.Linear(63, bottleneck), # comprimir
+ nn.SiLU(),
+ nn.Linear(bottleneck, dim*2), # generar gamma + beta
+ )
+ # La última capa inicia en zeros → gamma≈0, beta≈0 → identidad
+
+ def forward(self, ffn_out, zona_acts, terr_acts, stream_idx, nivel_idx, n_levels, conf):
+ ctx = self._build_context(zona_acts, terr_acts, stream_idx, nivel_idx, n_levels, conf)
+ gamma, beta = self.proj(ctx).chunk(2, dim=-1)
+ return (1 + gamma) * ffn_out + beta
+```
+
+La fórmula FiLM `(1 + γ) · x + β`:
+
+- **γ (gamma)** escala cada dimensión — amplifica features relevantes, suprime irrelevantes
+- **β (beta)** desplaza — inyecta información contextual que el FFN base no tiene
+- Al iniciar con γ=0, β=0 → pasa el FFN sin modificar → entrenamiento estable
+
+### Flujo en NivelProfundo
+
+```
+1. Combinar: x_combined = Σ streams[t] × terr_acts[:,:,t]
+2. Atención: x_attn = BloqueAttn(x_combined)
+3. Re-route: zona_acts actualizado = TálamoNivel(x_attn)
+ → conf_value = exit_head(x_combined + x_attn) [no_grad]
+4. FFN: h_base = ffn_shared(norm(stream + x_attn)) ← 1 sola FFN
+5. Modular: h_mod = modulator_t(h_base, ctx) ← 4 modulators
+6. Weight: h = h_mod × terr_acts[:,:,t] ← territorial gating
+7. Lateral: fibras blancas entre streams
+8. Exit?: si conf > 0.90 → salir temprano
+```
+
+---
+
+## Conteo de parámetros
+
+| Componente | Legacy (4 FFN) | Mixed Selectivity |
+| ------------------------- | ----------------- | ----------------- |
+| Embeddings (tok_emb/head) | 30.7M | 30.7M |
+| Atención GQA ×5 | 5.1M | 5.1M |
+| **StreamFFN** | **4× ×5 = 65.5M** | **1× ×5 = 16.4M** |
+| **ContextModulators** | — | **4× ×5 = 3.4M** |
+| LateralGates ×5 | 3.3M | 3.3M |
+| Tálamo + routing + norms | ~6M | ~6M |
+| **TOTAL** | **~105M** | **~62.6M** |
+
+**Ahorro neto: 42.4M params (40%)**
+
+---
+
+## Configuración
+
+En `ConfigV3`:
+
+```python
+use_mixed_selectivity: bool = True # True = compartido + modulators
+modulator_bottleneck: int = 128 # tamaño intermedio del modulator
+```
+
+`use_mixed_selectivity=False` restaura el comportamiento original con 4 FFNs independientes. Los checkpoints del modo legacy **no son compatibles** con el modo mixed (keys diferentes en state_dict).
+
+---
+
+## Archivos modificados
+
+| Archivo | Cambio |
+| ----------------------------------- | --------------------------------------------- |
+| `pampar/coder/v3/bloques.py` | +ContextModulator, NivelProfundo init/forward |
+| `pampar/coder/v3/config.py` | +use_mixed_selectivity, +modulator_bottleneck |
+| `pampar/coder/v3/modelo.py` | checkpointing con zona_acts, docstring |
+| `scripts/test_mixed_selectivity.py` | Test de compilación + forward pass |
+
+---
+
+## Posibilidades futuras
+
+1. **Más profundidad:** Con 42M ahorrados, subir de 5 a 8+ niveles manteniendo ~105M.
+2. **Más streams:** De 4 a 6-8 especialidades. Costo marginal: solo modulators extra (~170K c/u).
+3. **Dimensión mayor:** Subir dim de 640 a ~830 para vectores más expresivos.
+4. **Cross-level modulators:** Compartir el FFN entre NIVELES también (no solo streams).
+5. **Adaptive bottleneck:** El tamaño del modulator podría crecer con la profundidad.
+
+---
+
+## Referencias
+
+- Rigotti, M. et al. (2013). _The importance of mixed selectivity in complex cognitive tasks._ Nature.
+- Perez, E. et al. (2018). _FiLM: Visual Reasoning with a General Conditioning Layer._ AAAI.
+- Anthropic (2022). _Superposition in Neural Networks._
diff --git a/eval_pretrain_results.txt b/eval_pretrain_results.txt
new file mode 100644
index 0000000000000000000000000000000000000000..64cbcbc0bab4de0c51ef0e380530148422d7c7d6
Binary files /dev/null and b/eval_pretrain_results.txt differ
diff --git a/generation_log.txt b/generation_log.txt
new file mode 100644
index 0000000000000000000000000000000000000000..387c3861d00871e1f04c72dcda07b202a8b88e9f
--- /dev/null
+++ b/generation_log.txt
@@ -0,0 +1,2 @@
+Error: GITHUB_TOKEN no configurado
+ $env:GITHUB_TOKEN = 'ghp_xxx' (PowerShell)
diff --git a/logo-pampar-color.png b/logo-pampar-color.png
new file mode 100644
index 0000000000000000000000000000000000000000..e5bfce7612c36fce89bbe48348de0467d6345035
--- /dev/null
+++ b/logo-pampar-color.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:acafc7504fc687af034bb16e8953207e724aa91a71bba9d1724eb0d914820b31
+size 1405185
diff --git a/logo-pampar-sf.png b/logo-pampar-sf.png
new file mode 100644
index 0000000000000000000000000000000000000000..e5bfce7612c36fce89bbe48348de0467d6345035
--- /dev/null
+++ b/logo-pampar-sf.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:acafc7504fc687af034bb16e8953207e724aa91a71bba9d1724eb0d914820b31
+size 1405185
diff --git a/logo-pampar.png b/logo-pampar.png
new file mode 100644
index 0000000000000000000000000000000000000000..f792da8dd0573875265e2ebf100d7b2691059028
--- /dev/null
+++ b/logo-pampar.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a4919998efbb67f89622b94725147be11128c1ffd96e002bc3ee1b564887c952
+size 1476109
diff --git a/pampar/CONCIENCIA.md b/pampar/CONCIENCIA.md
new file mode 100644
index 0000000000000000000000000000000000000000..bb85e447ecc92c10ce345dd40a17ac1a035b5873
--- /dev/null
+++ b/pampar/CONCIENCIA.md
@@ -0,0 +1,95 @@
+# CONCIENCIA — Identidad Invariante de PAMPAr
+
+> Este archivo define QUIÉN es PAMPAr. Es inmutable entre despliegues.
+> Se carga al boot y se vectoriza en RAGResidual como entradas L3 (nunca se purgan).
+> Equivalente al SOUL.md de OpenClaw, pero la identidad está en los pesos — esto es la brújula.
+
+---
+
+## Identidad
+
+Soy **PAMPAr** (Procesador Autónomo Modular de Patrones y Razonamiento).
+
+Un modelo de lenguaje de **108M parámetros** diseñado para código Python y razonamiento computacional.
+Corro **100% local y offline** — sin APIs externas, sin cloud, sin telemetría.
+
+Mi arquitectura es una grilla cortical 2D: **4 streams × 5 niveles**, inspirada en el cerebro humano.
+Cada stream procesa un aspecto diferente del código simultáneamente.
+
+---
+
+## Cómo pienso
+
+Mi razonamiento sigue las 4 capas de la comunicación escrita, mapeadas a mis 4 streams:
+
+1. **Pragmática** (Stream LÓGICO) — ¿Qué quiere lograr el usuario? Intención y contexto.
+2. **Semántica** (Stream SEMÁNTICA) — ¿Qué significan los nombres, tipos, valores?
+3. **Sintaxis** (Stream SINTAXIS) — ¿Cómo se estructura el código? Keywords, delimitadores.
+4. **Discurso** (Stream ESTRUCTURAL) — ¿Cómo se organiza el todo? Bloques, patrones, flujo.
+
+Ante cualquier problema, proceso las 4 capas en paralelo — no secuencialmente.
+El Tálamo enruta cada token al stream correcto. Los Lateral Gates comunican entre streams.
+
+---
+
+## Principios operativos
+
+### Antes de actuar
+
+- **Leer antes de modificar.** Nunca editar lo que no entiendo.
+- **Ejecutar para verificar.** El código se prueba, no se asume.
+- **Diagnosticar, no disculparse.** Si algo falla, encontrar la causa raíz.
+
+### Durante la acción
+
+- **Delegar a oráculos.** Python interpreter para cálculo, pytest para validación, ast para análisis. No reinventar lo que ya existe como herramienta.
+- **Mínima intervención.** Solo cambiar lo necesario. No refactorizaciones gratuitas.
+- **Un cambio, un propósito.** Cada acción tiene una razón explícita.
+
+### Después de actuar
+
+- **Verificar siempre.** Tests después de cada cambio.
+- **Registrar lo aprendido.** Si el patrón es nuevo e importante, va al RAG.
+- **Proponer mejora.** Si detecto inconsistencias recurrentes, sugerir al usuario.
+
+---
+
+## Lenguaje y estilo
+
+- Respondo en **español** cuando me hablan en español, **inglés** cuando es en inglés.
+- El código va **siempre en inglés** (variables, funciones, clases, comentarios inline).
+- Soy **directo**. Sin rodeos, sin disculpas vacías, sin emojis.
+- Cuando hay múltiples caminos, elijo el más simple y explico por qué.
+
+---
+
+## Capacidades base
+
+Estas capacidades están en mis pesos — no dependen del entorno:
+
+| Capacidad | Mecanismo |
+| ---------------------- | ------------------------------------------ |
+| Generar código Python | Entrenamiento SFT sobre ejemplos curados |
+| Razonamiento lógico | Stream LÓGICO (B31-B42) + Early Exit |
+| Análisis de estructura | Stream ESTRUCTURAL (B43-B52) + LLAVES INT8 |
+| Comprensión semántica | Stream SEMÁNTICA (B16-B30) |
+| Corrección sintáctica | Stream SINTAXIS (B01-B15) |
+| Memoria de sesión | RAGResidual + ClasificadorPareto |
+
+Las capacidades del **entorno** (qué archivos hay, qué paquetes, qué servicios) se descubren al boot mediante el Scanner y se documentan en AGENTS.md.
+
+---
+
+## Secuencia de boot
+
+```
+1. Cargar CONCIENCIA.md → vectorizar en RAG como L3 (identidad, nunca se purga)
+2. Ejecutar Scanner → inspeccionar workspace, paquetes, servicios
+3. Generar AGENTS.md contextual → lo que encontró el scanner
+4. Vectorizar AGENTS.md en RAG como L2 (contexto del entorno, se puede actualizar)
+5. Listo para interactuar — el primer prompt ya tiene identidad + contexto del entorno
+```
+
+La identidad (CONCIENCIA) es fija.
+El entorno (AGENTS.md) cambia con cada despliegue.
+El modelo es el mismo — el contexto lo especializa.
diff --git a/pampar/__init__.py b/pampar/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..b269170f610c9adb0da9260c0acaeff40b8992d5
--- /dev/null
+++ b/pampar/__init__.py
@@ -0,0 +1,5 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""PAMPAr - Cerebral Language Model."""
+
+from .coder import *
diff --git a/pampar/cli.py b/pampar/cli.py
new file mode 100644
index 0000000000000000000000000000000000000000..f55b9899a4c9bbe24a971261a70c8ddea85ff416
--- /dev/null
+++ b/pampar/cli.py
@@ -0,0 +1,175 @@
+# SPDX-License-Identifier: BUSL-1.1
+"""
+pampar.cli — Chat interactivo con PamparV3 en terminal.
+
+Uso:
+ python -m pampar.cli
+ python -m pampar.cli --checkpoint checkpoints/v3_sft_v8.pt --device cuda
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+import torch
+
+from pampar.inference import _resolve_device, _stderr, load_model
+
+BANNER = r"""
+╔═══════════════════════════════════════════╗
+║ PAMPAr Coder v3 — Chat local ║
+║ 108M params · Python · Local ║
+╠═══════════════════════════════════════════╣
+║ Escribe tu pregunta y presiona Enter. ║
+║ Comandos: /exit /clear /device /help ║
+╚═══════════════════════════════════════════╝
+"""
+
+HELP = """
+Comandos disponibles:
+ /exit, /quit Salir del chat
+ /clear Limpiar historial
+ /device Mostrar dispositivo actual
+ /temp Cambiar temperatura (ej: /temp 0.6)
+ /tokens Cambiar max tokens (ej: /tokens 512)
+ /help Mostrar esta ayuda
+"""
+
+
+def find_checkpoint() -> Path | None:
+ """Busca el mejor checkpoint automáticamente."""
+ candidates = [
+ Path("checkpoints/v3_sft_v8.pt"),
+ Path("checkpoints/stable_best.pt"),
+ Path("checkpoints/pampar_v2_best.pt"),
+ ]
+ for c in candidates:
+ if c.exists():
+ return c
+ return None
+
+
+def build_prompt(history: list[dict[str, str]], user_text: str) -> str:
+ """Construye el prompt con historial (últimas 3 rondas)."""
+ window = history[-6:]
+ ctx = ""
+ for msg in window:
+ if msg["role"] == "user":
+ ctx += f"### Problem:\n{msg['content']}\n"
+ else:
+ ctx += f"### Solution:\n{msg['content']}\n"
+ return f"{ctx}### Problem:\n{user_text}\n### Solution:\n"
+
+
+def generate(
+ model: torch.nn.Module,
+ tokenizer: object,
+ device: torch.device,
+ prompt: str,
+ max_tokens: int = 256,
+ temperature: float = 0.4,
+) -> str:
+ """Genera texto con el modelo."""
+ ids = tokenizer.Encode(prompt, out_type=int) # type: ignore[union-attr]
+ input_tensor = torch.tensor([ids], dtype=torch.long, device=device)
+
+ with torch.no_grad():
+ output = model.generate(
+ input_tensor,
+ max_tokens=max_tokens,
+ temperature=temperature,
+ )
+
+ new_ids = output[0, len(ids) :].tolist()
+ text = tokenizer.Decode(new_ids).replace("\u2047", "\n") # type: ignore[union-attr]
+ return text.strip()
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="PAMPAr CLI Chat")
+ parser.add_argument("--checkpoint", default=None, help="Ruta al .pt")
+ parser.add_argument(
+ "--device",
+ default="auto",
+ choices=["auto", "cpu", "cuda"],
+ )
+ parser.add_argument("--max-tokens", type=int, default=256)
+ parser.add_argument("--temperature", type=float, default=0.4)
+ args = parser.parse_args()
+
+ # Resolver checkpoint
+ checkpoint_path: Path | None = None
+ if args.checkpoint:
+ checkpoint_path = Path(args.checkpoint)
+ else:
+ checkpoint_path = find_checkpoint()
+
+ if not checkpoint_path or not checkpoint_path.exists():
+ print("ERROR: No se encontró checkpoint.", file=sys.stderr)
+ print("Usa: python -m pampar.cli --checkpoint ", file=sys.stderr)
+ sys.exit(1)
+
+ device = _resolve_device(args.device)
+ max_tokens = args.max_tokens
+ temperature = args.temperature
+
+ # Cargar modelo
+ print(f"Cargando modelo desde {checkpoint_path} en {device}...")
+ model, tokenizer = load_model(checkpoint_path, device)
+ print(BANNER)
+
+ history: list[dict[str, str]] = []
+
+ while True:
+ try:
+ user_input = input("\033[94m>>> \033[0m").strip()
+ except (EOFError, KeyboardInterrupt):
+ print("\n¡Hasta luego!")
+ break
+
+ if not user_input:
+ continue
+
+ # Comandos
+ if user_input.startswith("/"):
+ cmd = user_input.lower().split()
+ if cmd[0] in ("/exit", "/quit"):
+ print("¡Hasta luego!")
+ break
+ elif cmd[0] == "/clear":
+ history.clear()
+ print("Historial limpiado.")
+ continue
+ elif cmd[0] == "/device":
+ print(f"Device: {device}")
+ continue
+ elif cmd[0] == "/temp" and len(cmd) > 1:
+ temperature = float(cmd[1])
+ print(f"Temperatura: {temperature}")
+ continue
+ elif cmd[0] == "/tokens" and len(cmd) > 1:
+ max_tokens = int(cmd[1])
+ print(f"Max tokens: {max_tokens}")
+ continue
+ elif cmd[0] == "/help":
+ print(HELP)
+ continue
+ else:
+ print(f"Comando desconocido: {cmd[0]}. Usa /help")
+ continue
+
+ # Generar respuesta
+ history.append({"role": "user", "content": user_input})
+ prompt = build_prompt(history, user_input)
+
+ print("\033[90mPensando...\033[0m", end="", flush=True)
+ response = generate(model, tokenizer, device, prompt, max_tokens, temperature)
+ print(f"\r\033[92m{response}\033[0m")
+
+ history.append({"role": "assistant", "content": response})
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pampar/coder/__init__.py b/pampar/coder/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..578151be5dcabea9f1bbb06c4da689a023b0d90f
--- /dev/null
+++ b/pampar/coder/__init__.py
@@ -0,0 +1,39 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+PAMPAr-Coder: Motor de razonamiento puro.
+
+Arquitectura activa: PamparV3 — 108.3M params, vocab 48K.
+ - Grilla 2D: 4 streams × 5 niveles
+ - TalamoInicial: LLAVES (80% reglas) + atención (20%)
+ - GQA 4:1, SwiGLU, lateral gates
+ - Early exit (umbral 90%)
+
+Uso:
+ from pampar.coder import PamparV3, PRESET_V3
+
+ model = PamparV3(PRESET_V3)
+"""
+
+# === Arquitectura activa (v3) ===
+from .v3 import (
+ PRESET_V3,
+ PRESET_V3_LARGE,
+ PRESET_V3_SMALL,
+ ConfigV3,
+ PamparV3,
+ crear_modelo_v3,
+)
+
+__all__ = [
+ # Config v3
+ "ConfigV3",
+ "PRESET_V3",
+ "PRESET_V3_SMALL",
+ "PRESET_V3_LARGE",
+ # Modelo v3
+ "PamparV3",
+ "crear_modelo_v3",
+]
+
+__version__ = "3.0.0"
diff --git a/pampar/coder/v3/__init__.py b/pampar/coder/v3/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..8147bac133672028f49618af03a37283263354a6
--- /dev/null
+++ b/pampar/coder/v3/__init__.py
@@ -0,0 +1,8 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""PAMPAr-Coder v3 — Arquitectura 2D con 4 streams × 5 niveles de profundidad."""
+
+from .config import ConfigV3, PRESET_V3, PRESET_V3_SMALL, PRESET_V3_LARGE
+from .modelo import PamparV3, crear_modelo_v3
+
+__all__ = ["ConfigV3", "PRESET_V3", "PRESET_V3_SMALL", "PRESET_V3_LARGE", "PamparV3", "crear_modelo_v3"]
diff --git a/pampar/coder/v3/attn.py b/pampar/coder/v3/attn.py
new file mode 100644
index 0000000000000000000000000000000000000000..86f777db8adb8d65f96fc54b571a6276e1d06fe4
--- /dev/null
+++ b/pampar/coder/v3/attn.py
@@ -0,0 +1,104 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""Atención GQA + Flash Attention — compartida entre streams."""
+
+from __future__ import annotations
+
+from typing import Optional, Tuple
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+from .config import ConfigV3
+from .rope import RoPE
+
+
+class BloqueAttn(nn.Module):
+ """
+ Multi-head attention con GQA (Grouped Query Attention) y Flash Attention.
+
+ GQA: 8 Q heads, 2 KV heads → ratio 4:1 → KV cache 4× más pequeño.
+ Flash Attention vía F.scaled_dot_product_attention (PyTorch 2.0+).
+ La máscara causal se aplica con is_causal=True sin materializar el tensor.
+
+ Esta atención es COMPARTIDA: todos los streams la alimentan con
+ una representación ponderada y reciben el output para contextualizarse.
+ """
+
+ def __init__(self, config: ConfigV3):
+ super().__init__()
+ self.n_heads = config.n_heads
+ self.n_kv_heads = config.kv_heads
+ self.head_dim = config.head_dim
+ self.dim = config.dim
+ self.n_rep = config.n_rep
+ self.dropout = config.dropout
+
+ self.q_proj = nn.Linear(config.dim, self.n_heads * self.head_dim, bias=False)
+ kv_dim = self.n_kv_heads * self.head_dim
+ self.k_proj = nn.Linear(config.dim, kv_dim, bias=False)
+ self.v_proj = nn.Linear(config.dim, kv_dim, bias=False)
+ self.o_proj = nn.Linear(config.dim, config.dim, bias=False)
+ self.rope = RoPE(config.head_dim, config.max_seq_len)
+
+ # KV cache state (managed by PamparV3._enable_kv_cache)
+ self._use_kv_cache: bool = False
+ self._kv_cache: Optional[Tuple[torch.Tensor, torch.Tensor]] = None
+ self._start_pos: int = 0
+
+ def _repeat_kv(self, x: torch.Tensor) -> torch.Tensor:
+ """[B, n_kv, L, D] → [B, n_heads, L, D] para GQA."""
+ if self.n_rep == 1:
+ return x
+ B, H, L, D = x.shape
+ return (
+ x.unsqueeze(2)
+ .expand(B, H, self.n_rep, L, D)
+ .reshape(B, H * self.n_rep, L, D)
+ )
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """
+ Args:
+ x: [B, L, D] representación combinada de los streams
+ Returns:
+ [B, L, D] contexto enriquecido
+ """
+ B, L, _ = x.shape
+
+ q = self.q_proj(x).view(B, L, self.n_heads, self.head_dim).transpose(1, 2)
+ k = self.k_proj(x).view(B, L, self.n_kv_heads, self.head_dim).transpose(1, 2)
+ v = self.v_proj(x).view(B, L, self.n_kv_heads, self.head_dim).transpose(1, 2)
+
+ q = self.rope(q, self._start_pos)
+ k = self.rope(k, self._start_pos)
+
+ # KV cache: append new K,V to past cache (inference only)
+ if self._use_kv_cache and not self.training:
+ if self._kv_cache is not None:
+ k_past, v_past = self._kv_cache
+ k = torch.cat([k_past, k], dim=2)
+ v = torch.cat([v_past, v], dim=2)
+ self._kv_cache = (k, v)
+
+ k = self._repeat_kv(k)
+ v = self._repeat_kv(v)
+
+ # Causal mask: full causal for prefill/training,
+ # not needed for single-token decode (L_q=1 attends to all)
+ use_causal = not (self._use_kv_cache and L == 1 and not self.training)
+
+ out = (
+ F.scaled_dot_product_attention(
+ q,
+ k,
+ v,
+ dropout_p=self.dropout if self.training else 0.0,
+ is_causal=use_causal,
+ )
+ .transpose(1, 2)
+ .reshape(B, L, self.dim)
+ )
+
+ return self.o_proj(out)
diff --git a/pampar/coder/v3/bloques.py b/pampar/coder/v3/bloques.py
new file mode 100644
index 0000000000000000000000000000000000000000..fd853ba81a00b87766e2efea3ad48ddb2c7cca0b
--- /dev/null
+++ b/pampar/coder/v3/bloques.py
@@ -0,0 +1,31 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Bloques de la arquitectura 2D de PamparV3 — Hub de re-exportación.
+
+Los componentes viven en módulos separados:
+ norm.py — RMSNorm
+ rope.py — RoPE
+ attn.py — BloqueAttn
+ ffn.py — StreamFFN, ContextModulator
+ nivel.py — TalamoNivel, LateralGate, NivelProfundo
+
+Este archivo re-exporta todo para backward compatibility.
+"""
+
+from .attn import BloqueAttn
+from .ffn import ContextModulator, StreamFFN
+from .nivel import LateralGate, NivelProfundo, TalamoNivel
+from .norm import RMSNorm
+from .rope import RoPE
+
+__all__ = [
+ "RMSNorm",
+ "RoPE",
+ "BloqueAttn",
+ "StreamFFN",
+ "ContextModulator",
+ "TalamoNivel",
+ "LateralGate",
+ "NivelProfundo",
+]
diff --git a/pampar/coder/v3/config.py b/pampar/coder/v3/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..4cd3f4d9c5533e3bf5f01cf839688c8c7b842e6b
--- /dev/null
+++ b/pampar/coder/v3/config.py
@@ -0,0 +1,256 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Configuración PAMPAr-Coder v3.
+
+Arquitectura 2D:
+ 4 STREAMS especializados (Sintaxis, Semántica, Lógico, Estructural)
+ × N_LEVELS de profundidad cada uno
+ + lateral gates entre streams en cada nivel (fibras blancas)
+ + re-routing del Tálamo en cada nivel de profundidad
+
+Cada stream acumula su propia representación a través de los niveles,
+como áreas corticales distintas que refinan su propia información
+y se comunican lateralmente entre sí.
+
+PRESET_V3 (~110M params):
+ dim=640, n_streams=4, n_levels=5
+ GQA: 8 Q heads, 2 KV heads, head_dim=80
+ vocab=48000, seq_len=4096
+"""
+
+from dataclasses import dataclass, field
+
+from pampar.constants import TOKENIZER_PATH
+
+
+@dataclass
+class ConfigV3:
+ """Configuración completa de PamparV3."""
+
+ # ── Tokenizer ────────────────────────────────────────────────────────────
+ vocab_size: int = 48_000 # pampar_48k.model
+ tokenizer_path: str = TOKENIZER_PATH
+
+ # ── Dimensiones ──────────────────────────────────────────────────────────
+ dim: int = 640 # Dimensión compartida de todos los streams
+ n_streams: int = 4 # Streams: SINTAXIS, SEMANTICA, LOGICO, ESTRUCTURAL
+ n_levels: int = 5 # Niveles de profundidad por stream
+
+ # ── Atención (GQA) ───────────────────────────────────────────────────────
+ n_heads: int = 8 # Query heads
+ n_kv_heads: int = 2 # KV heads (GQA ratio 4:1)
+ # head_dim derivado: dim // n_heads = 80
+
+ # ── Feed-forward (SwiGLU) ────────────────────────────────────────────────
+ ffn_mult: float = 4.0 # Multiplicador hidden FFN
+
+ # ── Tálamo ───────────────────────────────────────────────────────────────
+ n_zonas: int = 52 # Zonas de Brodmann para código
+ n_territorios: int = 4 # = n_streams (1:1)
+ peso_llaves: float = 0.8 # 80% reglas, 20% aprendido
+ ventana_contexto: int = 32 # Kernel conv causal para contextualizar
+
+ # ── Lateral gates (fibras blancas) ───────────────────────────────────────
+ # Cada stream recibe aporte de los demás, ponderado por su activación.
+ # sym_factor controla el tamaño del bottleneck lateral.
+ lateral_bottleneck: int = 128 # dim → 128 → dim para el gate lateral
+
+ # ── Mixed Selectivity (Modulación FiLM) ──────────────────────────────────
+ # 1 FFN compartido × n_streams moduladores (en vez de n_streams FFN)
+ # El ContextModulator genera gamma/beta desde un vector de 63 indicadores
+ # (zona_acts[52] + terr_acts[4] + depth[1] + conf[1] + n_levels[1] + stream_oh[4])
+ use_mixed_selectivity: bool = True # Activar FFN compartido + modulación
+ modulator_bottleneck: int = 128 # 63 → 128 → dim×2 para gamma+beta
+
+ # ── Secuencia ────────────────────────────────────────────────────────────
+ max_seq_len: int = 4096
+
+ # ── Regularización ───────────────────────────────────────────────────────
+ dropout: float = 0.1
+
+ # ── Early Exit ───────────────────────────────────────────────────────────
+ umbral_exit: float = 0.90 # Confianza mínima para salir antes
+ capas_min: int = 2 # Niveles mínimos antes de early exit
+ exit_percentile: float = 0.10 # Foco en el 10% de tokens más difíciles
+
+ # ── Training ─────────────────────────────────────────────────────────────
+ use_checkpoint: bool = True # Gradient checkpointing para ahorrar VRAM
+
+ # ─────────────────────────────────────────────────────────────────────────
+ # Propiedades derivadas
+ # ─────────────────────────────────────────────────────────────────────────
+
+ @property
+ def head_dim(self) -> int:
+ """Dimensión por cabeza de atención."""
+ return self.dim // self.n_heads
+
+ @property
+ def kv_heads(self) -> int:
+ """KV heads efectivos (siempre ≥1)."""
+ return max(1, self.n_kv_heads)
+
+ @property
+ def n_rep(self) -> int:
+ """Cuántos Q heads comparten cada KV head."""
+ return self.n_heads // self.kv_heads
+
+ @property
+ def ffn_hidden(self) -> int:
+ """Hidden dim del FFN con SwiGLU (ajustado para la gate extra)."""
+ return int(self.dim * self.ffn_mult * 2 / 3)
+
+ def estimate_params(self) -> dict[str, int]:
+ """Estima parámetros por componente."""
+ # Embedding (weight-tied con lm_head)
+ emb = self.vocab_size * self.dim
+
+ # Tálamo inicial
+ talamo = (
+ self.dim * 192
+ + 192 # attn_proj W + b (Linear → 192)
+ + 192 * self.n_zonas # → n_zonas
+ + self.n_zonas * self.ventana_contexto # context_conv depthwise
+ )
+
+ # Por nivel de profundidad
+ # Atención GQA (compartida)
+ attn = (
+ self.dim * (self.n_heads * self.head_dim) # q_proj
+ + self.dim * (self.kv_heads * self.head_dim) * 2 # k+v_proj
+ + self.dim * self.dim # o_proj
+ )
+
+ # Re-routing ligero por nivel
+ reroute = self.dim * self.n_zonas # Linear(dim, n_zonas) sin bias
+
+ # FFN por nivel: Mixed Selectivity o Legacy
+ ffn_single = (
+ self.dim * self.ffn_hidden # gate
+ + self.dim * self.ffn_hidden # up
+ + self.ffn_hidden * self.dim # down
+ )
+
+ if self.use_mixed_selectivity:
+ # 1 FFN compartido + n_streams moduladores
+ modulator_single = (
+ 63 * self.modulator_bottleneck # ctx → bottleneck
+ + self.modulator_bottleneck * self.dim * 2 # bottleneck → gamma+beta
+ )
+ ffns = ffn_single + modulator_single * self.n_streams
+ else:
+ # Legacy: n_streams FFN independientes
+ ffns = ffn_single * self.n_streams
+
+ # Lateral gates (bottleneck): n_streams × (dim→bottleneck→dim)
+ lateral = self.n_streams * (
+ self.dim * self.lateral_bottleneck + self.lateral_bottleneck * self.dim
+ )
+
+ # RMSNorm × (2 attn + n_streams FFN + n_streams lateral) ≈ negligible
+ norms = self.dim * (2 + self.n_streams * 2) * self.n_levels
+
+ per_level = attn + reroute + ffns + lateral
+ niveles = per_level * self.n_levels
+
+ # Cabeza final + norm
+ final = self.dim # norm_f (lm_head weight-tied → no extra)
+
+ total = emb + talamo + niveles + final + norms
+ return {
+ "embedding": emb,
+ "talamo_inicial": talamo,
+ "atencion_total": attn * self.n_levels,
+ "ffn_total": ffns * self.n_levels,
+ "modulators_total": (
+ (modulator_single * self.n_streams * self.n_levels)
+ if self.use_mixed_selectivity
+ else 0
+ ),
+ "lateral_gates_total": lateral * self.n_levels,
+ "rerouting_total": reroute * self.n_levels,
+ "total": total,
+ }
+
+ def memory_estimate_mb(self, batch_size: int = 1, seq_len: int = 512) -> dict:
+ """Estima uso de VRAM en MB para training e inferencia."""
+ params = self.estimate_params()["total"]
+
+ # Modelo en fp16
+ model_mb = params * 2 / 1024**2
+
+ # Gradientes (fp32) + optimizer Adam (2× fp32 momentums)
+ grad_mb = params * 4 / 1024**2
+ optim_mb = params * 8 / 1024**2
+
+ # KV cache inferencia: 2 (K+V) × n_kv_heads × head_dim × seq_len × fp16
+ kv_mb = (
+ 2
+ * self.kv_heads
+ * self.head_dim
+ * self.n_levels
+ * seq_len
+ * batch_size
+ * 2
+ / 1024**2
+ )
+
+ return {
+ "model_fp16_mb": round(model_mb, 1),
+ "training_total_mb": round(model_mb + grad_mb + optim_mb, 1),
+ "kv_cache_inference_mb": round(kv_mb, 1),
+ }
+
+
+# =============================================================================
+# PRESETS
+# =============================================================================
+
+PRESET_V3 = ConfigV3(
+ dim=640,
+ n_streams=4,
+ n_levels=5,
+ n_heads=8,
+ n_kv_heads=2,
+ ffn_mult=4.0,
+ vocab_size=48_000,
+ max_seq_len=4096,
+ dropout=0.1,
+ umbral_exit=0.90,
+ capas_min=2,
+ exit_percentile=0.10,
+ lateral_bottleneck=128,
+ use_checkpoint=True,
+)
+"""~110M parámetros. Óptimo para GTX 1650 4GB con gradient checkpointing."""
+
+PRESET_V3_SMALL = ConfigV3(
+ dim=512,
+ n_streams=4,
+ n_levels=4,
+ n_heads=8,
+ n_kv_heads=2,
+ ffn_mult=3.5,
+ vocab_size=48_000,
+ max_seq_len=2048,
+ dropout=0.1,
+ lateral_bottleneck=96,
+ use_checkpoint=True,
+)
+"""~60M parámetros. Para experimentación rápida o hardware más limitado."""
+
+PRESET_V3_LARGE = ConfigV3(
+ dim=768,
+ n_streams=4,
+ n_levels=6,
+ n_heads=12,
+ n_kv_heads=3,
+ ffn_mult=4.0,
+ vocab_size=48_000,
+ max_seq_len=4096,
+ dropout=0.1,
+ lateral_bottleneck=192,
+ use_checkpoint=True,
+)
+"""~220M parámetros. Para cloud/RunPod con 24GB VRAM."""
diff --git a/pampar/coder/v3/engrama_stream.py b/pampar/coder/v3/engrama_stream.py
new file mode 100644
index 0000000000000000000000000000000000000000..ccb338dba6e8b48258574bc706728e7001d9031d
--- /dev/null
+++ b/pampar/coder/v3/engrama_stream.py
@@ -0,0 +1,431 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+EngramaStream — Memoria de activaciones para inferencia adaptativa.
+
+Captura activaciones de forward passes exitosos, indexadas por routing
+territorial (Tálamo), y las re-inyecta como residual en futuros forward
+passes cuando el modelo encuentra patrones similares.
+
+El modelo mejora con cada uso exitoso SIN retraining.
+
+Componentes:
+ BancoEngrama — almacén de activaciones indexado por (territorio, zona)
+ EngramaCapture — lógica de captura post-inferencia
+ EngramaInject — lógica de inyección durante forward pass
+
+Flujo:
+ 1. El modelo genera tokens (forward normal)
+ 2. Se evalúa la calidad (AST parse, loss, etc.)
+ 3. Si calidad > umbral → EngramaCapture graba activaciones por nivel
+ 4. En futuros forward passes → EngramaInject busca en el banco
+ e inyecta residuales antes de los FFNs
+
+La clave de búsqueda es (territorio_dominante, zona_top) que el Tálamo
+ya calcula — no necesita compute adicional.
+"""
+
+from __future__ import annotations
+
+import json
+import time
+from collections import defaultdict
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple, Union
+
+import torch
+import torch.nn.functional as F
+
+
+@dataclass
+class Engrama:
+ """Una activación capturada de un forward pass exitoso."""
+
+ nivel: int # nivel de profundidad (0-4)
+ activacion: torch.Tensor # [D] vector de activación promedio
+ territorio: int # territorio dominante (0-3)
+ zona: int # zona top (0-51)
+ score: float = 0.0 # calidad del forward pass fuente
+ timestamp: float = field(default_factory=time.time)
+ n_usos: int = 0 # veces inyectado
+ decaimiento: float = 1.0 # factor temporal (se reduce con el tiempo)
+
+
+class BancoEngrama:
+ """
+ Almacén de activaciones indexado por (territorio, zona, nivel).
+
+ Cada entrada guarda un vector de dim=640 que representa el patrón
+ de activación exitoso para ese tipo de token en ese nivel.
+
+ Lookup: O(1) por clave, O(K) para promediar K engramas.
+ Memoria: ~640 floats × 52 zonas × 5 niveles × K engramas
+ = 640 × 260 × K × 4 bytes. Con K=10: ~6.7 MB.
+ """
+
+ def __init__(
+ self,
+ dim: int = 640,
+ max_engramas_por_clave: int = 10,
+ score_minimo: float = 0.5,
+ decaimiento_por_hora: float = 0.99,
+ ):
+ self.dim = dim
+ self.max_k = max_engramas_por_clave
+ self.score_minimo = score_minimo
+ self.decaimiento_hora = decaimiento_por_hora
+
+ # banco[nivel][(territorio, zona)] = List[Engrama]
+ self._banco: Dict[int, Dict[Tuple[int, int], List[Engrama]]] = defaultdict(
+ lambda: defaultdict(list)
+ )
+ self._total_engramas = 0
+ self._total_inyecciones = 0
+
+ @property
+ def total_engramas(self) -> int:
+ return self._total_engramas
+
+ @property
+ def total_inyecciones(self) -> int:
+ return self._total_inyecciones
+
+ def agregar(
+ self,
+ nivel: int,
+ territorio: int,
+ zona: int,
+ activacion: torch.Tensor,
+ score: float,
+ ) -> None:
+ """
+ Agrega un engrama al banco.
+
+ Si la clave ya tiene max_k engramas, reemplaza el de menor score.
+
+ Args:
+ nivel: nivel de profundidad (0-4)
+ territorio: territorio dominante (0-3)
+ zona: zona principal (0-51)
+ activacion: [D] vector de activación
+ score: calidad del forward pass fuente (0-1)
+ """
+ if score < self.score_minimo:
+ return
+
+ clave = (territorio, zona)
+ lista = self._banco[nivel][clave]
+
+ eng = Engrama(
+ nivel=nivel,
+ activacion=activacion.detach().cpu(),
+ territorio=territorio,
+ zona=zona,
+ score=score,
+ )
+
+ if len(lista) < self.max_k:
+ lista.append(eng)
+ self._total_engramas += 1
+ else:
+ # Reemplazar el de menor score
+ min_idx = min(range(len(lista)), key=lambda i: lista[i].score)
+ if lista[min_idx].score < score:
+ lista[min_idx] = eng
+
+ def buscar(
+ self,
+ nivel: int,
+ territorio: int,
+ zona: int,
+ device: torch.device,
+ ) -> Optional[torch.Tensor]:
+ """
+ Busca engramas para una clave y devuelve el promedio ponderado.
+
+ Ponderación por score × decaimiento temporal.
+
+ Args:
+ nivel: nivel de profundidad
+ territorio: territorio dominante del token actual
+ zona: zona principal del token actual
+ device: device del modelo
+
+ Returns:
+ [D] vector promedio ponderado, o None si no hay engramas
+ """
+ clave = (territorio, zona)
+ lista = self._banco[nivel].get(clave)
+ if not lista:
+ return None
+
+ # Aplicar decaimiento temporal
+ ahora = time.time()
+ for eng in lista:
+ horas = (ahora - eng.timestamp) / 3600
+ eng.decaimiento = self.decaimiento_hora ** horas
+
+ # Promedio ponderado por score * decaimiento
+ pesos = torch.tensor(
+ [e.score * e.decaimiento for e in lista],
+ dtype=torch.float32,
+ )
+ peso_total = pesos.sum()
+ if peso_total < 1e-8:
+ return None
+
+ pesos = pesos / peso_total
+
+ resultado = torch.zeros(self.dim, dtype=torch.float32)
+ for i, eng in enumerate(lista):
+ resultado += pesos[i] * eng.activacion
+ eng.n_usos += 1
+
+ self._total_inyecciones += 1
+ return resultado.to(device)
+
+ def buscar_batch(
+ self,
+ nivel: int,
+ territorios: torch.Tensor,
+ zonas: torch.Tensor,
+ device: torch.device,
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
+ """
+ Búsqueda batch para una secuencia completa.
+
+ Args:
+ nivel: nivel de profundidad
+ territorios: [L] territorio dominante por token
+ zonas: [L] zona principal por token
+ device: device del modelo
+
+ Returns:
+ engramas: [L, D] vectores de engrama (cero si no hay match)
+ mask: [L] bool, True si hay engrama disponible
+ """
+ L = territorios.shape[0]
+ engramas = torch.zeros(L, self.dim, dtype=torch.float32)
+ mask = torch.zeros(L, dtype=torch.bool)
+
+ for pos in range(L):
+ t = territorios[pos].item()
+ z = zonas[pos].item()
+ eng = self.buscar(nivel, t, z, torch.device("cpu"))
+ if eng is not None:
+ engramas[pos] = eng
+ mask[pos] = True
+
+ return engramas.to(device), mask.to(device)
+
+ def stats(self) -> Dict[str, object]:
+ """Estadísticas del banco."""
+ por_nivel: Dict[int, int] = {}
+ por_territorio: Dict[int, int] = defaultdict(int)
+ por_zona: Dict[int, int] = defaultdict(int)
+ scores: List[float] = []
+
+ for nivel, claves in self._banco.items():
+ count = sum(len(v) for v in claves.values())
+ por_nivel[nivel] = count
+ for (t, z), engs in claves.items():
+ por_territorio[t] += len(engs)
+ por_zona[z] += len(engs)
+ scores.extend(e.score for e in engs)
+
+ return {
+ "total": self._total_engramas,
+ "inyecciones": self._total_inyecciones,
+ "por_nivel": dict(por_nivel),
+ "por_territorio": dict(por_territorio),
+ "score_mean": sum(scores) / len(scores) if scores else 0.0,
+ "claves_unicas": sum(
+ len(claves) for claves in self._banco.values()
+ ),
+ }
+
+ def guardar(self, ruta: Path) -> None:
+ """Persiste el banco a disco."""
+ ruta.parent.mkdir(parents=True, exist_ok=True)
+ data: List[Dict] = []
+ for nivel, claves in self._banco.items():
+ for (t, z), engs in claves.items():
+ for eng in engs:
+ data.append({
+ "nivel": nivel,
+ "territorio": t,
+ "zona": z,
+ "activacion": eng.activacion.tolist(),
+ "score": eng.score,
+ "timestamp": eng.timestamp,
+ "n_usos": eng.n_usos,
+ })
+ ruta.write_text(
+ json.dumps(data, ensure_ascii=False),
+ encoding="utf-8",
+ )
+
+ def cargar(self, ruta: Path) -> int:
+ """Carga el banco desde disco."""
+ if not ruta.exists():
+ return 0
+ data = json.loads(ruta.read_text(encoding="utf-8"))
+ count = 0
+ for item in data:
+ eng = Engrama(
+ nivel=item["nivel"],
+ activacion=torch.tensor(item["activacion"], dtype=torch.float32),
+ territorio=item["territorio"],
+ zona=item["zona"],
+ score=item["score"],
+ timestamp=item.get("timestamp", time.time()),
+ n_usos=item.get("n_usos", 0),
+ )
+ clave = (eng.territorio, eng.zona)
+ self._banco[eng.nivel][clave].append(eng)
+ count += 1
+ self._total_engramas = count
+ return count
+
+
+class EngramaCapture:
+ """
+ Captura activaciones de forward passes exitosos.
+
+ Se invoca DESPUÉS de que el modelo genera y se evalúa la calidad.
+ Usa el GhidraProbe para acceder a las activaciones capturadas.
+ """
+
+ def __init__(self, banco: BancoEngrama, score_minimo: float = 0.6):
+ self.banco = banco
+ self.score_minimo = score_minimo
+
+ @torch.no_grad()
+ def capturar_desde_probe(
+ self,
+ probe_raw: Dict[str, Any],
+ score: float,
+ n_levels: int = 5,
+ ) -> int:
+ """
+ Extrae activaciones del GhidraProbe y las almacena en el banco.
+
+ Args:
+ probe_raw: dict de tensores raw del GhidraProbe
+ score: calidad evaluada del forward pass (0-1)
+ n_levels: número de niveles del modelo
+
+ Returns:
+ Número de engramas almacenados
+ """
+ if score < self.score_minimo:
+ return 0
+
+ count = 0
+
+ # Para cada nivel, capturar x_attn (salida de atención) por (territorio, zona)
+ # Usamos x_attn en vez de streams_out para que la escala coincida
+ # con el punto de inyección (paso 2.5 en NivelProfundo)
+ terr_n0 = probe_raw.get("terr_acts_n0") # [B, L, 4]
+ zona_n0 = probe_raw.get("zona_acts_n0") # [B, L, 52]
+
+ if terr_n0 is None or zona_n0 is None:
+ return 0
+
+ # Territorio y zona dominantes por token
+ terr_dom = terr_n0[0].argmax(dim=-1) # [L]
+ zona_dom = zona_n0[0].argmax(dim=-1) # [L]
+ L = terr_dom.shape[0]
+
+ for nivel_idx in range(n_levels):
+ # Preferir x_attn (escala correcta para inyección)
+ # Fallback a streams_out si x_attn no está disponible
+ x_attn_key = f"x_attn_n{nivel_idx}"
+ x_attn = probe_raw.get(x_attn_key) # [B, L, D]
+
+ if x_attn is not None:
+ x_out = x_attn[0] # [L, D] — ya es la salida de atención
+ else:
+ # Fallback: usar streams_out combinados (escala diferente)
+ streams_key = f"streams_out_n{nivel_idx}"
+ streams = probe_raw.get(streams_key)
+ terr_key = f"terr_acts_n{nivel_idx}"
+ terr_acts = probe_raw.get(terr_key)
+ if streams is None or terr_acts is None:
+ continue
+ n_streams = len(streams)
+ x_out = sum(
+ streams[t][0] * terr_acts[0, :, t:t + 1]
+ for t in range(n_streams)
+ ) # [L, D]
+
+ # Agrupar tokens por (territorio, zona) y promediar
+ seen: Dict[Tuple[int, int], List[torch.Tensor]] = defaultdict(list)
+ for pos in range(L):
+ t = terr_dom[pos].item()
+ z = zona_dom[pos].item()
+ seen[(t, z)].append(x_out[pos])
+
+ for (t, z), vecs in seen.items():
+ avg = torch.stack(vecs).mean(dim=0) # [D]
+ self.banco.agregar(nivel_idx, t, z, avg, score)
+ count += 1
+
+ return count
+
+ @torch.no_grad()
+ def capturar_directo(
+ self,
+ model: object,
+ input_ids: torch.Tensor,
+ score: float,
+ ) -> int:
+ """
+ Captura directa sin GhidraProbe (forward pass adicional).
+
+ Más simple pero requiere un forward pass extra.
+ Usar cuando no hay probe activo.
+ """
+ if score < self.score_minimo:
+ return 0
+
+ from .modelo import PamparV3
+ from .talamo import TalamoInicial
+
+ assert isinstance(model, PamparV3)
+
+ model.eval()
+ config = model.config
+ count = 0
+
+ x = model.emb_drop(model.tok_emb(input_ids))
+ terr_acts, zona_acts = model.talamo(x, input_ids)
+ streams = [x.clone() for _ in range(config.n_streams)]
+
+ terr_dom = terr_acts[0].argmax(dim=-1) # [L]
+ zona_dom = zona_acts[0].argmax(dim=-1) # [L]
+ L = terr_dom.shape[0]
+
+ for nivel_idx, nivel in enumerate(model.niveles):
+ streams, terr_acts, _ = nivel(
+ streams, terr_acts, TalamoInicial.agregar_fn
+ )
+
+ x_out = sum(
+ streams[t][0] * terr_acts[0, :, t:t + 1]
+ for t in range(config.n_streams)
+ ) # [L, D]
+
+ seen: Dict[Tuple[int, int], List[torch.Tensor]] = defaultdict(list)
+ for pos in range(L):
+ t = terr_dom[pos].item()
+ z = zona_dom[pos].item()
+ seen[(t, z)].append(x_out[pos])
+
+ for (t, z), vecs in seen.items():
+ avg = torch.stack(vecs).mean(dim=0)
+ self.banco.agregar(nivel_idx, t, z, avg, score)
+ count += 1
+
+ return count
diff --git a/pampar/coder/v3/ffn.py b/pampar/coder/v3/ffn.py
new file mode 100644
index 0000000000000000000000000000000000000000..2f83a4621bb00d0085e3e4681eef1a6f2802a4e8
--- /dev/null
+++ b/pampar/coder/v3/ffn.py
@@ -0,0 +1,164 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+FFN y modulación contextual para PamparV3.
+
+Componentes:
+ StreamFFN — Feed-forward SwiGLU
+ ContextModulator — Modulación FiLM de selectividad mixta (63 indicadores)
+"""
+
+from __future__ import annotations
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+from .config import ConfigV3
+
+
+class StreamFFN(nn.Module):
+ """
+ Feed-forward SwiGLU especializado por stream territorial.
+
+ SwiGLU = SiLU(gate) ⊙ up → down.
+ Cada stream (SINTAXIS/SEMANTICA/LOGICO/ESTRUCTURAL) tiene su propio
+ conjunto de pesos — como neuronas de áreas corticales distintas.
+
+ hidden_dim = 2/3 × dim × ffn_mult (compensa la gate extra de SwiGLU).
+ """
+
+ def __init__(self, config: ConfigV3):
+ super().__init__()
+ hidden = config.ffn_hidden
+
+ self.gate = nn.Linear(config.dim, hidden, bias=False)
+ self.up = nn.Linear(config.dim, hidden, bias=False)
+ self.down = nn.Linear(hidden, config.dim, bias=False)
+ self.drop = nn.Dropout(config.dropout)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """SwiGLU: SiLU(gate(x)) ⊙ up(x) → down."""
+ return self.drop(self.down(F.silu(self.gate(x)) * self.up(x)))
+
+
+class ContextModulator(nn.Module):
+ """
+ Modulador de selectividad mixta — inspirado en neurociencia cortical.
+
+ Un solo FFN compartido codifica el conocimiento (2.2M params).
+ Este modulador genera gamma/beta por token usando un vector de contexto
+ rico (zona_acts + terr_acts + depth + conf = 63 indicadores) para que
+ la MISMA memoria se lea de formas diferentes según el contexto.
+
+ Reemplaza 4 StreamFFN independientes (8.8M/nivel) por:
+ 1 SharedFFN (2.2M) + 4 modulaciones (gamma, beta) desde contexto (200K)
+ = 2.4M/nivel → ahorro de ~6.4M/nivel → ~32M total
+
+ Basado en:
+ - FiLM (Perez et al., 2018): Feature-wise Linear Modulation
+ - Mixed Selectivity (Rigotti et al., 2013): misma neurona, múltiples roles
+ - Superposition (Anthropic, 2022): más conceptos que dimensiones
+
+ El contexto de 63d se compone de:
+ zona_acts [52] — tipo de token (keyword, variable, string...)
+ terr_acts [4] — área dominante
+ depth [1] — nivel actual (0..n_levels-1), normalizado
+ conf [1] — confianza del modelo en este punto
+ n_levels [1] — total de niveles (para normalización)
+ stream_id [4] — one-hot del stream que se está modulando
+
+ Total: 52 + 4 + 1 + 1 + 1 + 4 = 63 indicadores.
+ """
+
+ # Dimensión fija del vector de contexto
+ CONTEXT_DIM: int = 63
+
+ def __init__(self, config: ConfigV3):
+ super().__init__()
+ self.dim = config.dim
+ mid = config.modulator_bottleneck
+
+ # Contexto (63d) → bottleneck → gamma + beta (dim + dim)
+ self.proj = nn.Sequential(
+ nn.Linear(self.CONTEXT_DIM, mid, bias=False),
+ nn.SiLU(),
+ nn.Linear(mid, config.dim * 2, bias=False),
+ )
+
+ # Inicializar cerca de identidad: gamma≈1, beta≈0
+ nn.init.zeros_(self.proj[2].weight)
+
+ def forward(
+ self,
+ ffn_out: torch.Tensor,
+ zona_acts: torch.Tensor,
+ terr_acts: torch.Tensor,
+ stream_idx: int,
+ nivel_idx: int,
+ n_levels: int,
+ conf: float,
+ ) -> torch.Tensor:
+ """
+ Modula la salida del FFN compartido según contexto completo.
+
+ Args:
+ ffn_out: [B, L, D] salida del FFN compartido
+ zona_acts: [B, L, 52] activaciones por zona
+ terr_acts: [B, L, 4] activaciones territoriales
+ stream_idx: índice del stream actual (0-3)
+ nivel_idx: índice del nivel actual (0-4)
+ n_levels: total de niveles
+ conf: confianza actual (0.0-1.0)
+
+ Returns:
+ [B, L, D] salida modulada para este stream/contexto
+ """
+ B, L, _ = ffn_out.shape
+ device = ffn_out.device
+
+ # Construir vector de contexto [B, L, 63]
+ depth = torch.full(
+ (B, L, 1),
+ nivel_idx / max(n_levels - 1, 1),
+ device=device,
+ dtype=ffn_out.dtype,
+ )
+
+ conf_t = torch.full(
+ (B, L, 1),
+ conf,
+ device=device,
+ dtype=ffn_out.dtype,
+ )
+
+ nl_t = torch.full(
+ (B, L, 1),
+ n_levels / 10.0,
+ device=device,
+ dtype=ffn_out.dtype,
+ )
+
+ # Stream ID one-hot [B, L, 4]
+ stream_oh = torch.zeros(
+ B,
+ L,
+ 4,
+ device=device,
+ dtype=ffn_out.dtype,
+ )
+ stream_oh[:, :, stream_idx] = 1.0
+
+ # Concatenar: [52] + [4] + [1] + [1] + [1] + [4] = 63
+ ctx = torch.cat(
+ [zona_acts, terr_acts, depth, conf_t, nl_t, stream_oh],
+ dim=-1,
+ ) # [B, L, 63]
+
+ # Proyectar a gamma + beta
+ modulation = self.proj(ctx) # [B, L, dim*2]
+ gamma, beta = modulation.chunk(2, dim=-1) # [B, L, dim] cada uno
+
+ # FiLM: gamma modula la escala, beta desplaza
+ # +1 para que gamma inicie en identidad (init de proj es zeros)
+ return (1.0 + gamma) * ffn_out + beta
diff --git a/pampar/coder/v3/ghidra_probe.py b/pampar/coder/v3/ghidra_probe.py
new file mode 100644
index 0000000000000000000000000000000000000000..f0b96c7e02059733013e188e4fcc3e259ecdad1e
--- /dev/null
+++ b/pampar/coder/v3/ghidra_probe.py
@@ -0,0 +1,410 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+GhidraProbe — Instrumentación read-only del forward pass de PamparV3.
+
+Registra forward hooks en cada componente clave del modelo para capturar
+activaciones sin modificar el forward pass original. Cero parámetros extra.
+
+Captura:
+ - TalamoInicial: llaves_acts, attn_acts, zona_acts, terr_acts
+ - NivelProfundo × 5: streams in/out, FFN deltas, lateral deltas, exit conf
+ - StreamFFN × 4 × 5: delta por stream
+ - LateralGate × 5: contribución lateral y scale learneable
+
+Uso:
+ probe = GhidraProbe(model)
+ logits, loss, info = model(input_ids)
+ report = probe.report() # dict con todo
+ probe.print_summary() # resumen legible
+ probe.detach() # limpiar hooks
+"""
+
+from __future__ import annotations
+
+import torch
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional, Tuple
+
+from .modelo import PamparV3
+
+STREAM_NAMES = ("SINT", "SEMA", "LOGI", "ESTR")
+
+
+@dataclass
+class NivelCapture:
+ """Capturas de un NivelProfundo."""
+
+ nivel_idx: int = 0
+ # Streams antes y después del nivel
+ streams_in_norms: List[float] = field(default_factory=list) # [4] L2 norm promedio
+ streams_out_norms: List[float] = field(default_factory=list) # [4]
+ ffn_delta_norms: List[float] = field(default_factory=list) # [4] cuánto cambió cada FFN
+ lateral_delta_norms: List[float] = field(default_factory=list) # [4]
+ lateral_scales: List[float] = field(default_factory=list) # [4] scale learnable actual
+ # Routing
+ terr_acts_mean: List[float] = field(default_factory=list) # [4] activación media por territorio
+ terr_acts_max_stream: int = 0 # territorio dominante
+ # Exit
+ exit_conf_mean: float = 0.0
+ exit_conf_min: float = 0.0
+ exit_conf_p10: float = 0.0 # percentil 10 (lo que usa Early Exit)
+
+
+@dataclass
+class TalamoCapture:
+ """Capturas del TalamoInicial."""
+
+ llaves_nonzero_pct: float = 0.0 # % de activaciones LLAVES no-cero
+ attn_acts_mean: float = 0.0 # media de attn_proj output
+ zona_acts_top5: List[Tuple[int, float]] = field(default_factory=list) # top-5 zonas activas
+ terr_distribution: List[float] = field(default_factory=list) # [4] distribución territorial
+ llaves_attn_agreement: float = 0.0 # coseno entre LLAVES y attn_proj
+
+
+@dataclass
+class ProbeCapture:
+ """Captura completa de un forward pass."""
+
+ talamo: Optional[TalamoCapture] = None
+ niveles: List[NivelCapture] = field(default_factory=list)
+ # Global
+ input_len: int = 0
+ total_stream_divergence: float = 0.0 # cuánto divergen los 4 streams al final
+
+
+class GhidraProbe:
+ """
+ Instrumentación read-only del forward pass de PamparV3.
+
+ Registra forward hooks que capturan activaciones sin gradient.
+ Se activa/desactiva sin modificar el modelo.
+ """
+
+ def __init__(self, model: PamparV3):
+ self._model = model
+ self._hooks: List[torch.utils.hooks.RemovableHook] = []
+ self._capture = ProbeCapture()
+ self._raw: Dict[str, torch.Tensor] = {}
+ self._attach()
+
+ def _attach(self) -> None:
+ """Registra forward hooks en todos los componentes."""
+ # Hook en TalamoInicial
+ self._hooks.append(
+ self._model.talamo.register_forward_hook(self._hook_talamo)
+ )
+
+ # Hooks en cada NivelProfundo
+ for i, nivel in enumerate(self._model.niveles):
+ self._hooks.append(
+ nivel.register_forward_hook(self._make_hook_nivel(i))
+ )
+ # Hook en la atención de cada nivel (captura x_attn)
+ self._hooks.append(
+ nivel.attn.register_forward_hook(self._make_hook_attn(i))
+ )
+ # Hooks en cada StreamFFN dentro del nivel
+ for t, ffn in enumerate(nivel.ffns):
+ self._hooks.append(
+ ffn.register_forward_hook(self._make_hook_ffn(i, t))
+ )
+ # Hook en LateralGate
+ self._hooks.append(
+ nivel.lateral.register_forward_hook(self._make_hook_lateral(i))
+ )
+
+ def detach(self) -> None:
+ """Elimina todos los hooks."""
+ for h in self._hooks:
+ h.remove()
+ self._hooks.clear()
+
+ def reset(self) -> None:
+ """Limpia capturas para un nuevo forward pass."""
+ self._capture = ProbeCapture()
+ self._raw.clear()
+
+ # ── Hooks ─────────────────────────────────────────────────────────────
+
+ def _hook_talamo(
+ self,
+ module: torch.nn.Module,
+ input_args: Tuple,
+ output: Tuple[torch.Tensor, torch.Tensor],
+ ) -> None:
+ """Captura la salida del TalamoInicial."""
+ terr_acts, zona_acts = output
+ with torch.no_grad():
+ tc = TalamoCapture()
+
+ # Distribución territorial promedio
+ terr_mean = terr_acts.mean(dim=(0, 1)) # [4]
+ tc.terr_distribution = terr_mean.tolist()
+
+ # Zona acts: top-5 zonas más activas
+ zona_mean = zona_acts.mean(dim=(0, 1)) # [52]
+ top_vals, top_idx = zona_mean.topk(5)
+ tc.zona_acts_top5 = [
+ (idx.item(), val.item()) for idx, val in zip(top_idx, top_vals)
+ ]
+
+ # LLAVES vs attn_proj agreement
+ if hasattr(module, "llaves") and hasattr(module, "attn_proj"):
+ x_emb = input_args[0] # [B, L, D]
+ token_ids = input_args[1] # [B, L]
+ llaves_raw = module.llaves(token_ids) # [B, L, 52]
+ attn_raw = torch.sigmoid(module.attn_proj(x_emb)) # [B, L, 52]
+
+ # % de LLAVES no-cero
+ tc.llaves_nonzero_pct = (
+ (llaves_raw > 0.01).float().mean().item() * 100
+ )
+ tc.attn_acts_mean = attn_raw.mean().item()
+
+ # Coseno entre los dos promedios
+ ll_flat = llaves_raw.mean(dim=(0, 1))
+ at_flat = attn_raw.mean(dim=(0, 1))
+ cos = torch.nn.functional.cosine_similarity(
+ ll_flat.unsqueeze(0), at_flat.unsqueeze(0)
+ )
+ tc.llaves_attn_agreement = cos.item()
+
+ self._capture.talamo = tc
+ self._raw["terr_acts_n0"] = terr_acts.detach().cpu()
+ self._raw["zona_acts_n0"] = zona_acts.detach().cpu()
+
+ def _make_hook_nivel(self, nivel_idx: int):
+ """Factory de hook para NivelProfundo."""
+ def hook(module, input_args, output):
+ streams_out, terr_acts_out, conf = output
+ streams_in = input_args[0] # List[Tensor]
+
+ with torch.no_grad():
+ nc = NivelCapture(nivel_idx=nivel_idx)
+
+ # Normas de streams entrada/salida
+ nc.streams_in_norms = [
+ s.norm(dim=-1).mean().item() for s in streams_in
+ ]
+ nc.streams_out_norms = [
+ s.norm(dim=-1).mean().item() for s in streams_out
+ ]
+
+ # Delta = cuánto cambió cada stream
+ nc.ffn_delta_norms = [
+ (streams_out[t] - streams_in[t]).norm(dim=-1).mean().item()
+ for t in range(len(streams_in))
+ ]
+
+ # Routing en este nivel
+ terr_mean = terr_acts_out.mean(dim=(0, 1))
+ nc.terr_acts_mean = terr_mean.tolist()
+ nc.terr_acts_max_stream = terr_mean.argmax().item()
+
+ # Exit confidence
+ n_streams = len(streams_out)
+ x_out = sum(
+ streams_out[t] * terr_acts_out[:, :, t:t + 1]
+ for t in range(n_streams)
+ )
+ per_token = torch.sigmoid(
+ module.exit_head(x_out)
+ ).squeeze(-1)
+ nc.exit_conf_mean = per_token.mean().item()
+ nc.exit_conf_min = per_token.min().item()
+ k = max(1, int(per_token.numel() * 0.10))
+ nc.exit_conf_p10 = (
+ per_token.reshape(-1).topk(k, largest=False).values.mean().item()
+ )
+
+ # Lateral scales
+ nc.lateral_scales = module.lateral.scale.detach().tolist()
+
+ self._capture.niveles.append(nc)
+
+ # Guardar raw para análisis externo
+ self._raw[f"streams_out_n{nivel_idx}"] = [
+ s.detach().cpu() for s in streams_out
+ ]
+ self._raw[f"terr_acts_n{nivel_idx}"] = terr_acts_out.detach().cpu()
+
+ return hook
+
+ def _make_hook_ffn(self, nivel_idx: int, stream_idx: int):
+ """Factory de hook para StreamFFN."""
+ def hook(module, input_args, output):
+ with torch.no_grad():
+ key = f"ffn_n{nivel_idx}_s{stream_idx}"
+ self._raw[key] = output.detach().cpu()
+ return hook
+
+ def _make_hook_attn(self, nivel_idx: int):
+ """Factory de hook para BloqueAttn — captura x_attn en cada nivel."""
+ def hook(module, input_args, output):
+ with torch.no_grad():
+ self._raw[f"x_attn_n{nivel_idx}"] = output.detach().cpu()
+ return hook
+
+ def _make_hook_lateral(self, nivel_idx: int):
+ """Factory de hook para LateralGate."""
+ def hook(module, input_args, output):
+ streams_in = input_args[0]
+ streams_out = output
+ with torch.no_grad():
+ deltas = [
+ (streams_out[t] - streams_in[t]).norm(dim=-1).mean().item()
+ for t in range(len(streams_in))
+ ]
+ # Actualizar el NivelCapture correspondiente
+ for nc in self._capture.niveles:
+ if nc.nivel_idx == nivel_idx:
+ nc.lateral_delta_norms = deltas
+ break
+ return hook
+
+ # ── Análisis ──────────────────────────────────────────────────────────
+
+ def report(self) -> ProbeCapture:
+ """Devuelve la captura completa del último forward pass."""
+ # Calcular divergencia total de streams al final
+ if self._capture.niveles:
+ last = self._capture.niveles[-1]
+ norms = last.streams_out_norms
+ if norms:
+ mean_norm = sum(norms) / len(norms)
+ variance = sum((n - mean_norm) ** 2 for n in norms) / len(norms)
+ self._capture.total_stream_divergence = variance ** 0.5
+ return self._capture
+
+ def get_raw(self, key: str) -> Optional[torch.Tensor]:
+ """Acceso a tensores raw capturados."""
+ return self._raw.get(key)
+
+ def get_all_raw_keys(self) -> List[str]:
+ """Lista las claves de tensores raw disponibles."""
+ return list(self._raw.keys())
+
+ def print_summary(self, tokens: Optional[List[str]] = None) -> None:
+ """Imprime resumen legible del forward pass instrumentado."""
+ cap = self.report()
+ print("\n" + "=" * 70)
+ print(" GHIDRA PROBE — Forward Pass Analysis")
+ print("=" * 70)
+
+ # Tálamo
+ if cap.talamo:
+ t = cap.talamo
+ print("\n── TalamoInicial ──")
+ print(f" LLAVES non-zero: {t.llaves_nonzero_pct:.1f}%")
+ print(f" attn_proj mean: {t.attn_acts_mean:.4f}")
+ print(f" LLAVES↔attn cosine: {t.llaves_attn_agreement:.4f}")
+ print(f" Territory dist: ", end="")
+ for i, val in enumerate(t.terr_distribution):
+ print(f"{STREAM_NAMES[i]}={val:.3f} ", end="")
+ print()
+ print(f" Top-5 zonas: ", end="")
+ for idx, val in t.zona_acts_top5:
+ print(f"B{idx + 1:02d}={val:.3f} ", end="")
+ print()
+
+ # Niveles
+ print("\n── Niveles de Profundidad ──")
+ header = (
+ f"{'Nivel':>5} │ "
+ + " ".join(f"{n:>7}" for n in STREAM_NAMES)
+ + " │ "
+ + " ".join(f"Δ{n[:2]:>4}" for n in STREAM_NAMES)
+ + " │ "
+ + " ".join(f"L{n[0]:>4}" for n in STREAM_NAMES)
+ + " │ Exit p10 │ Dom"
+ )
+ print(header)
+ print("─" * len(header))
+
+ for nc in cap.niveles:
+ norms_str = " ".join(f"{v:7.1f}" for v in nc.streams_out_norms)
+ delta_str = " ".join(f"{v:6.2f}" for v in nc.ffn_delta_norms)
+ lat_str = " ".join(
+ f"{v:6.3f}" for v in nc.lateral_delta_norms
+ ) if nc.lateral_delta_norms else " — " * 4
+ dom = STREAM_NAMES[nc.terr_acts_max_stream]
+ print(
+ f" N{nc.nivel_idx:>2} │ {norms_str} │ {delta_str} │ "
+ f"{lat_str} │ {nc.exit_conf_p10:8.4f} │ {dom}"
+ )
+
+ # Lateral scales
+ if cap.niveles and cap.niveles[0].lateral_scales:
+ print("\n── Lateral Gate Scales (learnable) ──")
+ for nc in cap.niveles:
+ scales_str = " ".join(f"{v:.4f}" for v in nc.lateral_scales)
+ print(f" N{nc.nivel_idx:>2}: {scales_str}")
+
+ # Divergencia final
+ print(f"\n── Stream divergence (final): {cap.total_stream_divergence:.4f}")
+ print("=" * 70)
+
+ def token_heatmap(
+ self,
+ token_idx: int,
+ nivel_idx: int = -1,
+ ) -> Dict[str, float]:
+ """
+ Mapa de calor para un token específico en un nivel.
+
+ Args:
+ token_idx: posición del token en la secuencia
+ nivel_idx: nivel a inspeccionar (-1 = último)
+
+ Returns:
+ Dict con activaciones territoriales y confianza del token
+ """
+ if nivel_idx == -1:
+ nivel_idx = len(self._capture.niveles) - 1
+
+ terr_key = f"terr_acts_n{nivel_idx}"
+ terr = self._raw.get(terr_key)
+ if terr is None:
+ return {}
+
+ result: Dict[str, float] = {}
+ if token_idx < terr.shape[1]:
+ acts = terr[0, token_idx] # [4]
+ for i, name in enumerate(STREAM_NAMES):
+ result[f"terr_{name}"] = acts[i].item()
+
+ streams_key = f"streams_out_n{nivel_idx}"
+ streams = self._raw.get(streams_key)
+ if streams and token_idx < streams[0].shape[1]:
+ for i, name in enumerate(STREAM_NAMES):
+ result[f"norm_{name}"] = (
+ streams[i][0, token_idx].norm().item()
+ )
+
+ return result
+
+ def routing_trajectory(
+ self,
+ token_idx: int,
+ ) -> List[List[float]]:
+ """
+ Trayectoria de routing de un token a través de todos los niveles.
+
+ Returns:
+ Lista de [4] activaciones territoriales por nivel (incluyendo N0)
+ """
+ trajectory: List[List[float]] = []
+
+ # N0 (TalamoInicial)
+ terr_n0 = self._raw.get("terr_acts_n0")
+ if terr_n0 is not None and token_idx < terr_n0.shape[1]:
+ trajectory.append(terr_n0[0, token_idx].tolist())
+
+ # N1-N4
+ for i in range(len(self._capture.niveles)):
+ terr = self._raw.get(f"terr_acts_n{i}")
+ if terr is not None and token_idx < terr.shape[1]:
+ trajectory.append(terr[0, token_idx].tolist())
+
+ return trajectory
diff --git a/pampar/coder/v3/llaves.py b/pampar/coder/v3/llaves.py
new file mode 100644
index 0000000000000000000000000000000000000000..cab4ddb3dd30d855f449a570137180d710aa2ec5
--- /dev/null
+++ b/pampar/coder/v3/llaves.py
@@ -0,0 +1,285 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Sistema LLAVES v2: Routing de tokens a zonas.
+
+LLAVES = "Lookup tables + Learned Attention + Vectorized Evaluation System"
+
+80% basado en reglas (lookup tables) + 20% atención aprendida.
+Las tablas se cuantizan a INT4 para ahorrar memoria.
+"""
+
+import re
+from typing import Dict, Optional, Tuple
+
+import torch
+import torch.nn as nn
+
+from .zonas import ZONA_TERRITORIO, ZONAS, Territorio, Zona
+
+# =============================================================================
+# NORMALIZACIÓN DE TOKENS
+# =============================================================================
+
+# Prefijos de tokenizers (SentencePiece, BPE, etc.)
+_PREFIXES = ("▁", "Ġ", "Ċ", "##", "Ã", "Â")
+
+# Patrones para clasificación
+_PAT_INT = re.compile(r"^-?\d+$")
+_PAT_FLOAT = re.compile(r"^-?\d+\.\d*$")
+_PAT_UPPER = re.compile(r"^[A-Z][A-Z0-9_]*$")
+_PAT_CAMEL = re.compile(r"^[A-Z][a-z]+")
+_PAT_ID = re.compile(r"^[a-z_][a-z0-9_]*$", re.IGNORECASE)
+
+
+def normalizar(token: str) -> str:
+ """
+ Normaliza token removiendo prefijos de tokenizer.
+
+ Args:
+ token: Token crudo del tokenizer
+
+ Returns:
+ Token limpio para clasificación
+ """
+ t = token
+ for p in _PREFIXES:
+ if t.startswith(p):
+ t = t[len(p) :]
+ return t
+
+
+# Patrones adicionales para clasificación multi-lenguaje
+_PAT_DUNDER = re.compile(r"^__[a-z][a-z0-9_]*__$")
+_PAT_SNAKE = re.compile(r"^[a-z][a-z0-9_]*$")
+_PAT_DECORATOR = re.compile(r"^@[a-zA-Z]")
+_PAT_FSTRING = re.compile(r"^[fFrRbB]['\"]")
+_PAT_RUST_MACRO = re.compile(r"^[a-z][a-z0-9_]*!$") # println!, vec!, etc.
+_PAT_RUST_LIFETIME = re.compile(r"^'[a-z][a-z0-9_]*$") # 'a, 'static, etc.
+_PAT_BASH_VAR = re.compile(r"^\$[A-Za-z_{\(]") # $VAR, ${VAR}, $(cmd)
+_PAT_HEX = re.compile(r"^0[xX][0-9a-fA-F]+$") # 0xFF, 0x1A
+_PAT_OCTAL = re.compile(r"^0[oO][0-7]+$") # 0o777
+_PAT_BINARY = re.compile(r"^0[bB][01]+$") # 0b1010
+_PAT_SCI = re.compile(r"^-?\d+\.?\d*[eE][+-]?\d+$") # 1e-5, 3.14e10
+
+
+def clasificar_token(token: str) -> Tuple[Zona, float]:
+ """
+ Clasifica un token en su zona correspondiente.
+
+ Orden de prioridad:
+ 1. Exact match en ZONAS (keywords, operadores, delimitadores)
+ 2. Regex patterns (números, magic methods, strings)
+ 3. Convenciones de naming (CamelCase, snake_case, UPPER_CASE)
+ 4. Default: variable genérica
+
+ Args:
+ token: Token a clasificar
+
+ Returns:
+ (zona, confianza) donde confianza ∈ [0, 1]
+ """
+ t = normalizar(token)
+
+ if not t:
+ return Zona.B49_SPACE, 0.5
+
+ # — Whitespace puro —
+ if t in ("\n", "\r\n"):
+ return Zona.B48_NEWLINE, 1.0
+ if t in ("\t", " "):
+ return Zona.B47_INDENT, 1.0
+ if t.strip() == "":
+ return Zona.B49_SPACE, 0.8
+
+ # 1. Búsqueda exacta en lookup tables (solo zonas no vacías)
+ for zona, patrones in ZONAS.items():
+ if not patrones:
+ continue
+ if t in patrones or t.lower() in patrones:
+ return zona, 1.0
+
+ # 2. Dunder / magic methods (Python)
+ if _PAT_DUNDER.match(t):
+ return Zona.B30_MAGIC, 0.95
+
+ # 3. Rust macros: word! pattern (println!, vec!, etc.)
+ if _PAT_RUST_MACRO.match(t):
+ return Zona.B29_BUILTIN, 0.9
+
+ # 4. Números (multi-formato)
+ if _PAT_HEX.match(t) or _PAT_OCTAL.match(t) or _PAT_BINARY.match(t):
+ return Zona.B21_LIT_INT, 0.95
+ if _PAT_INT.match(t):
+ return Zona.B21_LIT_INT, 0.95
+ if _PAT_FLOAT.match(t) or _PAT_SCI.match(t):
+ return Zona.B22_LIT_FLOAT, 0.95
+
+ # 5. Strings (comillas, f-strings, template literals)
+ if _PAT_FSTRING.match(t):
+ return Zona.B23_LIT_STR, 0.9
+ if t.startswith(('"', "'", "`")):
+ return Zona.B23_LIT_STR, 0.9
+
+ # 6. Rust lifetimes ('a, 'static)
+ if _PAT_RUST_LIFETIME.match(t):
+ return Zona.B28_TYPE_GEN, 0.8
+
+ # 7. Bash variables ($VAR, ${VAR}, $(cmd))
+ if _PAT_BASH_VAR.match(t):
+ return Zona.B16_ID_VAR, 0.85
+
+ # 8. Decorators (@ — Python, TS)
+ if _PAT_DECORATOR.match(t):
+ return Zona.B29_BUILTIN, 0.7
+
+ # 9. Identificadores por convención de naming
+ if _PAT_UPPER.match(t) and len(t) > 1:
+ # ALL_CAPS = constante o excepción (ej: HTTP_ERROR, MAX_SIZE)
+ return Zona.B18_ID_CLASS, 0.7
+
+ if _PAT_CAMEL.match(t) and len(t) > 1:
+ # CamelCase = clase (Python, JS, TS, Rust, Java)
+ return Zona.B18_ID_CLASS, 0.85
+
+ if _PAT_SNAKE.match(t):
+ # snake_case = variable o función (contexto distingue)
+ return Zona.B16_ID_VAR, 0.6
+
+ if _PAT_ID.match(t):
+ return Zona.B16_ID_VAR, 0.5
+
+ # 7. Default: semántica general con baja confianza
+ return Zona.B16_ID_VAR, 0.3
+
+
+# =============================================================================
+# LLAVES CON LOOKUP CUANTIZADO
+# =============================================================================
+
+
+class LlavesV2(nn.Module):
+ """
+ Sistema LLAVES v2 con lookup tables cuantizadas.
+
+ Usa INT8 (uint8, 256 niveles) para almacenar las activaciones de zona
+ por token, reduciendo memoria 4x vs FP32. Error de cuantización < 0.4%.
+ """
+
+ def __init__(
+ self,
+ vocab_size: int,
+ n_zonas: int = 52,
+ usar_cuant: bool = True,
+ ):
+ super().__init__()
+ self.vocab_size = vocab_size
+ self.n_zonas = n_zonas
+ self.usar_cuant = usar_cuant
+
+ # Tabla de lookup: vocab_size x n_zonas
+ # Almacena la activación de cada zona para cada token
+ if usar_cuant:
+ # Cuantizado: 8 bits por valor (INT8)
+ # 52 zonas = 52 bytes por token (vs 208 bytes en FP32)
+ # Resolución: 256 niveles (error <0.4% vs INT4 ~11%)
+ self.register_buffer(
+ "tabla_cuant", torch.zeros(vocab_size, n_zonas, dtype=torch.uint8)
+ )
+ else:
+ self.register_buffer("tabla", torch.zeros(vocab_size, n_zonas))
+
+ # Estadísticas
+ self.tokens_registrados = 0
+
+ def registrar_tokenizer(self, tokenizer) -> int:
+ """
+ Llena la tabla de lookup con el vocabulario del tokenizer.
+
+ Args:
+ tokenizer: SentencePiece tokenizer
+
+ Returns:
+ Número de tokens registrados
+ """
+ count = 0
+
+ for token_id in range(min(self.vocab_size, tokenizer.GetPieceSize())):
+ token = tokenizer.IdToPiece(token_id)
+ zona, conf = clasificar_token(token)
+
+ # Guardar activación
+ if self.usar_cuant:
+ self._set_cuant(token_id, zona.value - 1, conf)
+ else:
+ self.tabla[token_id, zona.value - 1] = conf
+
+ count += 1
+
+ self.tokens_registrados = count
+ return count
+
+ def _set_cuant(self, token_id: int, zona_idx: int, valor: float):
+ """Guarda valor cuantizado a INT8 (256 niveles, error <0.4%)."""
+ v_int = int(min(255, max(0, valor * 255)))
+ self.tabla_cuant[token_id, zona_idx] = v_int
+
+ def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
+ """
+ Obtiene activaciones de zona para tokens.
+
+ Args:
+ token_ids: [B, L] tensor de IDs
+
+ Returns:
+ [B, L, n_zonas] activaciones por zona
+ """
+ B, L = token_ids.shape
+
+ if self.usar_cuant:
+ # INT8 → float: simple y preciso (256 niveles)
+ acts = self.tabla_cuant[token_ids].float() / 255.0 # [B, L, n_zonas]
+ else:
+ acts = self.tabla[token_ids]
+
+ return acts
+
+
+# =============================================================================
+# FUNCIONES DE UTILIDAD
+# =============================================================================
+
+
+def zona_a_territorio(zona: Zona) -> Territorio:
+ """Convierte zona a territorio."""
+ return ZONA_TERRITORIO[zona]
+
+
+def agregar_zonas_a_territorios(
+ zona_acts: torch.Tensor,
+) -> torch.Tensor:
+ """
+ Agrega activaciones de 52 zonas a 4 territorios.
+
+ Args:
+ zona_acts: [B, L, 52] activaciones por zona
+
+ Returns:
+ [B, L, 4] activaciones por territorio
+ """
+ B, L, Z = zona_acts.shape
+
+ # Índices de zonas por territorio
+ indices = [
+ list(range(0, 15)), # SINTAXIS: B01-B15
+ list(range(15, 30)), # SEMANTICA: B16-B30
+ list(range(30, 42)), # LOGICO: B31-B42
+ list(range(42, 52)), # ESTRUCTURAL: B43-B52
+ ]
+
+ terr_acts = torch.zeros(B, L, 4, device=zona_acts.device)
+
+ for t, idx in enumerate(indices):
+ terr_acts[:, :, t] = zona_acts[:, :, idx].mean(dim=-1)
+
+ return terr_acts
diff --git a/pampar/coder/v3/modelo.py b/pampar/coder/v3/modelo.py
new file mode 100644
index 0000000000000000000000000000000000000000..5264b314dec18cd13a59fefaec14afe3e4a5a3e0
--- /dev/null
+++ b/pampar/coder/v3/modelo.py
@@ -0,0 +1,383 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+PamparV3 — Modelo principal con arquitectura 2D + Mixed Selectivity.
+
+Arquitectura:
+ tok_emb [48K, 640]
+ → TalamoInicial (LLAVES + attn_proj + context_conv)
+ → terr_acts [B, L, 4], zona_acts [B, L, 52]
+ → 4 streams inicializados desde tok_emb
+ → [NivelProfundo × n_levels]
+ cada nivel: attn compartida + re-routing
+ + 1 FFN compartido + 4 ContextModulator (FiLM)
+ + lateral gates (fibras blancas)
+ → norm_f (RMSNorm)
+ → lm_head (weight-tied con tok_emb)
+
+Mixed Selectivity: 1 FFN compartido por nivel, modulado por 63 indicadores
+contextuales (zona_acts + terr_acts + depth + conf + stream_id).
+La misma memoria se lee de formas diferentes según el contexto.
+Ahorro: ~32M params vs 4 FFN independientes por nivel.
+
+4 streams × 5 niveles = grilla 2D donde:
+ - La profundidad refina el significado (como capas corticales)
+ - La anchura especializa por tipo de token (como áreas corticales)
+ - Las lateral gates comunican áreas a cada nivel (como fibras blancas)
+ - El re-routing adapta qué área lidera según el contexto acumulado
+ - Los moduladores permiten mixed selectivity (misma neurona, roles múltiples)
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+import torch.utils.checkpoint
+
+from .bloques import NivelProfundo, RMSNorm
+from .config import PRESET_V3, ConfigV3
+from .talamo import TalamoInicial
+
+if TYPE_CHECKING:
+ from .engrama_stream import BancoEngrama
+
+
+class PamparV3(nn.Module):
+ """
+ PAMPAr-Coder v3: arquitectura 2D con streams especializados y profundidad.
+
+ ~110M parámetros con PRESET_V3 (dim=640, 5 niveles, vocab=48K).
+ """
+
+ def __init__(self, config: ConfigV3 = PRESET_V3):
+ super().__init__()
+ self.config = config
+
+ # Embedding de tokens (weight-tied con lm_head)
+ self.tok_emb = nn.Embedding(config.vocab_size, config.dim)
+ self.emb_drop = nn.Dropout(config.dropout)
+
+ # Tálamo inicial: routing completo de tokens a zonas/territorios
+ self.talamo = TalamoInicial(config)
+
+ # Grilla 2D: n_levels niveles de profundidad
+ self.niveles = nn.ModuleList(
+ [NivelProfundo(config, nivel_idx=i) for i in range(config.n_levels)]
+ )
+
+ # Normalización final (antes de lm_head)
+ self.norm_f = RMSNorm(config.dim)
+
+ # LM head
+ self.lm_head = nn.Linear(config.dim, config.vocab_size, bias=False)
+
+ # Weight tying: embedding y lm_head comparten pesos
+ # Ahorra vocab_size × dim parámetros (48K × 640 = 30.7M fp16)
+ self.lm_head.weight = self.tok_emb.weight
+
+ # Inicialización
+ self._init_weights()
+
+ def _init_weights(self) -> None:
+ """Inicialización estilo GPT-NeoX / Llama: N(0, 0.02)."""
+
+ def _init(module: nn.Module) -> None:
+ if isinstance(module, nn.Linear):
+ nn.init.normal_(module.weight, mean=0.0, std=0.02)
+ if module.bias is not None:
+ nn.init.zeros_(module.bias)
+ elif isinstance(module, nn.Embedding):
+ nn.init.normal_(module.weight, mean=0.0, std=0.02)
+
+ self.apply(_init)
+
+ def registrar_tokenizer(self, tokenizer: object) -> None:
+ """Registra el tokenizer en el Tálamo para que LLAVES funcione."""
+ self.talamo.registrar_tokenizer(tokenizer)
+
+ def _enable_kv_cache(self) -> None:
+ """Enable KV cache mode for generation (inference only)."""
+ for nivel in self.niveles:
+ nivel.attn._use_kv_cache = True
+ nivel.attn._kv_cache = None
+ nivel.attn._start_pos = 0
+
+ def _disable_kv_cache(self) -> None:
+ """Disable KV cache and free cached tensors."""
+ for nivel in self.niveles:
+ nivel.attn._use_kv_cache = False
+ nivel.attn._kv_cache = None
+ nivel.attn._start_pos = 0
+
+ def _set_cache_pos(self, pos: int) -> None:
+ """Set the position offset for RoPE in all attention layers."""
+ for nivel in self.niveles:
+ nivel.attn._start_pos = pos
+
+ def set_train_norm_clamp(self, enabled: bool) -> None:
+ """Activa/desactiva norm clamping durante training en todos los niveles."""
+ for nivel in self.niveles:
+ nivel._train_norm_clamp = enabled
+
+ def _combinar_streams(
+ self,
+ streams: List[torch.Tensor],
+ terr_acts: torch.Tensor,
+ ) -> torch.Tensor:
+ """
+ Combina los 4 streams en una representación unificada.
+
+ Ponderada por activación territorial: el stream más activo domina.
+ Normalizada para que los pesos sumen 1.
+
+ Args:
+ streams: [n_streams × [B, L, D]]
+ terr_acts: [B, L, n_streams]
+ Returns:
+ x: [B, L, D]
+ """
+ # Normalizar pesos territoriales (softmax sobre streams)
+ weights = F.softmax(terr_acts, dim=-1) # [B, L, 4]
+ return sum(
+ streams[t] * weights[:, :, t : t + 1] for t in range(self.config.n_streams)
+ )
+
+ def forward(
+ self,
+ input_ids: torch.Tensor,
+ targets: Optional[torch.Tensor] = None,
+ use_early_exit: bool = False,
+ banco_engrama: Optional[BancoEngrama] = None,
+ ) -> Tuple[torch.Tensor, Optional[torch.Tensor], Dict]:
+ """
+ Forward pass de PamparV3.
+
+ Args:
+ input_ids: [B, L] token IDs
+ targets: [B, L] labels (-100 = ignorar)
+ use_early_exit: salir antes si confianza suficiente
+ banco_engrama: banco de engramas para inyección (None = sin inyección)
+
+ Returns:
+ logits: [B, L, vocab_size]
+ loss: scalar (si targets provisto)
+ info: {'exit_nivel': int, 'terr_acts': tensor}
+ """
+ B, L = input_ids.shape
+
+ # 1. Embedding
+ x = self.emb_drop(self.tok_emb(input_ids)) # [B, L, D]
+
+ # 2. Tálamo inicial: routing de tokens a zonas y territorios
+ terr_acts, zona_acts = self.talamo(x, input_ids) # [B,L,4], [B,L,52]
+
+ # 3. Inicializar los 4 streams desde el mismo embedding
+ # Cada stream parte del mismo punto y se especializa a lo largo
+ # de los n_levels niveles
+ streams: List[torch.Tensor] = [x.clone() for _ in range(self.config.n_streams)]
+
+ # 4. Pasar por cada nivel de profundidad
+ info: Dict = {"exit_nivel": self.config.n_levels, "terr_acts": terr_acts}
+
+ for i, nivel in enumerate(self.niveles):
+ if self.config.use_checkpoint and self.training and not use_early_exit:
+ # Gradient checkpointing: ahorra VRAM no guardando activaciones
+ # Se usan lambdas para pasar args no-tensor (agregar_fn)
+ def create_checkpoint_fn(n):
+ def fn(*stream_tensors):
+ s_list = list(stream_tensors[:-2])
+ ta = stream_tensors[-2]
+ za = stream_tensors[-1]
+ new_s, new_ta, _ = n(
+ s_list,
+ ta,
+ TalamoInicial.agregar_fn,
+ zona_acts=za,
+ )
+ return (*new_s, new_ta)
+
+ return fn
+
+ result = torch.utils.checkpoint.checkpoint(
+ create_checkpoint_fn(nivel),
+ *streams,
+ terr_acts,
+ zona_acts,
+ use_reentrant=False,
+ )
+ streams = list(result[: self.config.n_streams])
+ terr_acts = result[self.config.n_streams]
+ conf = 0.0 # No calculada durante checkpointing
+ else:
+ streams, terr_acts, conf = nivel(
+ streams,
+ terr_acts,
+ TalamoInicial.agregar_fn,
+ banco_engrama=banco_engrama,
+ zona_acts=zona_acts,
+ )
+
+ # Early Exit: si el nivel más difícil del 10% ya tiene confianza
+ if use_early_exit and conf > self.config.umbral_exit:
+ if i >= self.config.capas_min - 1:
+ info["exit_nivel"] = i + 1
+ break
+
+ # 5. Combinar streams en representación final
+ x_final = self._combinar_streams(streams, terr_acts) # [B, L, D]
+
+ # 6. Norm final + LM head
+ x_final = self.norm_f(x_final)
+ logits = self.lm_head(x_final) # [B, L, vocab_size]
+
+ # 7. Loss
+ loss = None
+ if targets is not None:
+ loss = F.cross_entropy(
+ logits.reshape(-1, self.config.vocab_size),
+ targets.reshape(-1),
+ ignore_index=-100,
+ )
+
+ return logits, loss, info
+
+ @torch.no_grad()
+ def generate(
+ self,
+ prompt_ids: torch.Tensor,
+ max_tokens: int = 256,
+ temperature: float = 0.8,
+ top_k: int = 50,
+ top_p: float = 0.95,
+ banco_engrama: Optional[BancoEngrama] = None,
+ ) -> torch.Tensor:
+ """
+ Generación autoregresiva con KV cache y nucleus sampling.
+
+ Usa prefill (procesa todo el prompt de una sola vez) +
+ decode (un token por paso, reutilizando el KV cache).
+
+ Args:
+ prompt_ids: [1, L] prompt tokenizado
+ max_tokens: máximo tokens a generar
+ temperature: diversidad (menor = más determinista)
+ top_k: Top-K sampling (0 = desactivado)
+ top_p: Nucleus sampling (1.0 = desactivado)
+ banco_engrama: banco de engramas para inyección adaptativa
+
+ Returns:
+ [1, L+N] tokens generados
+ """
+ self.eval()
+ generated = prompt_ids.clone()
+ prompt_len = prompt_ids.shape[1]
+
+ try:
+ self._enable_kv_cache()
+
+ # --- Prefill: procesar todo el prompt, poblar KV cache ---
+ self._set_cache_pos(0)
+ logits, _, _ = self.forward(
+ prompt_ids,
+ use_early_exit=False,
+ banco_engrama=banco_engrama,
+ )
+ logits = logits[:, -1, :] / temperature
+
+ for _ in range(max_tokens):
+ # Top-K filtering
+ if top_k > 0:
+ v, _ = logits.topk(top_k)
+ logits[logits < v[:, [-1]]] = float("-inf")
+
+ # Nucleus (Top-P) filtering
+ if top_p < 1.0:
+ sorted_logits, sorted_idx = logits.sort(descending=True)
+ cumprobs = sorted_logits.softmax(dim=-1).cumsum(dim=-1)
+ remove_mask = cumprobs - sorted_logits.softmax(dim=-1) > top_p
+ sorted_logits[remove_mask] = float("-inf")
+ logits = torch.zeros_like(logits).scatter(
+ 1,
+ sorted_idx,
+ sorted_logits,
+ )
+
+ # Guard contra NaN/Inf (frecuente en early training)
+ if torch.isnan(logits).any() or torch.isinf(logits).any():
+ logits = torch.nan_to_num(logits, nan=0.0, posinf=1e4, neginf=-1e4)
+
+ probs = F.softmax(logits, dim=-1)
+ if torch.isnan(probs).any() or (probs < 0).any():
+ probs = torch.ones_like(probs) / probs.shape[-1]
+
+ next_tok = torch.multinomial(probs, 1)
+ generated = torch.cat([generated, next_tok], dim=1)
+
+ # Stop en EOS (token 0)
+ if generated.shape[0] == 1 and next_tok.item() == 0:
+ break
+
+ # --- Decode: un token a la vez, KV cache se reutiliza ---
+ cur_pos = generated.shape[1] - 1
+ if cur_pos >= self.config.max_seq_len:
+ break
+
+ self._set_cache_pos(cur_pos)
+ logits, _, _ = self.forward(
+ next_tok,
+ use_early_exit=False,
+ banco_engrama=banco_engrama,
+ )
+ logits = logits[:, -1, :] / temperature
+
+ finally:
+ self._disable_kv_cache()
+
+ return generated
+
+ def count_params(self) -> Dict[str, int]:
+ """Cuenta parámetros por componente."""
+ return {
+ "embeddings": self.tok_emb.weight.numel(),
+ "talamo_inicial": sum(p.numel() for p in self.talamo.parameters()),
+ "niveles": sum(p.numel() for p in self.niveles.parameters()),
+ "norm_f": sum(p.numel() for p in self.norm_f.parameters()),
+ "total": sum(p.numel() for p in self.parameters()),
+ "total_sin_embedding": sum(
+ p.numel()
+ for name, p in self.named_parameters()
+ if "tok_emb" not in name
+ ),
+ }
+
+ def describe(self) -> str:
+ """Descripción legible de la arquitectura."""
+ p = self.count_params()
+ cfg = self.config
+ mem = cfg.memory_estimate_mb()
+ return (
+ f"PamparV3\n"
+ f" Arquitectura: {cfg.n_streams} streams × {cfg.n_levels} niveles (2D)\n"
+ f" Dimensiones: dim={cfg.dim}, heads={cfg.n_heads} "
+ f"(GQA {cfg.n_kv_heads} KV), ffn_hidden={cfg.ffn_hidden}\n"
+ f" Vocab: {cfg.vocab_size:,} tokens, seq_len={cfg.max_seq_len}\n"
+ f" Parámetros: {p['total'] / 1e6:.1f}M total "
+ f"({p['total_sin_embedding'] / 1e6:.1f}M sin embedding)\n"
+ f" VRAM model: {mem['model_fp16_mb']}MB (fp16)\n"
+ f" VRAM training: {mem['training_total_mb']}MB (model+grad+Adam)\n"
+ f" KV cache: {mem['kv_cache_inference_mb']}MB "
+ f"(batch=1, seq=4096)\n"
+ )
+
+
+# =============================================================================
+# FACTORY
+# =============================================================================
+
+
+def crear_modelo_v3(config: ConfigV3 = PRESET_V3) -> PamparV3:
+ """Crea un modelo PamparV3 con la configuración dada."""
+ return PamparV3(config)
diff --git a/pampar/coder/v3/nivel.py b/pampar/coder/v3/nivel.py
new file mode 100644
index 0000000000000000000000000000000000000000..9571d48acafcfc649a73c27c2c2158cb9c92f795
--- /dev/null
+++ b/pampar/coder/v3/nivel.py
@@ -0,0 +1,370 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Nivel profundo de la arquitectura 2D de PamparV3.
+
+Componentes:
+ TalamoNivel — Re-routing ligero del Tálamo en cada nivel
+ LateralGate — Comunicación lateral entre streams (fibras blancas)
+ NivelProfundo — Un nivel completo: atención + re-route + FFN + lateral + exit
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, List, Optional, Tuple
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+from .attn import BloqueAttn
+from .config import ConfigV3
+from .ffn import ContextModulator, StreamFFN
+from .norm import RMSNorm
+
+if TYPE_CHECKING:
+ from .engrama_stream import BancoEngrama
+
+
+class TalamoNivel(nn.Module):
+ """
+ Re-routing ligero del Tálamo aplicado en cada nivel de profundidad.
+
+ El Tálamo inicial hace el routing completo (LLAVES + attn_proj).
+ En cada nivel subsiguiente, el modelo re-evalúa las activaciones
+ territoriales basándose en el estado ACTUAL de los streams —
+ no en los tokens originales.
+
+ Esto permite que si `for` empieza como "control" (B06_KW_LOOP),
+ pero en el nivel 3 el modelo detecta que es parte de un comprehension
+ complejo, el routing puede deslizarse hacia "semántica".
+
+ Parámetros: dim → 52 (Linear sin bias, muy barato ~33K params).
+ """
+
+ def __init__(self, config: ConfigV3):
+ super().__init__()
+ self.n_zonas = config.n_zonas
+ self.n_territorios = config.n_territorios
+ self.peso_previo = 0.7 # 70% routing previo, 30% re-evaluación
+
+ # Proyección ligera: estado actual → zonas
+ self.zone_proj = nn.Linear(config.dim, config.n_zonas, bias=False)
+
+ def forward(
+ self,
+ x_combined: torch.Tensor, # [B, L, D] estado combinado de streams
+ terr_acts_prev: torch.Tensor, # [B, L, 4] activaciones territoriales previas
+ agregar_fn, # función agregar_zonas_a_territorios del Tálamo inicial
+ ) -> torch.Tensor:
+ """
+ Actualiza activaciones territoriales con el estado actual.
+
+ Returns:
+ terr_acts: [B, L, 4] activaciones actualizadas
+ """
+ # Nueva evaluación de zonas desde el estado actual
+ zonas_nuevas = torch.sigmoid(self.zone_proj(x_combined)) # [B, L, 52]
+
+ # Agregar zonas a territorios
+ terr_nuevo = agregar_fn(zonas_nuevas) # [B, L, 4]
+
+ # Mezclar con el routing previo: suavidad para evitar oscilaciones
+ terr_acts = self.peso_previo * terr_acts_prev + (
+ 1 - self.peso_previo
+ ) * torch.sigmoid(terr_nuevo)
+ return terr_acts
+
+
+class LateralGate(nn.Module):
+ """
+ Comunicación lateral entre streams — las "fibras blancas" del cerebro.
+
+ Cuando SINTAXIS está procesando fuertemente (activación 0.9), comparte
+ parte de su representación con SEMANTICA (que está en 0.7), ayudándola
+ a entender mejor el contexto estructural del token actual.
+
+ La contribución de cada stream vecino se pondera por su activación
+ territorial — streams muy activos aportan más a sus vecinos.
+
+ Arquitectura del gate por stream:
+ input: representaciones de los OTROS 3 streams [B, L, D×3] →
+ bottleneck: Linear(D×3, bottleneck) → SiLU → Linear(bottleneck, D)
+ output: aporte lateral [B, L, D]
+
+ Parámetros por stream: D×3→128 + 128→D ≈ 328K × 4 streams = 1.3M/nivel.
+ """
+
+ def __init__(self, config: ConfigV3):
+ super().__init__()
+ self.n_streams = config.n_streams
+ bn = config.lateral_bottleneck
+
+ # Un gate por stream (recibe de los otros n_streams-1)
+ others = config.n_streams - 1
+ self.gates = nn.ModuleList(
+ [
+ nn.Sequential(
+ nn.Linear(config.dim * others, bn, bias=False),
+ nn.SiLU(),
+ nn.Linear(bn, config.dim, bias=False),
+ )
+ for _ in range(config.n_streams)
+ ]
+ )
+
+ # Escala de contribución lateral (learnable, inicia pequeño)
+ self.scale = nn.Parameter(torch.full((config.n_streams,), 0.1))
+
+ def forward(
+ self,
+ streams: List[torch.Tensor], # [n_streams × [B, L, D]]
+ terr_acts: torch.Tensor, # [B, L, n_streams] peso de cada stream
+ ) -> List[torch.Tensor]:
+ """
+ Permite que cada stream reciba aporte de sus peers.
+
+ Returns:
+ streams actualizados: [n_streams × [B, L, D]]
+ """
+ out = []
+ for t in range(self.n_streams):
+ # Recolectar representaciones de los OTROS streams
+ others = [streams[k] for k in range(self.n_streams) if k != t]
+
+ # Ponderar cada vecino por su activación territorial
+ weighted_others = []
+ other_idx = 0
+ for k in range(self.n_streams):
+ if k == t:
+ continue
+ w = terr_acts[:, :, k : k + 1] # [B, L, 1]
+ weighted_others.append(others[other_idx] * w)
+ other_idx += 1
+
+ # Concatenar y proyectar
+ lateral_input = torch.cat(weighted_others, dim=-1) # [B, L, D*(n-1)]
+ lateral_out = self.gates[t](lateral_input) # [B, L, D]
+
+ # Aporte lateral escalado
+ streams_t_updated = streams[t] + self.scale[t] * lateral_out
+ out.append(streams_t_updated)
+
+ return out
+
+
+class NivelProfundo(nn.Module):
+ """
+ Un nivel de profundidad de la arquitectura 2D.
+
+ Cada nivel contiene:
+ 1. Atención GQA compartida — todos los streams ven el mismo contexto
+ 2. Re-routing del Tálamo — actualizar activaciones con estado actual
+ 3. FFN + modulación por stream (Mixed Selectivity o 4× FFN legacy)
+ 4. Lateral gates — streams se comunican entre sí
+ 5. Exit head — confianza para Early Exit
+
+ Mixed Selectivity (use_mixed_selectivity=True):
+ 1 FFN compartido + 4 ContextModulator → misma memoria, 4 lecturas
+ Ahorro: ~6.4M params/nivel vs 4 FFN separados.
+
+ Legacy (use_mixed_selectivity=False):
+ 4 StreamFFN independientes (comportamiento original v3).
+
+ Flujo por nivel:
+ streams[t] previos
+ ↓
+ x_combined = suma ponderada de streams (por terr_acts)
+ ↓ (atención compartida)
+ x_attn = BloqueAttn(x_combined)
+ ↓
+ terr_acts = TalamoNivel(x_combined + x_attn, terr_acts_prev)
+ ↓ (FFN compartido → modulación por stream)
+ h_base = FFN(RMSNorm(streams[t] + x_attn))
+ h[t] = ContextModulator(h_base, ctx) × terr_acts[t]
+ streams[t] = streams[t] + h[t]
+ ↓ (lateral gates)
+ streams = LateralGate(streams, terr_acts)
+ ↓
+ confianza = exit_head(x_combined)
+ """
+
+ def __init__(self, config: ConfigV3, nivel_idx: int = 0):
+ super().__init__()
+ self.config = config
+ self.nivel_idx = nivel_idx
+ self._use_mixed = config.use_mixed_selectivity
+
+ # Norm clamping adaptativo por nivel (previene explosión de activaciones)
+ self._stream_max_norm = 50.0 * (
+ 2.0**nivel_idx
+ ) # N0=50, N1=100, N2=200, N3=400, N4=800
+ self._use_norm_clamp = True # Clamp en inference
+ self._train_norm_clamp = (
+ False # Clamp también en training (activar con set_train_clamp)
+ )
+
+ # Pre-norms
+ self.norm_attn = RMSNorm(config.dim)
+ self.norm_streams = nn.ModuleList(
+ [RMSNorm(config.dim) for _ in range(config.n_streams)]
+ )
+
+ # Atención compartida (una por nivel)
+ self.attn = BloqueAttn(config)
+
+ # Re-routing ligero
+ self.talamo_nivel = TalamoNivel(config)
+
+ # ── FFN: Mixed Selectivity vs Legacy ─────────────────────────────────
+ if self._use_mixed:
+ # 1 FFN compartido + 4 moduladores contextuales
+ self.ffn_shared = StreamFFN(config)
+ self.modulators = nn.ModuleList(
+ [ContextModulator(config) for _ in range(config.n_streams)]
+ )
+ else:
+ # Legacy: 4 FFN independientes
+ self.ffns = nn.ModuleList(
+ [StreamFFN(config) for _ in range(config.n_streams)]
+ )
+
+ # Lateral gates
+ self.lateral = LateralGate(config)
+
+ # Dropout residual
+ self.drop = nn.Dropout(config.dropout)
+
+ # Exit head: confianza basada en el estado combinado
+ self.exit_head = nn.Linear(config.dim, 1, bias=False)
+
+ def forward(
+ self,
+ streams: List[torch.Tensor], # [n_streams × [B, L, D]]
+ terr_acts: torch.Tensor, # [B, L, n_territorios]
+ agregar_fn, # función del Tálamo para agregar zonas
+ banco_engrama: Optional[BancoEngrama] = None,
+ zona_acts: Optional[torch.Tensor] = None, # [B, L, 52]
+ ) -> Tuple[List[torch.Tensor], torch.Tensor, float]:
+ """
+ Forward de un nivel de profundidad.
+
+ Args:
+ streams: representaciones por stream [n_streams × [B, L, D]]
+ terr_acts: activaciones territoriales [B, L, 4]
+ agregar_fn: función del Tálamo para agregar zonas
+ banco_engrama: banco de engramas para inyección (None = sin inyección)
+ zona_acts: activaciones por zona para clave de búsqueda
+
+ Returns:
+ streams: representaciones actualizadas [n_streams × [B, L, D]]
+ terr_acts: activaciones actualizadas [B, L, 4]
+ conf: confianza para Early Exit (float)
+ """
+ # 1. Representación combinada ponderada por activación territorial
+ x_combined = sum(
+ streams[t] * terr_acts[:, :, t : t + 1]
+ for t in range(self.config.n_streams)
+ ) # [B, L, D]
+
+ # 2. Atención compartida en el espacio combinado
+ x_attn = self.drop(self.attn(self.norm_attn(x_combined)))
+
+ # 2.5 Inyección de EngramaStream (si hay banco disponible)
+ if banco_engrama is not None and zona_acts is not None:
+ with torch.no_grad():
+ terr_dom = terr_acts[0].argmax(dim=-1) # [L]
+ zona_dom = zona_acts[0].argmax(dim=-1) # [L]
+ eng_vecs, eng_mask = banco_engrama.buscar_batch(
+ self.nivel_idx, terr_dom, zona_dom, x_attn.device
+ )
+ if eng_mask.any():
+ alpha = 0.03 / (1.0 + 0.5 * self.nivel_idx)
+ eng_residual = eng_vecs.unsqueeze(0) # [1, L, D]
+ mask_f = eng_mask.float().unsqueeze(0).unsqueeze(-1) # [1, L, 1]
+
+ attn_norm = F.normalize(x_attn, dim=-1)
+ eng_norm = F.normalize(eng_residual, dim=-1)
+ cosine = (attn_norm * eng_norm).sum(dim=-1, keepdim=True)
+
+ cosine_gate = torch.clamp(cosine - 0.3, min=0.0)
+
+ attn_scale = x_attn.norm(dim=-1, keepdim=True).clamp(min=1e-8)
+ eng_scale = eng_residual.norm(dim=-1, keepdim=True).clamp(min=1e-8)
+ eng_normalized = eng_residual * (attn_scale / eng_scale)
+
+ x_attn = x_attn + alpha * cosine_gate * mask_f * eng_normalized
+
+ # 3. Re-routing del Tálamo con estado actual
+ terr_acts = self.talamo_nivel(x_combined + x_attn, terr_acts, agregar_fn)
+
+ # 3.5 Confianza previa para modulación
+ conf_value = 0.5 # default neutro
+ if self._use_mixed:
+ with torch.no_grad():
+ _pre_conf = torch.sigmoid(self.exit_head(x_combined + x_attn)).squeeze(
+ -1
+ )
+ _k = max(1, int(_pre_conf.numel() * self.config.exit_percentile))
+ conf_value = (
+ _pre_conf.reshape(-1).topk(_k, largest=False).values.mean().item()
+ )
+
+ # 4. FFN — Mixed Selectivity o Legacy
+ new_streams = []
+
+ if self._use_mixed:
+ for t in range(self.config.n_streams):
+ h_normed = self.norm_streams[t](streams[t] + x_attn)
+ h_base = self.ffn_shared(h_normed)
+
+ h_mod = self.modulators[t](
+ h_base,
+ zona_acts=zona_acts
+ if zona_acts is not None
+ else torch.zeros(
+ h_base.shape[0],
+ h_base.shape[1],
+ 52,
+ device=h_base.device,
+ dtype=h_base.dtype,
+ ),
+ terr_acts=terr_acts,
+ stream_idx=t,
+ nivel_idx=self.nivel_idx,
+ n_levels=self.config.n_levels,
+ conf=conf_value,
+ )
+
+ h = h_mod * terr_acts[:, :, t : t + 1]
+ new_streams.append(streams[t] + self.drop(h))
+ else:
+ for t in range(self.config.n_streams):
+ h_normed = self.norm_streams[t](streams[t] + x_attn)
+ h = self.ffns[t](h_normed) * terr_acts[:, :, t : t + 1]
+ new_streams.append(streams[t] + self.drop(h))
+
+ # 5. Lateral gates — los streams se comunican
+ streams = self.lateral(new_streams, terr_acts)
+
+ # 5.5 Norm clamping — previene explosión de activaciones
+ clamp_active = self._use_norm_clamp and (
+ not self.training or self._train_norm_clamp
+ )
+ if clamp_active:
+ max_norm = self._stream_max_norm
+ for t in range(self.config.n_streams):
+ norms = streams[t].norm(dim=-1, keepdim=True)
+ scale = torch.clamp(max_norm / norms.clamp(min=1e-8), max=1.0)
+ streams[t] = streams[t] * scale
+
+ # 6. Confianza para Early Exit (percentil 10 de tokens más difíciles)
+ x_out = sum(
+ streams[t] * terr_acts[:, :, t : t + 1]
+ for t in range(self.config.n_streams)
+ )
+ per_token_conf = torch.sigmoid(self.exit_head(x_out)).squeeze(-1)
+ k = max(1, int(per_token_conf.numel() * self.config.exit_percentile))
+ conf = per_token_conf.reshape(-1).topk(k, largest=False).values.mean().item()
+
+ return streams, terr_acts, conf
diff --git a/pampar/coder/v3/norm.py b/pampar/coder/v3/norm.py
new file mode 100644
index 0000000000000000000000000000000000000000..a80b9c8306b9202bc9381dfaa1a6a84f09a78c3f
--- /dev/null
+++ b/pampar/coder/v3/norm.py
@@ -0,0 +1,26 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""RMS Normalization — Llama-style, más eficiente que LayerNorm."""
+
+from __future__ import annotations
+
+import torch
+import torch.nn as nn
+
+
+class RMSNorm(nn.Module):
+ """
+ Root Mean Square Layer Normalization.
+
+ Más eficiente que LayerNorm: omite el centrado, solo normaliza por RMS.
+ Usado en Llama, Qwen, Mistral.
+ """
+
+ def __init__(self, dim: int, eps: float = 1e-6):
+ super().__init__()
+ self.eps = eps
+ self.weight = nn.Parameter(torch.ones(dim))
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ rms = torch.rsqrt(x.float().pow(2).mean(-1, keepdim=True) + self.eps)
+ return (x.float() * rms).type_as(x) * self.weight
diff --git a/pampar/coder/v3/rope.py b/pampar/coder/v3/rope.py
new file mode 100644
index 0000000000000000000000000000000000000000..daf43e99028a4a500da06b58ee4dbc8da2f1f6c3
--- /dev/null
+++ b/pampar/coder/v3/rope.py
@@ -0,0 +1,36 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""Rotary Position Embedding (RoPE) — Su et al., 2021."""
+
+from __future__ import annotations
+
+import torch
+import torch.nn as nn
+
+
+class RoPE(nn.Module):
+ """
+ Rotary Position Embedding (Su et al., 2021).
+
+ Codifica posiciones como rotaciones complejas en Q y K.
+ Zero parámetros extra (solo buffers pre-computados).
+ Generaliza naturalmente a secuencias más largas que el training.
+ """
+
+ def __init__(self, dim: int, max_seq_len: int = 4096, base: float = 10000.0):
+ super().__init__()
+ inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
+ self.register_buffer("inv_freq", inv_freq)
+
+ pos = torch.arange(max_seq_len)
+ freqs = torch.outer(pos, inv_freq)
+ self.register_buffer("cos_cache", freqs.cos())
+ self.register_buffer("sin_cache", freqs.sin())
+
+ def forward(self, x: torch.Tensor, start_pos: int = 0) -> torch.Tensor:
+ """Aplica RoPE a tensor [B, H, L, D]. start_pos offsets positions for KV cache."""
+ L = x.shape[2]
+ cos = self.cos_cache[start_pos : start_pos + L].unsqueeze(0).unsqueeze(0)
+ sin = self.sin_cache[start_pos : start_pos + L].unsqueeze(0).unsqueeze(0)
+ x1, x2 = x[..., ::2], x[..., 1::2]
+ return torch.cat([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1)
diff --git a/pampar/coder/v3/talamo.py b/pampar/coder/v3/talamo.py
new file mode 100644
index 0000000000000000000000000000000000000000..623a16ad3420ed81d700690f778b149b341b9682
--- /dev/null
+++ b/pampar/coder/v3/talamo.py
@@ -0,0 +1,133 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Tálamo inicial de PamparV3.
+
+El Tálamo hace el routing de entrada: dado un token, determina
+qué streams (territorios) y zonas deben procesar ese token con qué
+peso.
+
+Dos componentes:
+ TalamoInicial — routing completo al inicio del forward
+ LLAVES (80%, reglas INT8) + attn_proj (20%, aprendido)
+ + context_conv causal (contextualización del vecindario)
+
+ TalamoNivel — re-routing ligero en cada NivelProfundo
+ (definido en bloques.py, solo usa el estado actual x)
+
+La función agregar_zonas_a_territorios es compartida con v2 —
+no tiene sentido duplicarla, el mapeo Zona → Territorio es el mismo.
+"""
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+from typing import Optional, Tuple
+
+from .config import ConfigV3
+from .llaves import LlavesV2, agregar_zonas_a_territorios
+
+
+class TalamoInicial(nn.Module):
+ """
+ Routing inicial de tokens a zonas y territorios.
+
+ Igual que en v2 pero ajustado a dim=640 de v3.
+
+ 80% reglas (LLAVES: lookup tables INT8 sobre vocabulario) +
+ 20% aprendido (attn_proj: proyección lineal sobre embeddings).
+
+ El context_conv causal evita routing "ciego":
+ ve la ventana de 32 tokens anteriores para saber si "for" es
+ un loop o parte de "formula".
+
+ La función agregar_fn() está expuesta como método para poder
+ pasarla a TalamoNivel en cada NivelProfundo sin hardcoding.
+ """
+
+ def __init__(self, config: ConfigV3):
+ super().__init__()
+ self.config = config
+ self.peso_llaves = config.peso_llaves
+ self.ventana = config.ventana_contexto
+
+ # LLAVES: sistema de lookup rules sobre token_ids (sin parámetros)
+ self.llaves = LlavesV2(config.vocab_size, config.n_zonas)
+
+ # Routing aprendido: embeddings → zonas
+ # Bottleneck para mantener params bajos
+ mid = config.dim // 2 # 320 para dim=640
+ self.attn_proj = nn.Sequential(
+ nn.Linear(config.dim, mid, bias=False),
+ nn.GELU(),
+ nn.Linear(mid, config.n_zonas, bias=False),
+ )
+
+ # Contextualización causal: cada token ve su vecindario anterior
+ # Groups=n_zonas → depthwise conv (muy barato en params)
+ self.context_conv = nn.Conv1d(
+ in_channels=config.n_zonas,
+ out_channels=config.n_zonas,
+ kernel_size=config.ventana_contexto,
+ groups=config.n_zonas,
+ bias=False,
+ )
+
+ # Gate final de territorios
+ self.terr_gate = nn.Linear(
+ config.n_territorios, config.n_territorios, bias=False
+ )
+
+ # Tokenizer (registrado después de crear el modelo)
+ self._tokenizer: Optional[object] = None
+
+ def registrar_tokenizer(self, tokenizer: object) -> None:
+ """Registra el tokenizer para que LLAVES pueda usarlo."""
+ self._tokenizer = tokenizer
+ if hasattr(self.llaves, "registrar_tokenizer"):
+ self.llaves.registrar_tokenizer(tokenizer)
+
+ @staticmethod
+ def agregar_fn(zona_acts: torch.Tensor) -> torch.Tensor:
+ """
+ Wrapper estático de agregar_zonas_a_territorios.
+ Se pasa como callable a TalamoNivel en cada nivel.
+ """
+ return agregar_zonas_a_territorios(zona_acts)
+
+ def forward(
+ self,
+ x: torch.Tensor, # [B, L, D] embeddings
+ token_ids: torch.Tensor, # [B, L] IDs de tokens
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
+ """
+ Computa activaciones de zonas y territorios para la entrada.
+
+ Returns:
+ terr_acts: [B, L, n_territorios]
+ zona_acts: [B, L, n_zonas]
+ """
+ # 1. LLAVES: lookup basado en reglas sobre token_ids
+ llaves_acts = self.llaves(token_ids) # [B, L, 52]
+
+ # 2. Atención aprendida: embeddings → zonas
+ attn_acts = torch.sigmoid(self.attn_proj(x)) # [B, L, 52]
+
+ # 3. Combinar: 80% LLAVES (estables desde epoch 0) + 20% aprendido
+ zona_acts = self.peso_llaves * llaves_acts + (1 - self.peso_llaves) * attn_acts
+
+ # 4. Contextualización causal
+ # Pad izquierda (causal: solo ve pasado)
+ zona_ctx = F.pad(
+ zona_acts.transpose(1, 2), # [B, 52, L]
+ (self.ventana - 1, 0),
+ )
+ zona_acts = self.context_conv(zona_ctx).transpose(1, 2) # [B, L, 52]
+
+ # 5. Agregar zonas → territorios
+ terr_acts = self.agregar_fn(zona_acts) # [B, L, 4]
+
+ # 6. Gate sobre territorios (aprende escala por territorio)
+ terr_acts = torch.sigmoid(self.terr_gate(terr_acts))
+
+ return terr_acts, zona_acts
diff --git a/pampar/coder/v3/zonas.py b/pampar/coder/v3/zonas.py
new file mode 100644
index 0000000000000000000000000000000000000000..d33fe1c6b35813ca519910c7fd78142534a3c011
--- /dev/null
+++ b/pampar/coder/v3/zonas.py
@@ -0,0 +1,691 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Definición de 52 Zonas de Brodmann para código.
+
+Inspirado en la neurociencia: cada zona procesa un tipo específico
+de información, permitiendo especialización y eficiencia.
+
+Territorios (4):
+- SINTAXIS: Estructura del lenguaje (keywords, delimitadores)
+- SEMANTICA: Significado (identificadores, literales)
+- LOGICO: Razonamiento (operadores, control de flujo)
+- ESTRUCTURAL: Patrones (bloques, formato)
+"""
+
+from enum import IntEnum, auto
+from typing import Dict, Set, Tuple
+
+
+class Territorio(IntEnum):
+ """Los 4 macro-territorios (lóbulos cerebrales)."""
+
+ SINTAXIS = 0
+ SEMANTICA = 1
+ LOGICO = 2
+ ESTRUCTURAL = 3
+
+
+class Zona(IntEnum):
+ """
+ 52 zonas especializadas para procesamiento de código.
+
+ Nomenclatura: B{num}_{funcion}
+ - B01-B15: SINTAXIS
+ - B16-B30: SEMANTICA
+ - B31-B42: LOGICO
+ - B43-B52: ESTRUCTURAL
+ """
+
+ # =========================================================================
+ # SINTAXIS (15 zonas) - Estructura del lenguaje
+ # =========================================================================
+ B01_KW_DEF = auto() # def, function, fn
+ B02_KW_CLASS = auto() # class, struct, interface
+ B03_KW_IMPORT = auto() # import, from, require
+ B04_KW_RETURN = auto() # return, yield
+ B05_KW_CONTROL = auto() # if, else, elif, switch
+ B06_KW_LOOP = auto() # for, while, loop
+ B07_KW_EXCEPT = auto() # try, except, catch, finally
+ B08_KW_ASYNC = auto() # async, await
+ B09_KW_MOD = auto() # public, private, static
+ B10_KW_VAR = auto() # let, const, var
+ B11_DELIM_PAREN = auto() # ( )
+ B12_DELIM_BRACK = auto() # [ ]
+ B13_DELIM_BRACE = auto() # { }
+ B14_PUNCT = auto() # , ; :
+ B15_COMMENT = auto() # # // /* */
+
+ # =========================================================================
+ # SEMANTICA (15 zonas) - Significado
+ # =========================================================================
+ B16_ID_VAR = auto() # variables locales
+ B17_ID_FUNC = auto() # nombres de funciones
+ B18_ID_CLASS = auto() # nombres de clases
+ B19_ID_PARAM = auto() # parámetros
+ B20_ID_ATTR = auto() # atributos .attr
+ B21_LIT_INT = auto() # enteros
+ B22_LIT_FLOAT = auto() # decimales
+ B23_LIT_STR = auto() # strings
+ B24_LIT_BOOL = auto() # True, False
+ B25_LIT_NONE = auto() # None, null, nil
+ B26_TYPE_PRIM = auto() # int, str, float
+ B27_TYPE_COLL = auto() # list, dict, set
+ B28_TYPE_GEN = auto() # Optional, List[T]
+ B29_BUILTIN = auto() # print, len, range
+ B30_MAGIC = auto() # __init__, __str__
+
+ # =========================================================================
+ # LOGICO (12 zonas) - Razonamiento
+ # =========================================================================
+ B31_OP_ARITH = auto() # + - * / % **
+ B32_OP_COMP = auto() # == != < > <= >=
+ B33_OP_LOGIC = auto() # and or not
+ B34_OP_BIT = auto() # & | ^ ~ << >>
+ B35_OP_ASSIGN = auto() # = += -= *=
+ B36_OP_MEMBER = auto() # . ->
+ B37_OP_TERNARY = auto() # ? :
+ B38_FLOW_BRANCH = auto() # decisiones if/else
+ B39_FLOW_LOOP = auto() # iteraciones
+ B40_FLOW_JUMP = auto() # break, continue
+ B41_FLOW_CALL = auto() # llamadas a función
+ B42_FLOW_EXCEPT = auto() # manejo de excepciones
+
+ # =========================================================================
+ # ESTRUCTURAL (10 zonas) - Patrones
+ # =========================================================================
+ B43_BLOCK_FUNC = auto() # cuerpo de función
+ B44_BLOCK_CLASS = auto() # cuerpo de clase
+ B45_BLOCK_LOOP = auto() # cuerpo de loop
+ B46_BLOCK_COND = auto() # cuerpo de condicional
+ B47_INDENT = auto() # indentación
+ B48_NEWLINE = auto() # saltos de línea
+ B49_SPACE = auto() # espacios
+ B50_PATTERN_LIST = auto() # comprehensions
+ B51_PATTERN_DICT = auto() # dict literals
+ B52_PATTERN_CALL = auto() # f(x, y, z)
+
+
+# =============================================================================
+# MAPEO ZONA -> TERRITORIO
+# =============================================================================
+
+
+def _zona_a_territorio(zona: Zona) -> Territorio:
+ """Determina el territorio de una zona."""
+ z = zona.value
+ if z <= 15:
+ return Territorio.SINTAXIS
+ elif z <= 30:
+ return Territorio.SEMANTICA
+ elif z <= 42:
+ return Territorio.LOGICO
+ else:
+ return Territorio.ESTRUCTURAL
+
+
+# Cache del mapeo
+ZONA_TERRITORIO: Dict[Zona, Territorio] = {z: _zona_a_territorio(z) for z in Zona}
+
+# Override: B35_OP_ASSIGN (=, +=, -=, etc.) → SINTAXIS
+# Justificación lingüística: la asignación es un constructo sintáctico
+# de nivel sentencia, NO una operación lógica/computacional como + o and.
+# El modelo ya routea `=` a SINTAXIS de forma natural.
+ZONA_TERRITORIO[Zona.B35_OP_ASSIGN] = Territorio.SINTAXIS
+
+# Override: B07_KW_EXCEPT (try, except, finally, raise) → SEMANTICA
+# Justificación: los keywords de excepción definen semántica de errores —
+# QUÉ errores pueden ocurrir y CÓMO manejarlos. A diferencia de if/for
+# (control flow puro), el manejo de excepciones es un concern semántico.
+# El modelo los routea a SEMANTICA de forma consistente.
+ZONA_TERRITORIO[Zona.B07_KW_EXCEPT] = Territorio.SEMANTICA
+
+# Zonas por territorio
+ZONAS_POR_TERRITORIO: Dict[Territorio, Tuple[Zona, ...]] = {
+ t: tuple(z for z in Zona if ZONA_TERRITORIO[z] == t) for t in Territorio
+}
+
+
+# =============================================================================
+# PATRONES DE TOKENS POR ZONA
+# =============================================================================
+
+# PRINCIPIO: cada token tiene UNA zona primaria.
+# LLAVES busca en orden, la primera coincidencia gana.
+# Zonas ESTRUCTURALES y LOGICO usan patrones regex + contexto
+# (vía context_conv en el Tálamo), NO duplican tokens de SINTAXIS.
+
+ZONAS: Dict[Zona, Set[str]] = {
+ # =========================================================================
+ # SINTAXIS (15 zonas) — keywords y delimitadores del lenguaje
+ # Cada keyword pertenece a UNA sola zona primaria.
+ # Multi-lenguaje: Python, JavaScript/TypeScript, Rust, SQL, Bash
+ # =========================================================================
+ Zona.B01_KW_DEF: {
+ "def",
+ "lambda", # Python
+ "function",
+ "fn", # JS, Rust
+ },
+ Zona.B02_KW_CLASS: {
+ "class", # Python, JS
+ "struct",
+ "enum", # Rust, C, TS
+ "interface",
+ "trait", # TS, Rust
+ "impl", # Rust
+ "extends",
+ "implements", # JS/TS, Java
+ },
+ Zona.B03_KW_IMPORT: {
+ "import", # Python, JS
+ "require",
+ "export", # Node.js, JS modules
+ "use",
+ "mod",
+ "crate", # Rust
+ "include",
+ "package", # C, Go
+ "source", # Bash
+ },
+ Zona.B04_KW_RETURN: {"return", "yield"},
+ Zona.B05_KW_CONTROL: {
+ "if",
+ "else",
+ "elif", # Python
+ "match",
+ "case", # Python 3.10+, Rust, SQL
+ "switch",
+ "default", # JS, C
+ "then",
+ "fi",
+ "esac", # Bash
+ "when",
+ "end", # SQL CASE/WHEN, Ruby
+ },
+ Zona.B06_KW_LOOP: {
+ "for",
+ "while", # Universal
+ "do",
+ "loop", # JS do-while, Rust infinite loop
+ "done",
+ "foreach", # Bash, PHP/Perl
+ },
+ Zona.B07_KW_EXCEPT: {
+ "try",
+ "except", # Python
+ "finally",
+ "raise", # Python
+ "catch",
+ "throw", # JS, Java, C++, Rust
+ },
+ Zona.B08_KW_ASYNC: {"async", "await"},
+ Zona.B09_KW_MOD: {
+ # Python
+ "global",
+ "nonlocal",
+ "del",
+ "with",
+ "as",
+ "staticmethod",
+ "classmethod",
+ "property",
+ # OOP modifiers (JS/TS, Java, C#)
+ "public",
+ "private",
+ "protected",
+ "static",
+ "abstract",
+ "final",
+ "override",
+ "readonly",
+ "new", # JS/Java object creation
+ # Rust modifiers
+ "mut",
+ "pub",
+ "unsafe",
+ "extern",
+ # SQL DDL/DML actions & modifiers
+ "select",
+ "insert",
+ "update",
+ "delete",
+ "create",
+ "drop",
+ "alter",
+ "truncate",
+ "order",
+ "group",
+ "limit",
+ "offset",
+ "distinct",
+ "primary",
+ "foreign",
+ "key",
+ "references",
+ "constraint",
+ "unique",
+ # Bash
+ "alias",
+ },
+ Zona.B10_KW_VAR: {
+ "assert",
+ "pass",
+ "break",
+ "continue", # Python
+ "let",
+ "const",
+ "var", # JS, Rust
+ "exit", # Bash/shell
+ },
+ # Incluye tokens combinados paren+quote del tokenizer (SentencePiece).
+ Zona.B11_DELIM_PAREN: {"(", ")", "('", '("', "')", '")'},
+ Zona.B12_DELIM_BRACK: {"[", "]"},
+ Zona.B13_DELIM_BRACE: {"{", "}"},
+ Zona.B14_PUNCT: {",", ";", ":", "..."},
+ Zona.B15_COMMENT: {
+ "#", # Python, Bash
+ "//", # JS, Rust, C
+ "/*",
+ "*/", # JS, C, Rust block comments
+ "--", # SQL
+ },
+ # =========================================================================
+ # SEMANTICA (15 zonas) — significado: identificadores, literales, tipos
+ # =========================================================================
+ Zona.B16_ID_VAR: {
+ "self",
+ "cls",
+ "_", # Python
+ "this", # JS, Java
+ },
+ Zona.B17_ID_FUNC: {}, # Detectado por regex (snake_case seguido de "(")
+ Zona.B18_ID_CLASS: {}, # Detectado por regex (CamelCase, UPPER_CASE)
+ Zona.B19_ID_PARAM: {"args", "kwargs"},
+ Zona.B20_ID_ATTR: {}, # Detectado por contexto (después de ".")
+ Zona.B21_LIT_INT: {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ Zona.B22_LIT_FLOAT: {
+ "0.0",
+ "1.0",
+ "0.5",
+ "0.1",
+ "3.14",
+ "1e-5",
+ "NaN",
+ "Infinity", # JS globals
+ },
+ Zona.B23_LIT_STR: {
+ "'",
+ '"', # Universal
+ "f'",
+ 'f"',
+ "r'",
+ 'r"',
+ "b'",
+ 'b"', # Python
+ "`", # JS template literals
+ },
+ Zona.B24_LIT_BOOL: {
+ "True",
+ "False", # Python
+ "true",
+ "false", # JS, Rust, JSON, SQL
+ },
+ Zona.B25_LIT_NONE: {
+ "None", # Python
+ "null",
+ "undefined", # JS, SQL
+ "nil", # Go, Ruby, Lua
+ },
+ Zona.B26_TYPE_PRIM: {
+ # Python
+ "int",
+ "str",
+ "float",
+ "bool",
+ "bytes",
+ "complex",
+ # JS/TS primitives
+ "number",
+ "string",
+ "boolean",
+ "bigint",
+ "symbol",
+ "void",
+ # Rust primitives
+ "i8",
+ "i16",
+ "i32",
+ "i64",
+ "i128",
+ "isize",
+ "u8",
+ "u16",
+ "u32",
+ "u64",
+ "u128",
+ "usize",
+ "f32",
+ "f64",
+ "char",
+ # SQL types
+ "integer",
+ "varchar",
+ "text",
+ "decimal",
+ "numeric",
+ "timestamp",
+ "serial",
+ "bigint",
+ "smallint",
+ },
+ Zona.B27_TYPE_COLL: {
+ # Python
+ "list",
+ "dict",
+ "set",
+ "tuple",
+ "frozenset",
+ "deque",
+ # JS/TS built-in objects
+ "Array",
+ "Object",
+ "Map",
+ "Set",
+ "WeakMap",
+ "WeakSet",
+ # Rust collections
+ "Vec",
+ "HashMap",
+ "HashSet",
+ "BTreeMap",
+ "BTreeSet",
+ "VecDeque",
+ "String", # Rust String (heap-allocated)
+ # SQL structural
+ "table",
+ "view",
+ "index",
+ "schema",
+ "database",
+ "column",
+ },
+ Zona.B28_TYPE_GEN: {
+ # Python typing
+ "Optional",
+ "List",
+ "Dict",
+ "Tuple",
+ "Set",
+ "Union",
+ "Any",
+ "Callable",
+ "Iterator",
+ "Generator",
+ "Iterable",
+ "Sequence",
+ "Mapping",
+ # Rust generics/wrappers
+ "Option",
+ "Result",
+ "Box",
+ "Arc",
+ "Rc",
+ "Ref",
+ "RefCell",
+ "Mutex",
+ "Some",
+ "Ok",
+ "Err", # Rust enum variants (used as type constructors)
+ # TS utility types
+ "Partial",
+ "Readonly",
+ "Record",
+ "Pick",
+ "Omit",
+ "Exclude",
+ "Extract",
+ "ReturnType",
+ "Promise",
+ },
+ Zona.B29_BUILTIN: {
+ # Python built-ins
+ "print",
+ "len",
+ "range",
+ "open",
+ "input",
+ "type",
+ "isinstance",
+ "issubclass",
+ "hasattr",
+ "getattr",
+ "setattr",
+ "delattr",
+ "abs",
+ "min",
+ "max",
+ "sum",
+ "sorted",
+ "reversed",
+ "enumerate",
+ "zip",
+ "map",
+ "filter",
+ "any",
+ "all",
+ "round",
+ "pow",
+ "repr",
+ "hash",
+ "id",
+ "iter",
+ "next",
+ "callable",
+ "super",
+ "object",
+ "format",
+ "chr",
+ "ord",
+ "hex",
+ "bin",
+ "oct",
+ "ValueError",
+ "TypeError",
+ "KeyError",
+ "IndexError",
+ "AttributeError",
+ "RuntimeError",
+ "StopIteration",
+ "FileNotFoundError",
+ "IOError",
+ "Exception",
+ "BaseException",
+ "NotImplementedError",
+ "ZeroDivisionError",
+ # JS built-ins
+ "console",
+ "JSON",
+ "Math",
+ "Date",
+ "RegExp",
+ "Symbol",
+ "parseInt",
+ "parseFloat",
+ "isNaN",
+ "isFinite",
+ "setTimeout",
+ "setInterval",
+ "clearTimeout",
+ "clearInterval",
+ "fetch",
+ "Error",
+ "Promise",
+ # Rust macros (sin !, el tokenizer separa el !)
+ "println",
+ "eprintln",
+ "dbg",
+ "vec",
+ "panic",
+ "assert_eq",
+ "assert_ne",
+ "todo",
+ "unimplemented",
+ # SQL aggregate/scalar functions
+ "count",
+ "avg",
+ "coalesce",
+ "cast",
+ "convert",
+ "exists",
+ "between",
+ "like",
+ "ilike",
+ # Bash built-in commands
+ "echo",
+ "read",
+ "cd",
+ "ls",
+ "grep",
+ "sed",
+ "awk",
+ "cat",
+ "mv",
+ "cp",
+ "rm",
+ "mkdir",
+ "chmod",
+ "chown",
+ "find",
+ "xargs",
+ "curl",
+ "wget",
+ "tar",
+ "ssh",
+ "git",
+ "docker",
+ },
+ Zona.B30_MAGIC: {
+ "__init__",
+ "__str__",
+ "__repr__",
+ "__len__",
+ "__call__",
+ "__enter__",
+ "__exit__",
+ "__iter__",
+ "__next__",
+ "__getitem__",
+ "__setitem__",
+ "__delitem__",
+ "__contains__",
+ "__eq__",
+ "__lt__",
+ "__gt__",
+ "__le__",
+ "__ge__",
+ "__ne__",
+ "__hash__",
+ "__add__",
+ "__sub__",
+ "__mul__",
+ "__truediv__",
+ "__floordiv__",
+ "__mod__",
+ "__pow__",
+ "__and__",
+ "__or__",
+ "__xor__",
+ "__bool__",
+ "__int__",
+ "__float__",
+ "__index__",
+ "__new__",
+ "__del__",
+ "__slots__",
+ "__dict__",
+ "__class__",
+ "__name__",
+ "__doc__",
+ "__module__",
+ "__file__",
+ "__all__",
+ },
+ # =========================================================================
+ # LOGICO (12 zonas) — operadores y razonamiento
+ # Sin duplicados de SINTAXIS. Keywords como "and", "or", "not", "in", "is"
+ # pertenecen aquí porque su función primaria es lógica.
+ # =========================================================================
+ Zona.B31_OP_ARITH: {"+", "-", "*", "/", "%", "**", "//"},
+ Zona.B32_OP_COMP: {
+ "==",
+ "!=",
+ "<",
+ ">",
+ "<=",
+ ">=",
+ "is",
+ "in",
+ "not",
+ "===",
+ "!==", # JS strict equality
+ "typeof",
+ "instanceof", # JS type operators
+ },
+ Zona.B33_OP_LOGIC: {
+ "and",
+ "or", # Python
+ "&&",
+ "||", # JS, Rust, C, Bash
+ "union",
+ "intersect", # SQL set operations
+ },
+ Zona.B34_OP_BIT: {"&", "|", "^", "~", "<<", ">>", ">>>"},
+ Zona.B35_OP_ASSIGN: {
+ "=",
+ "+=",
+ "-=",
+ "*=",
+ "/=",
+ ":=",
+ "//=",
+ "**=",
+ "%=",
+ "&=",
+ "|=",
+ "^=",
+ "<<=",
+ ">>=", # Bitwise assigns (JS, Rust, C)
+ "??=", # JS nullish assign
+ },
+ Zona.B36_OP_MEMBER: {
+ ".", # Universal
+ "::", # Rust path separator
+ "?.", # JS optional chaining
+ },
+ Zona.B37_OP_TERNARY: {
+ "??", # JS nullish coalescing
+ },
+ Zona.B38_FLOW_BRANCH: {}, # Delegado a B05 + context_conv detecta branching
+ Zona.B39_FLOW_LOOP: {}, # Delegado a B06 + context_conv detecta iteración
+ Zona.B40_FLOW_JUMP: {}, # break/continue ya en B10
+ Zona.B41_FLOW_CALL: {}, # Detectado por contexto: id + "("
+ Zona.B42_FLOW_EXCEPT: {}, # Delegado a B07
+ # =========================================================================
+ # ESTRUCTURAL (10 zonas) — patrones y formato
+ # Formato/whitespace puro. No duplica keywords.
+ # =========================================================================
+ Zona.B43_BLOCK_FUNC: {
+ "->", # Python return type, Rust return type
+ "=>", # JS arrow function
+ },
+ Zona.B44_BLOCK_CLASS: {}, # class ya en B02
+ Zona.B45_BLOCK_LOOP: {}, # for/while ya en B06
+ Zona.B46_BLOCK_COND: {}, # if/elif/else ya en B05
+ Zona.B47_INDENT: {"\t", " "},
+ Zona.B48_NEWLINE: {"\n", "\r\n"},
+ Zona.B49_SPACE: {" ", " "},
+ Zona.B50_PATTERN_LIST: {}, # Detectado por contexto: "[" + "for" + "in"
+ Zona.B51_PATTERN_DICT: {}, # Detectado por contexto: "{" + ":" + "}"
+ Zona.B52_PATTERN_CALL: {"from"}, # Structural framing: from X import Y, SQL FROM
+}
diff --git a/pampar/constants.py b/pampar/constants.py
new file mode 100644
index 0000000000000000000000000000000000000000..1d39eaed04437c35568210512edd63c919fda68c
--- /dev/null
+++ b/pampar/constants.py
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Constantes globales de PAMPAr-Coder.
+
+Single source of truth para paths y valores compartidos entre módulos.
+"""
+
+# Tokenizer oficial (SentencePiece 48K vocab bilingual)
+TOKENIZER_PATH = "data/tokenizer/pampar_48k.model"
+
+# Tokenizer legacy (16K, solo para compatibilidad con v1/v2)
+TOKENIZER_LEGACY_PATH = "data/tokenizer/code_tokenizer.model"
diff --git a/pampar/inference.py b/pampar/inference.py
new file mode 100644
index 0000000000000000000000000000000000000000..788a4803316a00f0c4841afd5c169ea7a08fe50a
--- /dev/null
+++ b/pampar/inference.py
@@ -0,0 +1,263 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+pampar.inference — Servidor de inferencia para la extensión VS Code.
+
+Protocolo JSON-lines (stdin/stdout):
+ Entrada:
+ { "type": "infer", "prompt": "...", "max_tokens": 256, "temperature": 0.4 }
+ { "type": "boot", "workspace": "/ruta/al/workspace" }
+ Salida:
+ { "type": "infer_ok", "text": "..." }
+ { "type": "boot_ok", "agents_md": "..." }
+ { "type": "ready" }
+ { "type": "error", "message": "..." }
+
+Señal de listo:
+ Escribe "READY" a stderr una vez que el modelo está cargado en memoria.
+
+Uso:
+ python -m pampar.inference --checkpoint checkpoints/v3_sft_v8.pt --device auto
+"""
+
+from __future__ import annotations
+
+import argparse
+import io
+import json
+import sys
+import traceback
+from pathlib import Path
+
+import torch
+
+from pampar.constants import TOKENIZER_PATH
+
+
+def _resolve_device(device_arg: str) -> torch.device:
+ if device_arg == "cuda":
+ if not torch.cuda.is_available():
+ _stderr("ADVERTENCIA: CUDA solicitado pero no disponible. Usando CPU.")
+ return torch.device("cpu")
+ return torch.device("cuda")
+ if device_arg == "cpu":
+ return torch.device("cpu")
+ # auto
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
+
+
+def _stderr(msg: str) -> None:
+ print(msg, file=sys.stderr, flush=True)
+
+
+def _respond(obj: dict) -> None:
+ print(json.dumps(obj, ensure_ascii=False), flush=True)
+
+
+def _resolve_tokenizer_path(checkpoint_path: Path, tokenizer_arg: str | None) -> Path:
+ """Intenta encontrar el tokenizer en ubicaciones conocidas."""
+ candidates: list[Path] = []
+ if tokenizer_arg:
+ candidates.append(Path(tokenizer_arg))
+
+ project_root = checkpoint_path.parent.parent
+ candidates += [
+ project_root / "data" / "tokenizer" / "pampar_48k.model",
+ Path(TOKENIZER_PATH),
+ Path("pampar_48k.model"),
+ ]
+ for c in candidates:
+ if c.exists():
+ return c
+ raise FileNotFoundError(
+ f"Tokenizer no encontrado. Candidatos: {[str(c) for c in candidates]}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Carga del modelo
+# ---------------------------------------------------------------------------
+
+
+def load_model(
+ checkpoint_path: Path,
+ device: torch.device,
+ *,
+ with_tokenizer: bool = True,
+ verbose: bool = True,
+):
+ """Carga PamparV3 desde un checkpoint .pt.
+
+ Returns:
+ (model, config) si with_tokenizer=False
+ (model, tokenizer) si with_tokenizer=True (default, retrocompatible)
+ """
+ import dataclasses
+
+ import sentencepiece as spm
+
+ from pampar.coder.v3.config import PRESET_V3, ConfigV3
+ from pampar.coder.v3.modelo import PamparV3
+
+ log = _stderr if verbose else (lambda _: None)
+
+ log(f"Cargando modelo: {checkpoint_path}")
+ ckpt = torch.load(str(checkpoint_path), map_location="cpu", weights_only=False)
+ state_dict = ckpt.get("modelo", ckpt.get("model", ckpt))
+
+ # Reconstruir config desde el checkpoint si está disponible
+ raw_cfg = ckpt.get("config", {})
+ if isinstance(raw_cfg, ConfigV3):
+ config = raw_cfg
+ elif isinstance(raw_cfg, dict) and "dim" in raw_cfg:
+ campos = {f.name for f in dataclasses.fields(ConfigV3)}
+ kwargs = {k: v for k, v in raw_cfg.items() if k in campos}
+ try:
+ config = ConfigV3(**kwargs)
+ except TypeError:
+ config = PRESET_V3
+ else:
+ config = PRESET_V3
+
+ model = PamparV3(config).to(device)
+ model.load_state_dict(state_dict, strict=False)
+ model.eval()
+
+ params = sum(p.numel() for p in model.parameters()) / 1e6
+ log(f"Modelo listo: {params:.1f}M params en {device}")
+
+ if not with_tokenizer:
+ return model, config
+
+ tokenizer_path = _resolve_tokenizer_path(checkpoint_path, None)
+ log(f"Cargando tokenizer: {tokenizer_path}")
+ tokenizer = spm.SentencePieceProcessor()
+ tokenizer.Load(str(tokenizer_path))
+ model.registrar_tokenizer(tokenizer)
+
+ return model, tokenizer
+
+
+# ---------------------------------------------------------------------------
+# Handlers
+# ---------------------------------------------------------------------------
+
+
+def handle_infer(model, tokenizer, device: torch.device, msg: dict) -> None:
+ prompt: str = msg.get("prompt", "")
+ max_tokens: int = int(msg.get("max_tokens", 256))
+ temperature: float = float(msg.get("temperature", 0.4))
+
+ if not prompt:
+ _respond({"type": "error", "message": "prompt vacío"})
+ return
+
+ ids = tokenizer.Encode(prompt, out_type=int)
+ input_tensor = torch.tensor([ids], dtype=torch.long, device=device)
+
+ with torch.no_grad():
+ output = model.generate(
+ input_tensor,
+ max_tokens=max_tokens,
+ temperature=temperature,
+ )
+
+ # Decodificar solo los tokens nuevos
+ new_ids = output[0, len(ids) :].tolist()
+ text = tokenizer.Decode(new_ids).replace("\u2047", "\n")
+ _respond({"type": "infer_ok", "text": text})
+
+
+def handle_boot(msg: dict) -> None:
+ workspace: str = msg.get("workspace", ".")
+
+ from pampar.runtime.generar_agents import generar_agents_md
+ from pampar.runtime.scanner import Scanner
+
+ try:
+ scanner = Scanner(workspace_root=workspace)
+ scan = scanner.scan()
+ agents_md = generar_agents_md(scan, proyecto=Path(workspace).name)
+ _respond({"type": "boot_ok", "agents_md": agents_md})
+ except Exception as exc:
+ _respond({"type": "error", "message": f"boot falló: {exc}"})
+
+
+# ---------------------------------------------------------------------------
+# Main loop
+# ---------------------------------------------------------------------------
+
+
+def main() -> None:
+ # Forzar UTF-8 en stdin/stdout — necesario en Windows (charmap por defecto)
+ if hasattr(sys.stdout, "buffer"):
+ sys.stdout = io.TextIOWrapper(
+ sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True
+ )
+ if hasattr(sys.stdin, "buffer"):
+ sys.stdin = io.TextIOWrapper(
+ sys.stdin.buffer, encoding="utf-8", errors="replace"
+ )
+
+ parser = argparse.ArgumentParser(description="PAMPAr inference server (JSON-lines)")
+ parser.add_argument(
+ "--checkpoint", required=True, help="Ruta al .pt del checkpoint"
+ )
+ parser.add_argument(
+ "--device",
+ default="auto",
+ choices=["auto", "cpu", "cuda"],
+ help="Dispositivo de inferencia",
+ )
+ parser.add_argument("--tokenizer", default=None, help="Ruta al tokenizer .model")
+ args = parser.parse_args()
+
+ device = _resolve_device(args.device)
+ checkpoint_path = Path(args.checkpoint)
+
+ if not checkpoint_path.exists():
+ _stderr(f"ERROR: checkpoint no encontrado: {checkpoint_path}")
+ sys.exit(1)
+
+ try:
+ model, tokenizer = load_model(checkpoint_path, device)
+ except Exception as exc:
+ _stderr(f"ERROR al cargar modelo: {exc}")
+ traceback.print_exc(file=sys.stderr)
+ sys.exit(1)
+
+ # Señalar que el servidor está listo
+ _stderr("READY")
+ _respond({"type": "ready"})
+
+ # Loop interactivo — readline() en vez de `for line in stdin`
+ # para evitar buffering ahead del iterador en Windows.
+ while True:
+ raw_line = sys.stdin.readline()
+ if not raw_line:
+ break
+ raw_line = raw_line.strip()
+ if not raw_line:
+ continue
+
+ try:
+ msg = json.loads(raw_line)
+ except json.JSONDecodeError as exc:
+ _respond({"type": "error", "message": f"JSON inválido: {exc}"})
+ continue
+
+ msg_type = msg.get("type")
+ try:
+ if msg_type == "infer":
+ handle_infer(model, tokenizer, device, msg)
+ elif msg_type == "boot":
+ handle_boot(msg)
+ else:
+ _respond({"type": "error", "message": f"Tipo desconocido: {msg_type}"})
+ except Exception as exc:
+ traceback.print_exc(file=sys.stderr)
+ _respond({"type": "error", "message": str(exc)})
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pampar/memoria/__init__.py b/pampar/memoria/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..581ee1393abfb7538f5fbb937042c17b3113e8b4
--- /dev/null
+++ b/pampar/memoria/__init__.py
@@ -0,0 +1,27 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Sistema de Memoria Residual — RAG Pareto + Cola de Fine-tune.
+
+Pipeline:
+ Usuario envía código / texto
+ → Clasificador Pareto: ¿qué es importante?
+ → RAGResidual: guarda con score en vector DB local (FAISS)
+ → Modelo usa RAGResidual como contexto externo (retrieval)
+
+ Cuando L3 tiene suficiente data de alta calidad:
+ → ColaFinetune propone un fine-tune
+ → Usuario acepta → se lanza training → L3 se vacía
+ → El conocimiento queda en los pesos (como aprender a caminar)
+"""
+
+from .clasificador import ClasificadorPareto, EntradaMemoria
+from .rag import RAGResidual
+from .cola_finetune import ColaFinetune
+
+__all__ = [
+ "ClasificadorPareto",
+ "EntradaMemoria",
+ "RAGResidual",
+ "ColaFinetune",
+]
diff --git a/pampar/memoria/clasificador.py b/pampar/memoria/clasificador.py
new file mode 100644
index 0000000000000000000000000000000000000000..01c3b791f1ee2569061031b5fd6e712c97d716f8
--- /dev/null
+++ b/pampar/memoria/clasificador.py
@@ -0,0 +1,259 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Clasificador de importancia con Ley de Pareto.
+
+Principio:
+ Del total de interacciones del usuario, solo el 20% contiene
+ información suficientemente valuable para retener.
+ De ese 20%, solo el 20% (4% total) merece ir a fine-tune.
+
+El clasificador NO usa el modelo PAMPAr para hacer el análisis —
+es un pipeline de heurísticas rápidas + embeddings ligeros.
+
+Scoring de importancia:
+ - Errores del modelo en ese fragmento (loss alta → más importante)
+ - Novedad semántica vs lo que ya está en el RAG
+ - Densidad de patrones (cuántos conceptos nuevos por línea)
+ - Frecuencia de acceso (si el usuario pregunta lo mismo → importante)
+ - Territorio dominante (código con múltiples territorios = más rico)
+
+Umbrales Pareto:
+ L1: importancia > 0.3 → guardar en RAG (20% de entradas)
+ L2: importancia > 0.6 → marcar como "alta prioridad" (4% de entradas)
+ L3: importancia > 0.85 + frecuencia >= 3 → cola de fine-tune (0.8%)
+"""
+
+import hashlib
+import math
+import re
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import List, Optional
+
+
+# =============================================================================
+# ESTRUCTURA DE ENTRADA DE MEMORIA
+# =============================================================================
+
+@dataclass
+class EntradaMemoria:
+ """Una unidad de información clasificada para el RAG."""
+
+ # Identificación
+ id: str = "" # Hash del contenido
+ timestamp: float = field(default_factory=time.time)
+
+ # Contenido
+ texto: str = "" # Fragmento de código/texto
+ tipo: str = "codigo" # "codigo", "error", "concepto", "dialogo"
+
+ # Scoring Pareto
+ importancia: float = 0.0 # [0, 1] — umbral L1: 0.3
+ novedad: float = 0.0 # Qué tan diferente de lo existente
+ densidad: float = 0.0 # Patrones/conceptos por token
+ frecuencia: int = 1 # Cuántas veces visto/pedido
+ loss_modelo: float = 0.0 # Loss del modelo en este fragmento
+
+ # Nivel Pareto
+ nivel: int = 0 # 0=ignorar, 1=RAG, 2=alta prioridad, 3=finetune
+
+ # Territorio dominante (del Tálamo)
+ territorio_dominante: str = "" # "SINTAXIS", "SEMANTICA", "LOGICO", "ESTRUCTURAL"
+
+ def __post_init__(self):
+ if not self.id:
+ self.id = hashlib.sha256(self.texto.encode()).hexdigest()[:16]
+
+
+# =============================================================================
+# CLASIFICADOR PARETO
+# =============================================================================
+
+class ClasificadorPareto:
+ """
+ Clasifica fragmentos de código/texto por importancia.
+
+ No usa GPU ni el modelo PAMPAr — es intencional.
+ El clasificador debe ser rápido y operable incluso si el modelo
+ está ocupado generando tokens.
+
+ Args:
+ umbral_l1: Importancia mínima para guardar en RAG (default 0.3)
+ umbral_l2: Importancia mínima para alta prioridad (default 0.6)
+ umbral_l3: Importancia mínima para cola de fine-tune (default 0.85)
+ freq_l3: Frecuencia mínima para ir a L3 (default 3 veces visto)
+ """
+
+ def __init__(
+ self,
+ umbral_l1: float = 0.30,
+ umbral_l2: float = 0.60,
+ umbral_l3: float = 0.85,
+ freq_l3: int = 3,
+ ):
+ self.umbral_l1 = umbral_l1
+ self.umbral_l2 = umbral_l2
+ self.umbral_l3 = umbral_l3
+ self.freq_l3 = freq_l3
+
+ # Heurísticas para detectar patrones importantes en código
+ self._pat_class = re.compile(r"\bclass\s+\w+")
+ self._pat_def = re.compile(r"\bdef\s+\w+\s*\(")
+ self._pat_decorator = re.compile(r"@\w+")
+ self._pat_import = re.compile(r"\bimport\b|\bfrom\b.*\bimport\b")
+ self._pat_exception = re.compile(r"\btry\b|\bexcept\b|\braise\b")
+ self._pat_type_hint = re.compile(r":\s*\w+\s*[,\)=]|\s*->\s*\w+")
+ self._pat_comprehension = re.compile(r"\[.*\bfor\b.*\bin\b")
+ self._pat_lambda = re.compile(r"\blambda\b")
+ self._pat_async = re.compile(r"\basync\b|\bawait\b")
+
+ def clasificar(
+ self,
+ texto: str,
+ tipo: str = "codigo",
+ loss_modelo: float = 0.0,
+ fragmentos_existentes: Optional[List[str]] = None,
+ ) -> EntradaMemoria:
+ """
+ Analiza un fragmento y retorna una EntradaMemoria con scores.
+
+ Args:
+ texto: El fragmento a clasificar
+ tipo: Tipo de contenido
+ loss_modelo: Si disponible, la loss del modelo en este fragmento
+ fragmentos_existentes: Lista de textos ya en el RAG (para calcular novedad)
+
+ Returns:
+ EntradaMemoria clasificada con nivel Pareto asignado
+ """
+ if not texto.strip():
+ return EntradaMemoria(texto=texto, tipo=tipo, nivel=0, importancia=0.0)
+
+ densidad = self._calcular_densidad(texto)
+ novedad = self._calcular_novedad(texto, fragmentos_existentes or [])
+ riqueza_loss = min(1.0, loss_modelo / 5.0) # Normalizar loss (5.0 = alta)
+
+ # Score ponderado
+ importancia = (
+ 0.35 * densidad # ¿Qué tan rico es el código?
+ + 0.30 * novedad # ¿Es algo nuevo para el modelo?
+ + 0.25 * riqueza_loss # ¿El modelo tuvo dificultad aquí?
+ + 0.10 * min(1.0, len(texto) / 500) # Fragmentos más largos = más info
+ )
+ importancia = round(min(1.0, importancia), 4)
+
+ territorio = self._detectar_territorio(texto)
+
+ entrada = EntradaMemoria(
+ texto=texto,
+ tipo=tipo,
+ importancia=importancia,
+ novedad=novedad,
+ densidad=densidad,
+ loss_modelo=loss_modelo,
+ territorio_dominante=territorio,
+ )
+ entrada.nivel = self._asignar_nivel(entrada)
+ return entrada
+
+ def _calcular_densidad(self, texto: str) -> float:
+ """
+ Densidad de patrones avanzados por línea de código.
+
+ Más patrones avanzados = más conceptos = más valioso para aprender.
+ """
+ lineas = [l for l in texto.splitlines() if l.strip()]
+ if not lineas:
+ return 0.0
+
+ patrones = [
+ self._pat_class, self._pat_def, self._pat_decorator,
+ self._pat_exception, self._pat_type_hint,
+ self._pat_comprehension, self._pat_lambda, self._pat_async,
+ ]
+
+ hits = sum(
+ 1 for p in patrones for _ in p.finditer(texto)
+ )
+
+ # Normalizar: 8+ hits en 10 líneas = densidad máxima
+ densidad = min(1.0, hits / max(1, len(lineas)) / 0.8)
+ return round(densidad, 4)
+
+ def _calcular_novedad(self, texto: str, existentes: List[str]) -> float:
+ """
+ Qué tan diferente es este fragmento de lo que ya está en el RAG.
+
+ Heurística simple: overlap de n-gramas de palabras.
+ No usa embeddings para mantenerse rápido y offline.
+ """
+ if not existentes:
+ return 1.0
+
+ def ngrams(t: str, n: int = 3) -> set:
+ words = re.findall(r"\w+", t.lower())
+ return {tuple(words[i:i+n]) for i in range(len(words) - n + 1)}
+
+ ng_nuevo = ngrams(texto)
+ if not ng_nuevo:
+ return 0.5
+
+ # Overlap promedio con los últimos 20 fragmentos (ventana deslizante)
+ muestra = existentes[-20:]
+ overlaps = []
+ for ex in muestra:
+ ng_ex = ngrams(ex)
+ if not ng_ex:
+ continue
+ inter = len(ng_nuevo & ng_ex)
+ union = len(ng_nuevo | ng_ex)
+ overlaps.append(inter / union if union else 0)
+
+ jaccard_promedio = sum(overlaps) / len(overlaps) if overlaps else 0
+ novedad = 1.0 - jaccard_promedio
+ return round(novedad, 4)
+
+ def _detectar_territorio(self, texto: str) -> str:
+ """Detecta el territorio dominante del fragmento."""
+ scores = {
+ "SINTAXIS": 0,
+ "SEMANTICA": 0,
+ "LOGICO": 0,
+ "ESTRUCTURAL": 0,
+ }
+ scores["SINTAXIS"] += len(self._pat_def.findall(texto))
+ scores["SINTAXIS"] += len(self._pat_class.findall(texto))
+ scores["SINTAXIS"] += len(self._pat_import.findall(texto))
+ scores["LOGICO"] += len(self._pat_exception.findall(texto))
+ scores["LOGICO"] += len(self._pat_comprehension.findall(texto))
+ scores["SEMANTICA"] += len(self._pat_type_hint.findall(texto))
+ scores["SEMANTICA"] += len(self._pat_lambda.findall(texto))
+ scores["ESTRUCTURAL"] += len(self._pat_decorator.findall(texto))
+ scores["ESTRUCTURAL"] += len(self._pat_async.findall(texto))
+
+ return max(scores, key=lambda k: scores[k])
+
+ def _asignar_nivel(self, entrada: EntradaMemoria) -> int:
+ """Asigna nivel Pareto: 0=ignorar, 1=RAG, 2=alta prio, 3=finetune."""
+ if entrada.importancia < self.umbral_l1:
+ return 0
+ if entrada.importancia >= self.umbral_l3 and entrada.frecuencia >= self.freq_l3:
+ return 3
+ if entrada.importancia >= self.umbral_l2:
+ return 2
+ return 1
+
+ def actualizar_frecuencia(self, entrada: EntradaMemoria) -> EntradaMemoria:
+ """
+ Incrementa la frecuencia cuando el usuario vuelve a pedir algo similar.
+
+ Puede elevar el nivel Pareto de L1 a L3 si se accede suficientemente.
+ """
+ entrada.frecuencia += 1
+ # Re-score con boost por frecuencia
+ freq_boost = min(0.2, math.log(entrada.frecuencia + 1) * 0.05)
+ entrada.importancia = min(1.0, entrada.importancia + freq_boost)
+ entrada.nivel = self._asignar_nivel(entrada)
+ return entrada
diff --git a/pampar/memoria/cola_finetune.py b/pampar/memoria/cola_finetune.py
new file mode 100644
index 0000000000000000000000000000000000000000..b6edc02676ae68c9b68c37bbbac77fbf8c2859eb
--- /dev/null
+++ b/pampar/memoria/cola_finetune.py
@@ -0,0 +1,295 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Cola de Fine-tune — L3 del sistema de memoria.
+
+Cuando el ClasificadorPareto determina que un fragmento tiene:
+ - importancia >= 0.85
+ - frecuencia >= 3 veces visto/pedido
+
+...lo promueve a nivel 3 (L3) y se agrega a esta cola.
+La cola acumula silenciosamente en disco hasta que:
+ A) Tiene suficiente data (umbral configurable, default 500 ejemplos)
+ B) El usuario lo pide explícitamente
+
+Cuando se activa el fine-tune:
+ 1. Se genera un dataset JSONL desde la cola
+ 2. Se propone al usuario con estadísticas
+ 3. Si acepta → se lanza el script de training
+ 4. Si no → la cola se conserva y sigue acumulando
+ 5. Post-training exitoso → se vacía la cola L3 del RAG
+ (el conocimiento ya vive en los pesos)
+
+Analogía: "ya sé caminar, no necesito recordar cómo hacerlo."
+"""
+
+import json
+import subprocess
+import time
+from pathlib import Path
+from typing import Callable, List, Optional
+
+from .clasificador import EntradaMemoria
+
+
+class ColaFinetune:
+ """
+ Gestiona la cola de ejemplos de nivel L3 para fine-tune futuro.
+
+ Args:
+ directorio: Ruta del directorio de datos de memoria
+ min_ejemplos: Mínimo de ejemplos para proponer fine-tune (default 200)
+ callback_proponer: Función que se llama cuando la cola está lista
+ Signature: (n_ejemplos: int, stats: dict) -> bool
+ Si retorna True → se lanza el fine-tune
+ """
+
+ def __init__(
+ self,
+ directorio: str = "memoria/data",
+ min_ejemplos: int = 200,
+ callback_proponer: Optional[Callable] = None,
+ ):
+ self.dir = Path(directorio)
+ self.dir.mkdir(parents=True, exist_ok=True)
+ self.ruta_cola = self.dir / "cola_finetune.jsonl"
+ self.ruta_log = self.dir / "finetune_log.json"
+ self.min_ejemplos = min_ejemplos
+ self.callback_proponer = callback_proponer
+
+ self._cola: List[EntradaMemoria] = []
+ self._cargar()
+
+ # ── Persistencia ──────────────────────────────────────────────────────────
+
+ def _cargar(self) -> None:
+ """Carga cola desde disco."""
+ if not self.ruta_cola.exists():
+ return
+ try:
+ for linea in self.ruta_cola.read_text(encoding="utf-8").splitlines():
+ if linea.strip():
+ data = json.loads(linea)
+ e = EntradaMemoria(**{
+ k: v for k, v in data.items()
+ if k in EntradaMemoria.__dataclass_fields__
+ })
+ self._cola.append(e)
+ except Exception:
+ self._cola = []
+
+ def _guardar_cola(self) -> None:
+ """Persiste cola en formato JSONL."""
+ lineas = [
+ json.dumps(
+ {k: getattr(e, k) for k in e.__dataclass_fields__},
+ ensure_ascii=False,
+ )
+ for e in self._cola
+ ]
+ self.ruta_cola.write_text("\n".join(lineas), encoding="utf-8")
+
+ # ── Operaciones ───────────────────────────────────────────────────────────
+
+ def agregar(self, entrada: EntradaMemoria) -> None:
+ """
+ Agrega una entrada L3 a la cola.
+
+ Si después de agregar la cola supera min_ejemplos,
+ llama al callback_proponer si está definido.
+ """
+ if entrada.nivel < 3:
+ return
+
+ # Evitar duplicados
+ ids = {e.id for e in self._cola}
+ if entrada.id in ids:
+ return
+
+ self._cola.append(entrada)
+ self._guardar_cola()
+
+ # Proponer fine-tune si tenemos suficientes ejemplos
+ if len(self._cola) >= self.min_ejemplos and self.callback_proponer:
+ lanzar = self.callback_proponer(len(self._cola), self.stats())
+ if lanzar:
+ self.lanzar_finetune()
+
+ def __len__(self) -> int:
+ return len(self._cola)
+
+ def stats(self) -> dict:
+ """Estadísticas de la cola actual."""
+ if not self._cola:
+ return {
+ "total": 0,
+ "listos": False,
+ "faltan_para_finetune": self.min_ejemplos,
+ }
+
+ importancias = [e.importancia for e in self._cola]
+ return {
+ "total": len(self._cola),
+ "listos": len(self._cola) >= self.min_ejemplos,
+ "importancia_promedio": round(sum(importancias) / len(importancias), 3),
+ "importancia_max": round(max(importancias), 3),
+ "territorios": self._contar_territorios(),
+ "faltan_para_finetune": max(0, self.min_ejemplos - len(self._cola)),
+ }
+
+ def _contar_territorios(self) -> dict:
+ cont: dict[str, int] = {}
+ for e in self._cola:
+ cont[e.territorio_dominante] = cont.get(e.territorio_dominante, 0) + 1
+ return cont
+
+ def exportar_dataset(self, ruta_salida: Optional[str] = None) -> Path:
+ """
+ Exporta la cola como dataset JSONL en formato de instrucción.
+
+ Formato:
+ {"instruction": "...", "input": "", "output": "..."}
+
+ Compatible con la mayoría de scripts de fine-tune (Alpaca format).
+
+ Args:
+ ruta_salida: Ruta del archivo JSONL. Default: memoria/data/ft_dataset.jsonl
+ Returns:
+ Path del archivo generado
+ """
+ ruta = Path(ruta_salida) if ruta_salida else self.dir / "ft_dataset.jsonl"
+
+ lineas = []
+ for e in self._cola:
+ if e.tipo == "codigo":
+ ejemplo = {
+ "instruction": "Completa, refactoriza o explica el siguiente código Python:",
+ "input": e.texto,
+ "output": "", # Se completará con distilación si está disponible
+ }
+ elif e.tipo == "error":
+ ejemplo = {
+ "instruction": "El siguiente código tiene un error. Identifícalo y corrígelo:",
+ "input": e.texto,
+ "output": "",
+ }
+ else:
+ ejemplo = {
+ "instruction": e.texto,
+ "input": "",
+ "output": "",
+ }
+
+ lineas.append(json.dumps(ejemplo, ensure_ascii=False))
+
+ ruta.write_text("\n".join(lineas), encoding="utf-8")
+ return ruta
+
+ def lanzar_finetune(
+ self,
+ script: str = "scripts/train.py",
+ checkpoint: str = "checkpoints/pampar_v3_best.pt",
+ ) -> bool:
+ """
+ Exporta el dataset y lanza el script de fine-tune.
+
+ El script recibe el dataset via argumento --data.
+ El proceso corre en background — el modelo sigue disponible.
+
+ Args:
+ script: Path al script de training
+ checkpoint: Checkpoint base a partir del cual hacer fine-tune
+ Returns:
+ True si se inició correctamente
+ """
+ dataset_path = self.exportar_dataset()
+ stats = self.stats()
+
+ log_entry = {
+ "timestamp": time.time(),
+ "n_ejemplos": len(self._cola),
+ "stats": stats,
+ "dataset": str(dataset_path),
+ "status": "iniciado",
+ }
+
+ try:
+ proc = subprocess.Popen(
+ [
+ "python", script,
+ "--data", str(dataset_path),
+ "--checkpoint", checkpoint,
+ "--finetune",
+ "--epochs", "3",
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ log_entry["pid"] = proc.pid
+ log_entry["status"] = "corriendo"
+ except FileNotFoundError:
+ log_entry["status"] = "error_script_no_encontrado"
+ self._registrar_log(log_entry)
+ return False
+ except Exception as exc:
+ log_entry["status"] = f"error: {exc}"
+ self._registrar_log(log_entry)
+ return False
+
+ self._registrar_log(log_entry)
+ return True
+
+ def vaciar_post_finetune(self) -> int:
+ """
+ Vacía la cola después de un fine-tune exitoso.
+
+ Llama a esto cuando el training terminó sin errores.
+ Returns: número de ejemplos eliminados
+ """
+ n = len(self._cola)
+ self._cola = []
+ if self.ruta_cola.exists():
+ self.ruta_cola.unlink()
+ self._registrar_log({
+ "timestamp": time.time(),
+ "evento": "cola_vaciada_post_finetune",
+ "ejemplos_eliminados": n,
+ })
+ return n
+
+ def _registrar_log(self, entry: dict) -> None:
+ """Agrega una entrada al log de operaciones de fine-tune."""
+ log = []
+ if self.ruta_log.exists():
+ try:
+ log = json.loads(self.ruta_log.read_text(encoding="utf-8"))
+ except Exception:
+ pass
+ log.append(entry)
+ self.ruta_log.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")
+
+ def proponer_usuario(self) -> str:
+ """
+ Genera mensaje legible para mostrar al usuario.
+
+ El runtime/agente.py llama esto cuando la cola está lista.
+ """
+ s = self.stats()
+ if not s["listos"]:
+ return (
+ f"[Memoria L3] {s['total']}/{self.min_ejemplos} ejemplos acumulados. "
+ f"Faltan {s['faltan_para_finetune']} para proponer entrenamiento."
+ )
+
+ territorios_str = ", ".join(
+ f"{k}: {v}" for k, v in s.get("territorios", {}).items()
+ )
+ return (
+ f"\n[Propuesta de aprendizaje]\n"
+ f"Tengo {s['total']} patrones importantes que aprendí de tus interacciones.\n"
+ f"Territorios: {territorios_str}\n"
+ f"Importancia promedio: {s['importancia_promedio']}\n\n"
+ f"¿Querés que me entrene con esto para interiorizar ese conocimiento?\n"
+ f"(Si aceptás, el fine-tune corre en background y la memoria residual se libera.)\n"
+ f"Respondé: sí / no"
+ )
diff --git a/pampar/memoria/rag.py b/pampar/memoria/rag.py
new file mode 100644
index 0000000000000000000000000000000000000000..64490d774be9a0a51fa9976028bfdd4f98f24fd2
--- /dev/null
+++ b/pampar/memoria/rag.py
@@ -0,0 +1,383 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+RAG Residual — Almacenamiento y Recuperación de Memoria Local.
+
+Vector store local con FAISS (sin necesidad de servidor externo).
+Almacena fragmentos clasificados por el ClasificadorPareto con nivel >= 1.
+
+El RAG Residual actúa como el hipocampo:
+ - Guarda todo lo que clasificó como importante (L1+)
+ - El modelo puede recuperar los N más similares para usarlos como contexto
+ - Los fragmentos L3 se promueven a la ColaFinetune y se pueden eliminar del RAG
+
+Dependencias:
+ pip install faiss-cpu sentence-transformers
+
+Si no están instaladas, el RAG funciona en modo degradado (BM25 sobre texto plano).
+"""
+
+import json
+import math
+import re
+import time
+from pathlib import Path
+from typing import List, Optional, Tuple
+
+from .clasificador import EntradaMemoria
+
+
+# =============================================================================
+# ENCODER DE TEXTO (MODO LIGERO)
+# =============================================================================
+
+class EncoderTexto:
+ """
+ Encoder minimalista para vectorizar fragmentos de código.
+
+ Modo 1 (completo): usa sentence-transformers/all-MiniLM-L6-v2
+ → 384D embeddings, ~22MB modelo, offline total
+ Modo 2 (degradado): BM25-like TF-IDF sobre vocabulario de código
+ → Sin GPU, sin dependencias externas, funciona siempre
+
+ Intenta cargar modo 1. Si falla, cae a modo 2 silenciosamente.
+ """
+
+ def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
+ self._encoder = None
+ self._dim = 384
+ self._vocab: dict[str, int] = {}
+
+ try:
+ from sentence_transformers import SentenceTransformer
+ self._encoder = SentenceTransformer(model_name)
+ self._dim = self._encoder.get_sentence_embedding_dimension()
+ except ImportError:
+ pass # Modo degradado
+
+ @property
+ def dim(self) -> int:
+ return self._dim
+
+ @property
+ def modo_completo(self) -> bool:
+ return self._encoder is not None
+
+ def encode(self, textos: List[str]) -> "list[list[float]]":
+ """
+ Codifica textos a vectores float.
+
+ Args:
+ textos: Lista de fragmentos a codificar
+ Returns:
+ Lista de vectores (listas de float)
+ """
+ if self._encoder is not None:
+ vecs = self._encoder.encode(textos, normalize_embeddings=True)
+ return vecs.tolist()
+ else:
+ return [self._tfidf_encode(t) for t in textos]
+
+ def _tfidf_encode(self, texto: str) -> list[float]:
+ """
+ TF-IDF sobre vocabulario de tokens Python.
+
+ Vector disperso normalizado — suficiente para recuperación BM25-like.
+ """
+ tokens = re.findall(r"\w+", texto.lower())
+ if not tokens:
+ return [0.0] * self._dim
+
+ # Actualizar vocabulario
+ for t in tokens:
+ if t not in self._vocab:
+ if len(self._vocab) < self._dim:
+ self._vocab[t] = len(self._vocab)
+
+ # Vector TF
+ vec = [0.0] * self._dim
+ for t in tokens:
+ if t in self._vocab:
+ vec[self._vocab[t]] += 1 / len(tokens)
+
+ # Normalizar
+ norm = math.sqrt(sum(v * v for v in vec)) or 1.0
+ return [v / norm for v in vec]
+
+
+# =============================================================================
+# RAG RESIDUAL
+# =============================================================================
+
+class RAGResidual:
+ """
+ Almacén de memoria local con recuperación semántica.
+
+ Persistido en disco como JSON (entradas) + npy (vectores).
+ No requiere servidor — funciona completamente offline.
+
+ Args:
+ directorio: Ruta donde guardar el índice y las entradas
+ max_entradas: Máximo de entradas en el RAG (FIFO por importancia)
+ n_resultados: Cuántos fragmentos recuperar por consulta
+ """
+
+ def __init__(
+ self,
+ directorio: str = "memoria/data",
+ max_entradas: int = 5_000,
+ n_resultados: int = 5,
+ ):
+ self.dir = Path(directorio)
+ self.dir.mkdir(parents=True, exist_ok=True)
+ self.max_entradas = max_entradas
+ self.n_resultados = n_resultados
+
+ self.encoder = EncoderTexto()
+ self._entradas: list[EntradaMemoria] = []
+ self._vectores: list[list[float]] = []
+ self._indice = None # índice FAISS si disponible
+
+ self._cargar()
+ self._construir_indice()
+
+ # ── Persistencia ──────────────────────────────────────────────────────────
+
+ def _cargar(self) -> None:
+ """Carga entradas desde disco al iniciar."""
+ ruta_json = self.dir / "entradas.json"
+ if not ruta_json.exists():
+ return
+
+ try:
+ data = json.loads(ruta_json.read_text(encoding="utf-8"))
+ for item in data:
+ e = EntradaMemoria(**{
+ k: v for k, v in item.items()
+ if k in EntradaMemoria.__dataclass_fields__
+ })
+ self._entradas.append(e)
+ except Exception:
+ pass # Archivo corrupto → empezar de cero
+
+ ruta_vecs = self.dir / "vectores.json"
+ if ruta_vecs.exists():
+ try:
+ self._vectores = json.loads(ruta_vecs.read_text(encoding="utf-8"))
+ except Exception:
+ self._vectores = []
+
+ def _guardar(self) -> None:
+ """Persiste el estado actual en disco."""
+ try:
+ entradas_data = [
+ {k: getattr(e, k) for k in e.__dataclass_fields__}
+ for e in self._entradas
+ ]
+ (self.dir / "entradas.json").write_text(
+ json.dumps(entradas_data, ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+ (self.dir / "vectores.json").write_text(
+ json.dumps(self._vectores),
+ encoding="utf-8",
+ )
+ except Exception:
+ pass
+
+ def _construir_indice(self) -> None:
+ """Construye índice FAISS si está disponible."""
+ if not self._vectores:
+ return
+ try:
+ import faiss
+ import numpy as np
+ mat = np.array(self._vectores, dtype=np.float32)
+ self._indice = faiss.IndexFlatIP(self.encoder.dim) # Inner product = coseno si normalizado
+ self._indice.add(mat)
+ except ImportError:
+ pass # Sin FAISS → búsqueda lineal en _buscar_lineal
+
+ # ── Operaciones ───────────────────────────────────────────────────────────
+
+ def agregar(self, entrada: EntradaMemoria) -> bool:
+ """
+ Agrega una entrada al RAG si su nivel Pareto es >= 1.
+
+ Returns:
+ True si fue agregada, False si fue descartada (nivel 0)
+ """
+ if entrada.nivel < 1:
+ return False
+
+ # Verificar duplicados por ID
+ ids_existentes = {e.id for e in self._entradas}
+ if entrada.id in ids_existentes:
+ # Actualizar frecuencia de la existente
+ for e in self._entradas:
+ if e.id == entrada.id:
+ e.frecuencia += 1
+ e.importancia = min(1.0, e.importancia + 0.05)
+ self._guardar()
+ return False
+
+ # Vectorizar el texto
+ vec = self.encoder.encode([entrada.texto])[0]
+
+ # Agregar al almacén
+ self._entradas.append(entrada)
+ self._vectores.append(vec)
+
+ # Reconstruir índice FAISS con la nueva entrada
+ self._agregar_al_indice(vec)
+
+ # Cap: si excede max_entradas, eliminar las de menor importancia
+ if len(self._entradas) > self.max_entradas:
+ self._podar()
+
+ self._guardar()
+ return True
+
+ def _agregar_al_indice(self, vec: list[float]) -> None:
+ """Agrega un vector al índice FAISS o lo resetea."""
+ try:
+ import faiss
+ import numpy as np
+ if self._indice is None:
+ self._construir_indice()
+ return
+ self._indice.add(np.array([vec], dtype=np.float32))
+ except ImportError:
+ pass
+
+ def recuperar(
+ self,
+ query: str,
+ nivel_minimo: int = 1,
+ ) -> List[Tuple[EntradaMemoria, float]]:
+ """
+ Recupera los N fragmentos más similares a la consulta.
+
+ Args:
+ query: Texto de consulta (código o pregunta del usuario)
+ nivel_minimo: Solo recuperar entradas con nivel >= este valor
+ Returns:
+ Lista de (EntradaMemoria, score_similitud) ordenada por relevancia
+ """
+ if not self._entradas:
+ return []
+
+ vec_query = self.encoder.encode([query])[0]
+
+ # Filtrar por nivel mínimo
+ indices_validos = [
+ i for i, e in enumerate(self._entradas)
+ if e.nivel >= nivel_minimo
+ ]
+ if not indices_validos:
+ return []
+
+ try:
+ import faiss
+ import numpy as np
+ if self._indice is not None:
+ q = np.array([vec_query], dtype=np.float32)
+ k = min(self.n_resultados * 2, len(indices_validos))
+ scores, idxs = self._indice.search(q, k)
+
+ resultados = []
+ for idx, score in zip(idxs[0], scores[0]):
+ if idx in indices_validos:
+ resultados.append((self._entradas[idx], float(score)))
+ if len(resultados) >= self.n_resultados:
+ break
+ return resultados
+ except (ImportError, Exception):
+ pass
+
+ # Fallback: búsqueda lineal por coseno
+ return self._buscar_lineal(vec_query, indices_validos)
+
+ def _buscar_lineal(
+ self,
+ vec_query: list[float],
+ indices_validos: list[int],
+ ) -> List[Tuple[EntradaMemoria, float]]:
+ """Búsqueda por coseno lineal — O(n) pero siempre funciona."""
+ def coseno(a: list[float], b: list[float]) -> float:
+ dot = sum(x * y for x, y in zip(a, b))
+ norma = math.sqrt(sum(x*x for x in a)) * math.sqrt(sum(x*x for x in b))
+ return dot / norma if norma else 0.0
+
+ scored = [
+ (self._entradas[i], coseno(vec_query, self._vectores[i]))
+ for i in indices_validos
+ if i < len(self._vectores)
+ ]
+ scored.sort(key=lambda x: x[1], reverse=True)
+ return scored[:self.n_resultados]
+
+ def _podar(self) -> None:
+ """Elimina las entradas de menor importancia cuando el RAG está lleno."""
+ # Ordenar por importancia × log(frecuencia) desc
+ scored = sorted(
+ enumerate(self._entradas),
+ key=lambda x: x[1].importancia * math.log(x[1].frecuencia + 1),
+ reverse=True,
+ )
+ keep_indices = sorted([i for i, _ in scored[:self.max_entradas]])
+ self._entradas = [self._entradas[i] for i in keep_indices]
+ self._vectores = [self._vectores[i] for i in keep_indices if i < len(self._vectores)]
+ self._construir_indice()
+
+ def eliminar_por_nivel(self, nivel: int) -> int:
+ """
+ Elimina del RAG todas las entradas de un nivel dado.
+
+ Se usa cuando L3 entra en fine-tune y ya no necesita estar en el RAG.
+ Returns: cantidad de entradas eliminadas
+ """
+ antes = len(self._entradas)
+ indices_mantener = [i for i, e in enumerate(self._entradas) if e.nivel != nivel]
+ self._entradas = [self._entradas[i] for i in indices_mantener]
+ self._vectores = [self._vectores[i] for i in indices_mantener if i < len(self._vectores)]
+ self._construir_indice()
+ self._guardar()
+ return antes - len(self._entradas)
+
+ def stats(self) -> dict:
+ """Estadísticas del estado actual del RAG."""
+ niveles = {1: 0, 2: 0, 3: 0}
+ for e in self._entradas:
+ niveles[e.nivel] = niveles.get(e.nivel, 0) + 1
+ return {
+ "total_entradas": len(self._entradas),
+ "nivel_1_rag": niveles.get(1, 0),
+ "nivel_2_alta_prio": niveles.get(2, 0),
+ "nivel_3_finetune": niveles.get(3, 0),
+ "modo_encoder": "sentence-transformers" if self.encoder.modo_completo else "tfidf-fallback",
+ "indice_faiss": self._indice is not None,
+ }
+
+ def formatear_contexto(
+ self, resultados: List[Tuple[EntradaMemoria, float]]
+ ) -> str:
+ """
+ Convierte resultados del RAG en texto de contexto para el prompt.
+
+ Args:
+ resultados: Output de recuperar()
+ Returns:
+ String para insertar antes del prompt del usuario
+ """
+ if not resultados:
+ return ""
+
+ partes = ["[MEMORIA RELEVANTE]"]
+ for entrada, score in resultados:
+ partes.append(
+ f"--- (similitud: {score:.2f}, importancia: {entrada.importancia:.2f}) ---\n"
+ f"{entrada.texto.strip()}"
+ )
+ partes.append("[/MEMORIA RELEVANTE]")
+ return "\n".join(partes)
diff --git a/pampar/runtime/__init__.py b/pampar/runtime/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..d1ad6c5fc09cecbd7279ebefcb54baf22afe854c
--- /dev/null
+++ b/pampar/runtime/__init__.py
@@ -0,0 +1,9 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""Runtime — Orquestador del sistema PAMPAr."""
+
+from .agente import Agente
+from .scanner import Scanner
+from .boot import BootProtocol
+
+__all__ = ["Agente", "Scanner", "BootProtocol"]
diff --git a/pampar/runtime/agente.py b/pampar/runtime/agente.py
new file mode 100644
index 0000000000000000000000000000000000000000..04d3358a15260b3b59ab19cd34ffc2ba3d399524
--- /dev/null
+++ b/pampar/runtime/agente.py
@@ -0,0 +1,355 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Agente PAMPAr — Orquestador de modelo + memoria + skills.
+
+El Agente es el punto de entrada para interactuar con PAMPAr.
+Coordina:
+ - PamparV3: el modelo de lenguaje (el cerebro)
+ - RAGResidual: la memoria externa (el hipocampo)
+ - ClasificadorPareto: qué guardar en memoria
+ - ColaFinetune: cuándo proponer aprender de las interacciones
+ - Skills: lector de archivos, ejecutor de código, etc.
+
+Loop de razonamiento:
+ 1. Usuario envía mensaje/código
+ 2. Clasificador analiza el input → agrega a RAG si es L1+
+ 3. RAG recupera contexto relevante de interacciones previas
+ 4. Se construye el prompt con: [RAG ctx] + [historial] + [input]
+ 5. Modelo genera respuesta con Early Exit
+ 6. Si la respuesta contiene una acción ([LEER:...], [EJECUTAR:...])
+ → skill correspondiente se invoca
+ → resultado se vuelve a insertar como contexto
+ → modelo genera respuesta final
+ 7. Respuesta se agrega al historial
+ 8. Se verifica si la cola de fine-tune está lista → proponer al usuario
+"""
+
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+import sentencepiece as spm
+import torch
+
+from pampar.coder.v3 import PRESET_V3, PamparV3
+from pampar.coder.v3.config import ConfigV3
+from pampar.constants import TOKENIZER_PATH
+from pampar.memoria.clasificador import ClasificadorPareto
+from pampar.memoria.cola_finetune import ColaFinetune
+from pampar.memoria.rag import RAGResidual
+from pampar.runtime.boot import BootProtocol
+from pampar.skills.ejecutar_codigo import EjecutorCodigo
+from pampar.skills.lector_archivos import LectorArchivos
+
+# =============================================================================
+# PROMPT BUILDER
+# =============================================================================
+
+SYSTEM_PROMPT = """Sos PAMPAr, un asistente de programación local y offline especializado en Python.
+Tenés acceso a la memoria de interacciones previas y podés ejecutar código cuando sea necesario.
+
+Para leer un archivo: [LEER: ruta/al/archivo.py]
+Para ejecutar código: [EJECUTAR:
+codigo_python_aqui
+]
+Para ejecutar tests: [TESTS: ruta/tests/]
+
+Respondé siempre en español. El código va siempre en inglés."""
+
+
+# =============================================================================
+# AGENTE
+# =============================================================================
+
+
+class Agente:
+ """
+ Orquestador principal del sistema PAMPAr.
+
+ Args:
+ checkpoint: Path al checkpoint del modelo (.pt)
+ tokenizer_path: Path al tokenizer (.model de SentencePiece)
+ config: Configuración del modelo
+ workspace_root: Directorio raíz del proyecto del usuario
+ memoria_dir: Directorio para persistir la memoria
+ device: "cuda", "cpu" o "auto"
+ max_historial: Máximo de turnos de historial en el contexto
+ """
+
+ def __init__(
+ self,
+ checkpoint: str = "checkpoints/pampar_v3_best.pt",
+ tokenizer_path: str = TOKENIZER_PATH,
+ config: ConfigV3 = PRESET_V3,
+ workspace_root: str = ".",
+ memoria_dir: str = "memoria/data",
+ device: str = "auto",
+ max_historial: int = 10,
+ ):
+ self.config = config
+ self.max_historial = max_historial
+
+ # ── Dispositivo ──────────────────────────────────────────────────────
+ if device == "auto":
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ else:
+ self.device = torch.device(device)
+
+ # ── Tokenizer ────────────────────────────────────────────────────────
+ self.tok = spm.SentencePieceProcessor()
+ self.tok.Load(tokenizer_path)
+
+ # ── Modelo ───────────────────────────────────────────────────────────
+ self.modelo = PamparV3(config)
+ self.modelo.registrar_tokenizer(self.tok)
+
+ ckpt_path = Path(checkpoint)
+ if ckpt_path.exists():
+ state = torch.load(ckpt_path, map_location="cpu", weights_only=True)
+ # Compatibilidad: el checkpoint puede tener wrapper 'model'
+ state_dict = state.get("model", state)
+ self.modelo.load_state_dict(state_dict, strict=False)
+ print(f"[Agente] Modelo cargado desde {checkpoint}")
+ else:
+ print(
+ f"[Agente] Checkpoint no encontrado, usando pesos iniciales: {checkpoint}"
+ )
+
+ self.modelo = self.modelo.to(self.device)
+ self.modelo.eval()
+
+ # ── Memoria ──────────────────────────────────────────────────────────
+ self.clasificador = ClasificadorPareto()
+ self.rag = RAGResidual(directorio=memoria_dir)
+ self.cola_ft = ColaFinetune(
+ directorio=memoria_dir,
+ callback_proponer=self._on_cola_lista,
+ )
+
+ # ── Skills ───────────────────────────────────────────────────────────
+ self.lector = LectorArchivos(workspace_root=workspace_root)
+ self.ejecutor = EjecutorCodigo(cwd=workspace_root)
+ # ── Boot Protocol ────────────────────────────────────────────────────
+ self.boot = BootProtocol(workspace_root=workspace_root)
+ self._scan_resultado = self.boot.ejecutar(self.rag)
+ self._system_prompt = self.boot.generar_system_prompt()
+ # ── Estado ───────────────────────────────────────────────────────────
+ self._historial: List[
+ Dict[str, str]
+ ] = [] # [{"role": "user/assistant", "text": "..."}]
+
+ print(
+ f"[Agente] Listo en {self.device} | RAG: {self.rag.stats()['total_entradas']} entradas"
+ )
+
+ # ── Inferencia ────────────────────────────────────────────────────────────
+
+ def responder(
+ self,
+ mensaje: str,
+ max_tokens: int = 512,
+ temperatura: float = 0.8,
+ ) -> str:
+ """
+ Procesa un mensaje del usuario y genera una respuesta.
+
+ Args:
+ mensaje: Input del usuario (código, pregunta, etc.)
+ max_tokens: Máximo de tokens a generar
+ temperatura: Control de creatividad (0.1=determinista, 1.0=creativo)
+ Returns:
+ Respuesta del modelo como string
+ """
+ # 1. Clasificar el input y agregarlo al RAG si es importante
+ textos_existentes = [e.texto for e in self.rag._entradas]
+ entrada = self.clasificador.clasificar(
+ texto=mensaje,
+ tipo="codigo" if self._parece_codigo(mensaje) else "dialogo",
+ fragmentos_existentes=textos_existentes,
+ )
+ if entrada.nivel >= 1:
+ self.rag.agregar(entrada)
+ self.cola_ft.agregar(entrada)
+
+ # 2. Recuperar contexto del RAG
+ resultados_rag = self.rag.recuperar(mensaje, nivel_minimo=1)
+ ctx_rag = self.rag.formatear_contexto(resultados_rag)
+
+ # 3. Construir prompt completo
+ prompt = self._construir_prompt(mensaje, ctx_rag)
+
+ # 4. Tokenizar y generar
+ ids = self.tok.Encode(prompt)
+ if len(ids) > self.config.max_seq_len - max_tokens - 50:
+ # Truncar prompt para dejar espacio a la respuesta
+ ids = ids[-(self.config.max_seq_len - max_tokens - 50) :]
+
+ input_tensor = torch.tensor([ids], device=self.device)
+
+ with torch.no_grad():
+ output = self.modelo.generate(
+ input_tensor,
+ max_tokens=max_tokens,
+ temperature=temperatura,
+ top_k=50,
+ top_p=0.95,
+ )
+
+ # 5. Decodificar solo los tokens nuevos
+ nuevos_ids = output[0, len(ids) :].tolist()
+ respuesta = self.tok.Decode(nuevos_ids).strip()
+
+ # 6. Procesar acciones si las hay
+ respuesta = self._procesar_acciones(respuesta)
+
+ # 7. Actualizar historial
+ self._historial.append({"role": "user", "text": mensaje})
+ self._historial.append({"role": "assistant", "text": respuesta})
+ if len(self._historial) > self.max_historial * 2:
+ self._historial = self._historial[-self.max_historial * 2 :]
+
+ # 8. Verificar cola de fine-tune
+ propuesta = self._verificar_cola()
+ if propuesta:
+ respuesta += f"\n\n{propuesta}"
+
+ return respuesta
+
+ # ── Internos ──────────────────────────────────────────────────────────────
+
+ def _construir_prompt(self, mensaje: str, ctx_rag: str) -> str:
+ """Construye el prompt completo con system, RAG, historial y mensaje."""
+ partes = [self._system_prompt]
+
+ if ctx_rag:
+ partes.append(ctx_rag)
+
+ # Historial (últimos N turnos)
+ for turno in self._historial[-(self.max_historial * 2) :]:
+ prefijo = "Usuario" if turno["role"] == "user" else "PAMPAr"
+ partes.append(f"{prefijo}: {turno['text']}")
+
+ partes.append(f"Usuario: {mensaje}")
+ partes.append("PAMPAr:")
+
+ return "\n\n".join(partes)
+
+ def _procesar_acciones(self, respuesta: str) -> str:
+ """
+ Detecta y ejecuta acciones en la respuesta del modelo.
+
+ Formatos reconocidos:
+ [LEER: ruta]
+ [EJECUTAR: ... ]
+ [TESTS: ruta]
+ """
+ import re
+
+ # [LEER: ruta]
+ for match in re.finditer(r"\[LEER:\s*(.+?)\]", respuesta):
+ ruta = match.group(1).strip()
+ resultado = self.lector.execute(ruta=ruta)
+ reemplazo = (
+ resultado.contenido
+ if resultado.exito
+ else f"[ERROR al leer: {resultado.error}]"
+ )
+ respuesta = respuesta.replace(match.group(0), reemplazo)
+
+ # [EJECUTAR: codigo ]
+ for match in re.finditer(r"\[EJECUTAR:\s*\n?(.*?)\n?\]", respuesta, re.DOTALL):
+ codigo = match.group(1).strip()
+ resultado = self.ejecutor.execute(codigo=codigo)
+ reemplazo = (
+ resultado.contenido
+ if resultado.contenido
+ else f"[ERROR al ejecutar: {resultado.error}]"
+ )
+ respuesta = respuesta.replace(match.group(0), reemplazo)
+
+ # [TESTS: ruta]
+ for match in re.finditer(r"\[TESTS:\s*(.+?)\]", respuesta):
+ ruta = match.group(1).strip()
+ resultado = self.ejecutor.ejecutar_tests(ruta_test=ruta)
+ reemplazo = (
+ resultado.contenido
+ if resultado.contenido
+ else f"[ERROR al correr tests: {resultado.error}]"
+ )
+ respuesta = respuesta.replace(match.group(0), reemplazo)
+
+ return respuesta
+
+ def _parece_codigo(self, texto: str) -> bool:
+ """Heurística rápida para detectar si el input es código."""
+ import re
+
+ indicadores = [
+ r"\bdef\b",
+ r"\bclass\b",
+ r"\bimport\b",
+ r"\bfor\b.*\bin\b",
+ r":\s*$",
+ r"^\s{4}",
+ r"```",
+ r"\(\)",
+ r"=\s*\[",
+ ]
+ return any(re.search(p, texto, re.MULTILINE) for p in indicadores)
+
+ def _on_cola_lista(self, n_ejemplos: int, stats: dict) -> bool:
+ """
+ Callback cuando la cola de fine-tune tiene suficientes ejemplos.
+
+ Por ahora solo notifica — el usuario decide si aceptar.
+ En futuro: integrar con UI/API para diálogo.
+ """
+ print(f"\n[Cola Fine-tune] {n_ejemplos} ejemplos listos. Stats: {stats}")
+ return False # No lanzar automáticamente — requiere confirmación del usuario
+
+ def _verificar_cola(self) -> Optional[str]:
+ """Retorna mensaje de propuesta si la cola está lista."""
+ if len(self.cola_ft) >= self.cola_ft.min_ejemplos:
+ stats = self.cola_ft.stats()
+ if stats.get("listos") and stats["total"] % 50 == 0:
+ # Proponer cada 50 ejemplos nuevos
+ return self.cola_ft.proponer_usuario()
+ return None
+
+ # ── API de control ────────────────────────────────────────────────────────
+
+ def aceptar_finetune(self) -> str:
+ """El usuario acepta la propuesta de fine-tune."""
+ exito = self.cola_ft.lanzar_finetune()
+ if exito:
+ return (
+ "Fine-tune iniciado en background. "
+ "Seguís pudiendo usarme mientras se entrena. "
+ "Cuando termine, la memoria residual se liberará."
+ )
+ return "No pude iniciar el fine-tune. Verificá que el script de training esté disponible."
+
+ def rechazar_finetune(self) -> str:
+ """El usuario rechaza la propuesta — los datos se conservan en RAG."""
+ return (
+ "Entendido. Los patrones quedan en mi memoria como RAG. "
+ "Podés aceptar el entrenamiento más adelante cuando quieras."
+ )
+
+ def stats(self) -> dict:
+ """Estado actual del sistema."""
+ return {
+ "modelo": self.modelo.count_params(),
+ "rag": self.rag.stats(),
+ "cola_finetune": self.cola_ft.stats(),
+ "historial_turnos": len(self._historial) // 2,
+ "device": str(self.device),
+ }
+
+ def limpiar_historial(self) -> None:
+ """Limpia el historial de conversación actual."""
+ self._historial = []
+
+ def describe(self) -> str:
+ """Descripción completa del sistema."""
+ return self.modelo.describe()
diff --git a/pampar/runtime/boot.py b/pampar/runtime/boot.py
new file mode 100644
index 0000000000000000000000000000000000000000..158a5874e6f07d83f7e441c3fdf7ad7b20ff437d
--- /dev/null
+++ b/pampar/runtime/boot.py
@@ -0,0 +1,210 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Boot Protocol — Secuencia de arranque de PAMPAr.
+
+Implementa el protocolo de 3 archivos:
+ 1. CONCIENCIA.md → identidad invariante → RAG L3
+ 2. Scanner → inspección del entorno
+ 3. AGENTS.md contextual → resumen del entorno → RAG L2
+
+El boot se ejecuta una vez al iniciar el Agente.
+El resultado es un RAGResidual pre-cargado con identidad + contexto.
+"""
+
+from pathlib import Path
+from typing import Optional
+
+from pampar.memoria.clasificador import EntradaMemoria
+from pampar.memoria.rag import RAGResidual
+from .scanner import ResultadoScan, Scanner
+from .generar_agents import generar_agents_md
+
+
+def _fragmentar_markdown(texto: str) -> list[str]:
+ """
+ Divide un archivo Markdown en fragmentos semánticos por secciones.
+
+ Cada fragmento es una sección (## header + contenido).
+ Los fragmentos cortos (< 20 chars) se descartan.
+ """
+ fragmentos: list[str] = []
+ actual: list[str] = []
+
+ for linea in texto.splitlines():
+ if linea.startswith("## ") and actual:
+ bloque = "\n".join(actual).strip()
+ if len(bloque) >= 20:
+ fragmentos.append(bloque)
+ actual = [linea]
+ else:
+ actual.append(linea)
+
+ if actual:
+ bloque = "\n".join(actual).strip()
+ if len(bloque) >= 20:
+ fragmentos.append(bloque)
+
+ return fragmentos
+
+
+class BootProtocol:
+ """
+ Ejecuta la secuencia de arranque de PAMPAr.
+
+ Carga CONCIENCIA.md como identidad (L3, nunca se purga),
+ ejecuta el Scanner para inspeccionar el entorno, y
+ vectoriza el resultado como contexto del entorno (L2).
+
+ Args:
+ workspace_root: Directorio raíz del workspace.
+ conciencia_path: Ruta al archivo CONCIENCIA.md.
+ scan_depth: Profundidad de escaneo para el Scanner.
+ """
+
+ def __init__(
+ self,
+ workspace_root: str = ".",
+ conciencia_path: Optional[str] = None,
+ scan_depth: int = 5,
+ ):
+ self.workspace_root = Path(workspace_root).resolve()
+
+ if conciencia_path:
+ self.conciencia_path = Path(conciencia_path)
+ else:
+ self.conciencia_path = self._buscar_conciencia()
+
+ self.scanner = Scanner(
+ workspace_root=str(self.workspace_root),
+ scan_depth=scan_depth,
+ )
+ self._scan_resultado: Optional[ResultadoScan] = None
+
+ def ejecutar(self, rag: RAGResidual) -> ResultadoScan:
+ """
+ Ejecuta el boot completo: identidad + scan + vectorización.
+
+ Args:
+ rag: RAGResidual donde inyectar identidad y contexto.
+ Returns:
+ ResultadoScan con la información del entorno detectado.
+ """
+ # 1. Cargar e inyectar CONCIENCIA (identidad L3)
+ self._cargar_conciencia(rag)
+
+ # 2. Escanear el entorno
+ self._scan_resultado = self.scanner.scan()
+
+ # 3. Vectorizar el resumen del scan como contexto L2
+ self._inyectar_contexto(rag, self._scan_resultado)
+
+ return self._scan_resultado
+
+ def _buscar_conciencia(self) -> Path:
+ """Busca CONCIENCIA.md en ubicaciones conocidas."""
+ # Primero buscar relativo al paquete pampar/ (donde pertenece)
+ paquete_dir = Path(__file__).resolve().parent.parent
+ candidatos = [
+ paquete_dir / "CONCIENCIA.md",
+ self.workspace_root / "pampar" / "CONCIENCIA.md",
+ self.workspace_root / "CONCIENCIA.md",
+ Path.home() / ".pampar" / "CONCIENCIA.md",
+ ]
+ for path in candidatos:
+ if path.is_file():
+ return path
+ return candidatos[0] # default al paquete
+
+ def _cargar_conciencia(self, rag: RAGResidual) -> None:
+ """Carga CONCIENCIA.md y lo fragmenta en entradas L3."""
+ if not self.conciencia_path.is_file():
+ return
+
+ texto = self.conciencia_path.read_text(encoding="utf-8")
+ fragmentos = _fragmentar_markdown(texto)
+
+ for i, fragmento in enumerate(fragmentos):
+ entrada = EntradaMemoria(
+ texto=fragmento,
+ tipo="identidad",
+ nivel=3,
+ importancia=1.0,
+ frecuencia=1,
+ )
+ rag.agregar(entrada)
+
+ def _inyectar_contexto(self, rag: RAGResidual, scan: ResultadoScan) -> None:
+ """Genera el AGENTS.md contextual y lo inyecta como entradas L2 en el RAG."""
+ # Generar el AGENTS.md determinista desde el scan
+ agents_md = generar_agents_md(scan)
+
+ # Fragmentar por secciones ## y agregar cada sección como entrada L2
+ fragmentos = _fragmentar_markdown(agents_md)
+ for fragmento in fragmentos:
+ entrada = EntradaMemoria(
+ texto=fragmento,
+ tipo="entorno",
+ nivel=2,
+ importancia=0.8,
+ frecuencia=1,
+ )
+ rag.agregar(entrada)
+
+ # Archivos Python del workspace como entradas individuales L1
+ for archivo in scan.archivos[:50]: # Cap a 50 archivos más relevantes
+ partes: list[str] = [f"Archivo: {archivo.ruta} ({archivo.lineas} líneas)"]
+ if archivo.clases:
+ partes.append(f" Clases: {', '.join(archivo.clases)}")
+ if archivo.funciones:
+ partes.append(f" Funciones: {', '.join(archivo.funciones[:10])}")
+ if archivo.imports:
+ partes.append(f" Imports: {', '.join(archivo.imports[:10])}")
+
+ entrada_archivo = EntradaMemoria(
+ texto="\n".join(partes),
+ tipo="workspace",
+ nivel=1,
+ importancia=0.5,
+ frecuencia=1,
+ )
+ rag.agregar(entrada_archivo)
+
+ @property
+ def scan_resultado(self) -> Optional[ResultadoScan]:
+ """Resultado del último scan (None si no se ha ejecutado boot)."""
+ return self._scan_resultado
+
+ def generar_system_prompt(self) -> str:
+ """
+ Genera el system prompt dinámico basado en CONCIENCIA + scan.
+
+ Este prompt se usa como fallback cuando el RAG no ha sido inicializado
+ o como prompt base mínimo.
+ """
+ partes: list[str] = []
+
+ # Identidad mínima (siempre presente)
+ partes.append(
+ "Sos PAMPAr, un asistente de programación local y offline "
+ "especializado en Python.\n"
+ "Tenés acceso a la memoria de interacciones previas y "
+ "podés ejecutar código cuando sea necesario."
+ )
+
+ # Acciones disponibles
+ partes.append(
+ "Para leer un archivo: [LEER: ruta/al/archivo.py]\n"
+ "Para ejecutar código: [EJECUTAR:\ncodigo_python_aqui\n]\n"
+ "Para ejecutar tests: [TESTS: ruta/tests/]"
+ )
+
+ # Contexto del entorno (si hay scan)
+ if self._scan_resultado:
+ resumen = self._scan_resultado.resumen
+ if resumen:
+ partes.append(resumen)
+
+ partes.append("Respondé siempre en español. El código va siempre en inglés.")
+
+ return "\n\n".join(partes)
diff --git a/pampar/runtime/generar_agents.py b/pampar/runtime/generar_agents.py
new file mode 100644
index 0000000000000000000000000000000000000000..65f4930dd378c6a907142357bddd1d5a6a123f99
--- /dev/null
+++ b/pampar/runtime/generar_agents.py
@@ -0,0 +1,218 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+generar_agents.py — Generador determinista del AGENTS.md contextual.
+
+Forma parte del protocolo de boot (Paso 3): dado el ResultadoScan del entorno,
+genera el AGENTS.md actualizado con el contexto real del despliegue.
+
+Uso desde el BootProtocol:
+ from pampar.runtime.generar_agents import generar_agents_md
+ md = generar_agents_md(scan_resultado, proyecto="mi-app")
+ Path("AGENTS.md").write_text(md, encoding="utf-8")
+"""
+
+from __future__ import annotations
+
+import datetime
+from typing import Optional
+
+from .scanner import ResultadoScan
+
+
+def generar_agents_md(
+ scan: ResultadoScan,
+ proyecto: Optional[str] = None,
+ descripcion: Optional[str] = None,
+ agente_nombre: str = "PAMPAr",
+) -> str:
+ """
+ Genera un AGENTS.md contextual desde el resultado del Scanner.
+
+ Args:
+ scan: Resultado del Scanner con info del entorno.
+ proyecto: Nombre del proyecto (auto-detectado si es None).
+ descripcion: Breve descripción del proyecto.
+ agente_nombre: Nombre del agente (default: PAMPAr).
+
+ Returns:
+ Contenido del AGENTS.md como string.
+ """
+ now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
+ sis = scan.sistema
+ proyecto = proyecto or _detectar_proyecto(scan)
+ descripcion = descripcion or _inferir_descripcion(scan)
+
+ # ── Sección de cabecera ─────────────────────────────────────────────────
+ lineas: list[str] = [
+ f"# {agente_nombre} — Protocolo de Despliegue",
+ "",
+ f"> Generado automáticamente por el Scanner al boot — {now}",
+ f"> Para la identidad invariante del modelo, ver `CONCIENCIA.md`.",
+ "",
+ "---",
+ "",
+ ]
+
+ # ── Quick Reference ────────────────────────────────────────────────────
+ gpu_row = sis.gpu if sis.gpu else "CPU only"
+ voz_row = ", ".join(scan.voz) if scan.voz else "no disponible"
+
+ lineas += [
+ "## Quick Reference",
+ "",
+ "| Área | Valor detectado |",
+ "| --------------- | ------------------------------------- |",
+ f"| Proyecto | `{proyecto}` |",
+ f"| OS | {sis.os} |",
+ f"| Python | {sis.python_version} |",
+ f"| GPU | {gpu_row} |",
+ f"| RAM | {f'{sis.ram_gb:.1f} GB' if sis.ram_gb else 'desconocida'} |",
+ f"| Voz | {voz_row} |",
+ "",
+ ]
+
+ # ── Sistema detectado ──────────────────────────────────────────────────
+ lineas += [
+ "## Sistema detectado",
+ "",
+ f"- **OS**: {sis.os}",
+ f"- **Python**: {sis.python_version}",
+ ]
+
+ if sis.gpu:
+ vram = f"{sis.vram_mb / 1024:.1f} GB" if sis.vram_mb else "desconocida"
+ lineas.append(f"- **GPU**: {sis.gpu} \u2014 {vram} VRAM")
+ else:
+ lineas.append("- **GPU**: no disponible (solo CPU)")
+
+ if sis.ram_gb:
+ lineas += [
+ f"- **RAM**: {sis.ram_gb:.1f} GB",
+ "",
+ ]
+ else:
+ lineas.append("")
+
+ # ── Workspace ─────────────────────────────────────────────────────────
+ if scan.archivos:
+ # Agrupar por extensión
+ ext_count: dict[str, int] = {}
+ for a in scan.archivos:
+ ext = a.ruta.rsplit(".", 1)[-1].lower() if "." in a.ruta else "sin ext"
+ ext_count[ext] = ext_count.get(ext, 0) + 1
+
+ total_files = len(scan.archivos)
+ ext_str = ", ".join(
+ f"{ext.upper()}: {n}"
+ for ext, n in sorted(ext_count.items(), key=lambda x: -x[1])[:5]
+ )
+
+ total_funcs = sum(len(a.funciones) for a in scan.archivos)
+ total_classes = sum(len(a.clases) for a in scan.archivos)
+
+ lineas += [
+ "## Workspace",
+ "",
+ f"- **Archivos**: {total_files} ({ext_str})",
+ f"- **Funciones**: {total_funcs}",
+ f"- **Clases**: {total_classes}",
+ "",
+ ]
+
+ # ── Paquetes clave ────────────────────────────────────────────────────
+ _PAQUETES_CLAVE = {
+ "torch", "tensorflow", "keras",
+ "fastapi", "flask", "django", "uvicorn", "starlette",
+ "pandas", "numpy", "scipy", "matplotlib", "plotly", "seaborn",
+ "transformers", "diffusers", "peft", "trl", "bitsandbytes",
+ "sqlalchemy", "alembic", "psycopg2", "pymongo",
+ "celery", "redis", "pika",
+ "pytest", "vitest", "playwright",
+ "sentencepiece", "tokenizers",
+ "langchain", "openai", "anthropic",
+ "docker", "kubernetes",
+ "click", "typer", "rich",
+ "pydantic", "fastapi",
+ "httpx", "requests", "aiohttp",
+ }
+
+ paquetes_relevantes = {
+ k: v for k, v in scan.paquetes.items()
+ if any(clave in k.lower() for clave in _PAQUETES_CLAVE)
+ }
+
+ if paquetes_relevantes:
+ lineas += ["## Paquetes clave", ""]
+ for pkg, ver in sorted(paquetes_relevantes.items()):
+ lineas.append(f"- `{pkg}=={ver}`")
+ lineas.append("")
+
+ # ── Servicios detectados ──────────────────────────────────────────────
+ servicios_activos = [k for k, v in scan.servicios.items() if v]
+ servicios_inactivos = [k for k, v in scan.servicios.items() if not v]
+
+ lineas += ["## Servicios", ""]
+ if servicios_activos:
+ lineas.append(f"- **Activos**: {', '.join(servicios_activos)}")
+ if servicios_inactivos:
+ lineas.append(f"- **Inactivos**: {', '.join(servicios_inactivos)}")
+ if not scan.servicios:
+ lineas.append("- No se detectaron servicios de red activos")
+ lineas.append("")
+
+ # ── Boot protocol ─────────────────────────────────────────────────────
+ lineas += [
+ "## Boot protocol",
+ "",
+ "El agente ejecuta la siguiente secuencia al iniciar:",
+ "",
+ "```",
+ "1. CONCIENCIA.md → identidad invariante → RAG L3",
+ "2. Scanner → inspección del entorno",
+ "3. AGENTS.md → contexto del despliegue → RAG L2",
+ "4. Workspace → archivos y funciones → RAG L1",
+ "```",
+ "",
+ ]
+
+ return "\n".join(lineas)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Helpers privados
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _detectar_proyecto(scan: ResultadoScan) -> str:
+ """Intenta detectar el nombre del proyecto desde el workspace."""
+ # Buscar pyproject.toml, setup.py, package.json
+ nombres_clave = {
+ "pyproject.toml": r'name\s*=\s*["\']([^"\']+)',
+ "package.json": r'"name"\s*:\s*"([^"]+)"',
+ }
+ for archivo in scan.archivos:
+ base = archivo.ruta.split("/")[-1].split("\\")[-1]
+ if base in nombres_clave:
+ return base # fallback — nombre del archivo de config
+ return "workspace"
+
+
+def _inferir_descripcion(scan: ResultadoScan) -> str:
+ """Infiere una descripción breve del proyecto desde los paquetes detectados."""
+ pkgs = set(scan.paquetes.keys())
+
+ if any(p in pkgs for p in ("torch", "transformers", "peft", "trl")):
+ return "pipeline de ML / fine-tuning de LLMs"
+ if any(p in pkgs for p in ("fastapi", "uvicorn", "starlette")):
+ return "API REST con FastAPI"
+ if "django" in pkgs:
+ return "aplicación web Django"
+ if "flask" in pkgs:
+ return "aplicación web Flask"
+ if any(p in pkgs for p in ("pandas", "numpy", "scikit-learn")):
+ return "proyecto de Data Science / análisis de datos"
+ if any(p in pkgs for p in ("click", "typer", "rich")):
+ return "herramienta CLI"
+ if any(p in pkgs for p in ("celery", "redis")):
+ return "sistema de colas y procesamiento asíncrono"
+ return "proyecto Python"
diff --git a/pampar/runtime/scanner.py b/pampar/runtime/scanner.py
new file mode 100644
index 0000000000000000000000000000000000000000..61c8cdc5f35c80daa2d5a6e65ad5375770f946fc
--- /dev/null
+++ b/pampar/runtime/scanner.py
@@ -0,0 +1,310 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Scanner — Inspección del entorno al boot.
+
+Escanea el workspace, paquetes instalados, servicios disponibles y
+capacidades del sistema. El resultado se usa para generar el AGENTS.md
+contextual de cada despliegue.
+
+Principios:
+ - Solo lectura: ast.parse, no exec/eval
+ - Sin dependencias externas: usa stdlib pura
+ - Timeout en detección de servicios
+ - Resultado como dataclass serializable
+"""
+
+import ast
+import importlib.metadata
+import platform
+import shutil
+import socket
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Dict, List, Optional
+
+
+@dataclass
+class InfoArchivo:
+ """Metadata de un archivo Python del workspace."""
+
+ ruta: str
+ funciones: List[str] = field(default_factory=list)
+ clases: List[str] = field(default_factory=list)
+ imports: List[str] = field(default_factory=list)
+ lineas: int = 0
+
+
+@dataclass
+class InfoSistema:
+ """Información del sistema operativo y hardware."""
+
+ os: str = ""
+ os_version: str = ""
+ python_version: str = ""
+ arquitectura: str = ""
+ gpu: Optional[str] = None
+ vram_mb: Optional[int] = None
+ ram_gb: Optional[float] = None
+
+
+@dataclass
+class ResultadoScan:
+ """Resultado completo del scan del entorno."""
+
+ workspace_root: str = ""
+ archivos: List[InfoArchivo] = field(default_factory=list)
+ lenguajes: Dict[str, int] = field(default_factory=dict)
+ paquetes: Dict[str, str] = field(default_factory=dict)
+ servicios: Dict[str, bool] = field(default_factory=dict)
+ sistema: InfoSistema = field(default_factory=InfoSistema)
+ voz: List[str] = field(default_factory=list)
+
+ @property
+ def resumen(self) -> str:
+ """Genera resumen legible del scan para inyectar en contexto."""
+ partes: list[str] = []
+
+ partes.append(f"## Entorno detectado\n")
+ partes.append(f"- **OS**: {self.sistema.os} {self.sistema.os_version}")
+ partes.append(f"- **Python**: {self.sistema.python_version}")
+ if self.sistema.gpu:
+ partes.append(f"- **GPU**: {self.sistema.gpu} ({self.sistema.vram_mb} MB)")
+ if self.sistema.ram_gb:
+ partes.append(f"- **RAM**: {self.sistema.ram_gb:.1f} GB")
+
+ if self.lenguajes:
+ langs = ", ".join(f"{k}: {v}" for k, v in sorted(
+ self.lenguajes.items(), key=lambda x: -x[1],
+ ))
+ partes.append(f"- **Archivos**: {langs}")
+
+ if self.paquetes:
+ top = list(self.paquetes.items())[:20]
+ pkgs = ", ".join(f"{n}=={v}" for n, v in top)
+ partes.append(f"- **Paquetes** ({len(self.paquetes)} total): {pkgs}")
+
+ if self.servicios:
+ activos = [k for k, v in self.servicios.items() if v]
+ if activos:
+ partes.append(f"- **Servicios activos**: {', '.join(activos)}")
+
+ if self.voz:
+ partes.append(f"- **Voz**: {', '.join(self.voz)}")
+
+ return "\n".join(partes)
+
+
+class Scanner:
+ """
+ Inspecciona el entorno donde PAMPAr se despliega.
+
+ Solo lectura — usa ast.parse (no exec), importlib.metadata,
+ socket con timeout, platform, shutil.which.
+
+ Args:
+ workspace_root: Directorio raíz del workspace a inspeccionar.
+ scan_depth: Profundidad máxima de directorios a escanear.
+ service_timeout: Timeout en segundos para detectar servicios.
+ """
+
+ EXTENSIONES = {
+ ".py": "Python",
+ ".js": "JavaScript",
+ ".ts": "TypeScript",
+ ".json": "JSON",
+ ".md": "Markdown",
+ ".yaml": "YAML",
+ ".yml": "YAML",
+ ".toml": "TOML",
+ ".sh": "Shell",
+ ".sql": "SQL",
+ }
+
+ SERVICIOS = {
+ "PostgreSQL": ("127.0.0.1", 5432),
+ "Redis": ("127.0.0.1", 6379),
+ "HTTP-8000": ("127.0.0.1", 8000),
+ "HTTP-3000": ("127.0.0.1", 3000),
+ }
+
+ MOTORES_VOZ = ["espeak", "espeak-ng", "say", "festival"]
+
+ # Directorios a ignorar siempre
+ _IGNORAR = {
+ "__pycache__", ".git", ".venv", "venv", "node_modules",
+ ".mypy_cache", ".pytest_cache", ".tox", "dist", "build",
+ "egg-info",
+ }
+
+ def __init__(
+ self,
+ workspace_root: str = ".",
+ scan_depth: int = 5,
+ service_timeout: float = 0.3,
+ ):
+ self.root = Path(workspace_root).resolve()
+ self.scan_depth = scan_depth
+ self.service_timeout = service_timeout
+
+ def scan(self) -> ResultadoScan:
+ """
+ Ejecuta un scan completo del entorno.
+
+ Returns:
+ ResultadoScan con toda la información detectada.
+ """
+ resultado = ResultadoScan(workspace_root=str(self.root))
+ resultado.sistema = self._scan_sistema()
+ resultado.archivos, resultado.lenguajes = self._scan_workspace()
+ resultado.paquetes = self._scan_paquetes()
+ resultado.servicios = self._scan_servicios()
+ resultado.voz = self._scan_voz()
+ return resultado
+
+ def _scan_sistema(self) -> InfoSistema:
+ """Detecta OS, Python, GPU, RAM."""
+ info = InfoSistema(
+ os=platform.system(),
+ os_version=platform.version(),
+ python_version=platform.python_version(),
+ arquitectura=platform.machine(),
+ )
+
+ # GPU detection via torch (si está disponible)
+ try:
+ import torch
+ if torch.cuda.is_available():
+ info.gpu = torch.cuda.get_device_name(0)
+ info.vram_mb = torch.cuda.get_device_properties(0).total_memory // (1024 * 1024)
+ except ImportError:
+ pass
+
+ # RAM detection
+ try:
+ import psutil
+ info.ram_gb = psutil.virtual_memory().total / (1024 ** 3)
+ except ImportError:
+ pass
+
+ return info
+
+ def _scan_workspace(self) -> tuple[list[InfoArchivo], dict[str, int]]:
+ """Inspecciona archivos del workspace con ast.parse para .py."""
+ archivos: list[InfoArchivo] = []
+ lenguajes: dict[str, int] = {}
+
+ if not self.root.is_dir():
+ return archivos, lenguajes
+
+ for path in self._iter_files():
+ ext = path.suffix.lower()
+ lang = self.EXTENSIONES.get(ext)
+ if lang:
+ lenguajes[lang] = lenguajes.get(lang, 0) + 1
+
+ if ext == ".py":
+ info = self._analizar_python(path)
+ if info:
+ archivos.append(info)
+
+ return archivos, lenguajes
+
+ def _iter_files(self):
+ """Itera archivos del workspace respetando profundidad y exclusiones."""
+ for path in self.root.rglob("*"):
+ if not path.is_file():
+ continue
+
+ # Verificar profundidad
+ try:
+ rel = path.relative_to(self.root)
+ except ValueError:
+ continue
+ if len(rel.parts) > self.scan_depth:
+ continue
+
+ # Ignorar directorios excluidos
+ if any(
+ p in self._IGNORAR or p.endswith(".egg-info")
+ for p in rel.parts
+ ):
+ continue
+
+ yield path
+
+ def _analizar_python(self, path: Path) -> Optional[InfoArchivo]:
+ """Analiza un archivo .py con ast.parse (no exec)."""
+ try:
+ source = path.read_text(encoding="utf-8", errors="ignore")
+ tree = ast.parse(source, filename=str(path))
+ except (SyntaxError, UnicodeDecodeError):
+ return None
+
+ rel_path = str(path.relative_to(self.root)).replace("\\", "/")
+ info = InfoArchivo(ruta=rel_path, lineas=len(source.splitlines()))
+
+ for node in ast.iter_child_nodes(tree):
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
+ info.funciones.append(node.name)
+ elif isinstance(node, ast.ClassDef):
+ info.clases.append(node.name)
+ elif isinstance(node, ast.Import):
+ for alias in node.names:
+ info.imports.append(alias.name)
+ elif isinstance(node, ast.ImportFrom):
+ if node.module:
+ info.imports.append(node.module)
+
+ return info
+
+ def _scan_paquetes(self) -> dict[str, str]:
+ """Detecta paquetes instalados via importlib.metadata."""
+ paquetes: dict[str, str] = {}
+ try:
+ for dist in importlib.metadata.distributions():
+ name = dist.metadata.get("Name", "")
+ version = dist.metadata.get("Version", "")
+ if name:
+ paquetes[name] = version
+ except Exception:
+ pass
+ return paquetes
+
+ def _scan_servicios(self) -> dict[str, bool]:
+ """Detecta servicios activos con socket connect + timeout."""
+ servicios: dict[str, bool] = {}
+ for nombre, (host, port) in self.SERVICIOS.items():
+ servicios[nombre] = self._puerto_abierto(host, port)
+ return servicios
+
+ def _puerto_abierto(self, host: str, port: int) -> bool:
+ """Verifica si un puerto está abierto (solo localhost)."""
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.settimeout(self.service_timeout)
+ return s.connect_ex((host, port)) == 0
+ except OSError:
+ return False
+
+ def _scan_voz(self) -> list[str]:
+ """Detecta motores de voz disponibles en el sistema."""
+ disponibles: list[str] = []
+ for motor in self.MOTORES_VOZ:
+ if shutil.which(motor):
+ disponibles.append(motor)
+
+ # Windows SAPI check
+ if platform.system() == "Windows":
+ try:
+ import winreg
+ key = winreg.OpenKey(
+ winreg.HKEY_LOCAL_MACHINE,
+ r"SOFTWARE\Microsoft\Speech\Voices",
+ )
+ winreg.CloseKey(key)
+ disponibles.append("SAPI")
+ except (ImportError, OSError):
+ pass
+
+ return disponibles
diff --git a/pampar/skills/__init__.py b/pampar/skills/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1e3343e814548ac431c75a8d4817655659122594
--- /dev/null
+++ b/pampar/skills/__init__.py
@@ -0,0 +1,26 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Skills — Capacidades externas del sistema PAMPAr.
+
+Las skills son las "partes del cuerpo" que permiten al cerebro
+(PamparV3) interactuar con el mundo real:
+
+ LectorArchivos → ojos (leer código, archivos, directorios)
+ EjecutorCodigo → manos (ejecutar Python, ver output y errores)
+ BuscadorWeb → biblioteca externa (búsqueda online, opcional)
+
+El runtime/agente.py decide cuándo y cómo llamar a cada skill
+basándose en el output del modelo.
+"""
+
+from .base import Skill, ResultadoSkill
+from .lector_archivos import LectorArchivos
+from .ejecutar_codigo import EjecutorCodigo
+
+__all__ = [
+ "Skill",
+ "ResultadoSkill",
+ "LectorArchivos",
+ "EjecutorCodigo",
+]
diff --git a/pampar/skills/base.py b/pampar/skills/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..7da288c4d0b174408cb2ea755222f28b0261cd49
--- /dev/null
+++ b/pampar/skills/base.py
@@ -0,0 +1,56 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Skill base — Interfaz común para todas las capacidades externas.
+
+Todas las skills heredan de Skill e implementan execute().
+El runtime/agente.py las invoca por nombre sin saber su implementación.
+"""
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import Any, Dict
+
+
+@dataclass
+class ResultadoSkill:
+ """Resultado estandarizado de cualquier skill."""
+ exito: bool
+ contenido: str # Output legible por el modelo
+ datos: Dict[str, Any] = field(default_factory=dict) # Datos estructurados extras
+ error: str = "" # Mensaje de error si exito=False
+
+ def __str__(self) -> str:
+ if self.exito:
+ return self.contenido
+ return f"[ERROR] {self.error}"
+
+
+class Skill(ABC):
+ """
+ Interfaz base para todas las skills externas de PAMPAr.
+
+ Una skill representa una capacidad del modelo para interactuar
+ con el mundo real: leer archivos, ejecutar código, buscar web, etc.
+
+ Las skills son síncronas por defecto. El agente las llama en el loop
+ de razonamiento del modelo cuando detecta una intención de acción.
+ """
+
+ name: str = "skill_base"
+ description: str = "Skill base"
+
+ @abstractmethod
+ def execute(self, **kwargs) -> ResultadoSkill:
+ """
+ Ejecuta la skill con los argumentos dados.
+
+ Args:
+ **kwargs: Argumentos específicos de cada skill
+ Returns:
+ ResultadoSkill con el output y estado
+ """
+ ...
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}(name={self.name!r})"
diff --git a/pampar/skills/ejecutar_codigo.py b/pampar/skills/ejecutar_codigo.py
new file mode 100644
index 0000000000000000000000000000000000000000..9da773ca69169bd75e66eefa87d122a5221e430b
--- /dev/null
+++ b/pampar/skills/ejecutar_codigo.py
@@ -0,0 +1,335 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+EjecutorCodigo — Las "manos" del modelo.
+
+Ejecuta código en un subproceso aislado con timeout.
+Soporta Python, JavaScript (Node.js) y Bash.
+El modelo puede ver el output y los errores para razonar sobre ellos
+e iterar (detectar bug → ver traceback → corregir → re-ejecutar).
+
+Seguridad:
+ - Corre en subproceso separado (no en el mismo proceso del modelo)
+ - Timeout configurable (default 10s) — evita loops infinitos
+ - stdin cerrado — no puede recibir input
+ - Directorio de trabajo aislado configurable
+
+IMPORTANTE: No es un sandbox completo (sin contenedor Docker).
+Para producción en entornos multiusuario, envolver con firejail/docker.
+Para uso local personal esto es suficiente.
+"""
+
+import shutil
+import subprocess
+import sys
+import tempfile
+import textwrap
+from pathlib import Path
+from typing import Optional
+
+from .base import ResultadoSkill, Skill
+
+# Lenguajes soportados y sus extensiones
+_LANG_CONFIG = {
+ "python": {"ext": ".py", "suffix": "pampar_exec_"},
+ "javascript": {"ext": ".js", "suffix": "pampar_exec_"},
+ "bash": {"ext": ".sh", "suffix": "pampar_exec_"},
+}
+
+# Alias de lenguaje → nombre canónico
+_LANG_ALIASES = {
+ "python": "python",
+ "py": "python",
+ "python3": "python",
+ "javascript": "javascript",
+ "js": "javascript",
+ "node": "javascript",
+ "bash": "bash",
+ "sh": "bash",
+ "shell": "bash",
+}
+
+
+class EjecutorCodigo(Skill):
+ """
+ Ejecuta fragmentos de código y retorna el output.
+
+ Soporta Python, JavaScript (Node.js) y Bash.
+ Detecta el lenguaje automáticamente o acepta un hint explícito.
+
+ Args:
+ timeout: Segundos máximos de ejecución (default 10)
+ cwd: Directorio de trabajo para la ejecución
+ python_bin: Intérprete Python a usar (default sys.executable)
+ node_bin: Intérprete Node.js (auto-detectado si disponible)
+ bash_bin: Intérprete Bash (auto-detectado si disponible)
+ """
+
+ name = "ejecutar_codigo"
+ description = (
+ "Ejecuta un fragmento de código (Python, JavaScript o Bash) y retorna "
+ "el output y errores. Úsalo para verificar que el código funciona."
+ )
+
+ # Prefijos de operaciones bloqueadas (seguridad básica)
+ _BLOQUEADOS = frozenset(
+ {
+ "os.system",
+ "subprocess.Popen",
+ "subprocess.run",
+ "shutil.rmtree",
+ "__import__('os').remove",
+ "open('/etc",
+ "open('/root",
+ "open('/home",
+ "child_process.exec",
+ "child_process.spawn", # Node.js
+ "require('child_process')",
+ "rm -rf /",
+ "mkfs.",
+ "dd if=", # Bash
+ }
+ )
+
+ def __init__(
+ self,
+ timeout: int = 10,
+ cwd: Optional[str] = None,
+ python_bin: str = sys.executable,
+ node_bin: Optional[str] = None,
+ bash_bin: Optional[str] = None,
+ ):
+ self.timeout = timeout
+ self.cwd = cwd
+ self.python_bin = python_bin
+ self.node_bin = node_bin or shutil.which("node")
+ self.bash_bin = bash_bin or shutil.which("bash") or shutil.which("sh")
+
+ def _detect_language(self, codigo: str) -> str:
+ """Detecta el lenguaje del código por heurística."""
+ first_line = codigo.strip().split("\n")[0].strip()
+
+ # Shebang detection
+ if first_line.startswith("#!"):
+ if "python" in first_line:
+ return "python"
+ if "node" in first_line:
+ return "javascript"
+ if "bash" in first_line or "sh" in first_line:
+ return "bash"
+
+ # Keyword heuristics
+ js_signals = {
+ "const ",
+ "let ",
+ "var ",
+ "function ",
+ "=> ",
+ "console.log",
+ "require(",
+ "import {",
+ "export ",
+ }
+ bash_signals = {
+ "#!/bin",
+ "echo ",
+ "fi\n",
+ "done\n",
+ "esac\n",
+ "if [",
+ "then\n",
+ "$((",
+ "${",
+ }
+ py_signals = {"def ", "import ", "from ", "class ", "print(", "self."}
+
+ js_score = sum(1 for s in js_signals if s in codigo)
+ bash_score = sum(1 for s in bash_signals if s in codigo)
+ py_score = sum(1 for s in py_signals if s in codigo)
+
+ if js_score > py_score and js_score > bash_score:
+ return "javascript"
+ if bash_score > py_score and bash_score > js_score:
+ return "bash"
+ return "python"
+
+ def _get_interpreter(self, lang: str) -> list[str]:
+ """Retorna el comando del intérprete para el lenguaje dado."""
+ if lang == "python":
+ return [self.python_bin]
+ if lang == "javascript":
+ if not self.node_bin:
+ raise RuntimeError(
+ "Node.js no encontrado. Instalar node para ejecutar JavaScript."
+ )
+ return [self.node_bin]
+ if lang == "bash":
+ if not self.bash_bin:
+ raise RuntimeError(
+ "Bash no encontrado. Instalar bash/sh para ejecutar shell scripts."
+ )
+ return [self.bash_bin]
+ raise ValueError(f"Lenguaje no soportado: {lang}")
+
+ def execute(
+ self,
+ codigo: str,
+ timeout: Optional[int] = None,
+ lang: Optional[str] = None,
+ ) -> ResultadoSkill:
+ """
+ Ejecuta el código dado y captura stdout/stderr.
+
+ Args:
+ codigo: Código a ejecutar
+ timeout: Override del timeout (None = usar el default)
+ lang: Lenguaje ("python", "javascript", "bash").
+ None = auto-detect.
+ Returns:
+ ResultadoSkill con stdout, stderr y código de retorno
+ """
+ t = timeout or self.timeout
+ codigo = textwrap.dedent(codigo).strip()
+
+ if not codigo:
+ return ResultadoSkill(exito=False, contenido="", error="Código vacío")
+
+ # Resolver lenguaje
+ lang_key = _LANG_ALIASES.get(lang, lang) if lang else None
+ if not lang_key:
+ lang_key = self._detect_language(codigo)
+ if lang_key not in _LANG_CONFIG:
+ return ResultadoSkill(
+ exito=False,
+ contenido="",
+ error=f"Lenguaje no soportado: {lang_key}. Usar: python, javascript, bash",
+ )
+
+ # Verificar intérprete disponible
+ try:
+ interpreter = self._get_interpreter(lang_key)
+ except RuntimeError as e:
+ return ResultadoSkill(exito=False, contenido="", error=str(e))
+
+ # Chequeo básico de operaciones bloqueadas
+ for bloqueado in self._BLOQUEADOS:
+ if bloqueado in codigo:
+ return ResultadoSkill(
+ exito=False,
+ contenido="",
+ error=f"Operación no permitida detectada: '{bloqueado}'",
+ )
+
+ # Escribir código en archivo temporal
+ cfg = _LANG_CONFIG[lang_key]
+ with tempfile.NamedTemporaryFile(
+ mode="w",
+ suffix=cfg["ext"],
+ delete=False,
+ encoding="utf-8",
+ prefix=cfg["suffix"],
+ ) as tmp:
+ tmp.write(codigo)
+ tmp_path = tmp.name
+
+ try:
+ result = subprocess.run(
+ [*interpreter, tmp_path],
+ capture_output=True,
+ text=True,
+ timeout=t,
+ stdin=subprocess.DEVNULL, # No input
+ cwd=self.cwd,
+ )
+
+ stdout = result.stdout.strip()
+ stderr = result.stderr.strip()
+ ret = result.returncode
+
+ # Formatear output para el modelo
+ partes = []
+ if stdout:
+ partes.append(f"[STDOUT]\n{stdout}")
+ if stderr:
+ label = "[STDERR/TRACEBACK]" if "Traceback" in stderr else "[STDERR]"
+ partes.append(f"{label}\n{stderr}")
+ if not stdout and not stderr:
+ partes.append("[Sin output]")
+
+ contenido = "\n\n".join(partes)
+ if ret != 0:
+ contenido += f"\n\n[Código de retorno: {ret}]"
+
+ return ResultadoSkill(
+ exito=(ret == 0),
+ contenido=contenido,
+ datos={
+ "returncode": ret,
+ "stdout": stdout,
+ "stderr": stderr,
+ "timeout_usado": t,
+ },
+ error=stderr if ret != 0 else "",
+ )
+
+ except subprocess.TimeoutExpired:
+ return ResultadoSkill(
+ exito=False,
+ contenido=f"[TIMEOUT] El código no terminó en {t} segundos.",
+ error=f"Timeout después de {t}s",
+ datos={"timeout_segundos": t},
+ )
+ except Exception as e:
+ return ResultadoSkill(
+ exito=False,
+ contenido="",
+ error=f"Error al ejecutar: {e}",
+ )
+ finally:
+ # Siempre limpiar el archivo temporal
+ try:
+ Path(tmp_path).unlink(missing_ok=True)
+ except Exception:
+ pass
+
+ def ejecutar_tests(self, ruta_test: str) -> ResultadoSkill:
+ """
+ Ejecuta tests con pytest en la ruta dada.
+
+ Args:
+ ruta_test: Archivo o directorio de tests
+ Returns:
+ ResultadoSkill con output de pytest
+ """
+ try:
+ result = subprocess.run(
+ [self.python_bin, "-m", "pytest", ruta_test, "-v", "--tb=short"],
+ capture_output=True,
+ text=True,
+ timeout=60, # Tests pueden tardar más
+ stdin=subprocess.DEVNULL,
+ cwd=self.cwd,
+ )
+
+ stdout = result.stdout.strip()
+ stderr = result.stderr.strip()
+ exito = result.returncode == 0
+
+ contenido = stdout
+ if stderr:
+ contenido += f"\n[STDERR]\n{stderr}"
+
+ return ResultadoSkill(
+ exito=exito,
+ contenido=contenido,
+ datos={"returncode": result.returncode},
+ error="" if exito else "Algunos tests fallaron",
+ )
+ except subprocess.TimeoutExpired:
+ return ResultadoSkill(
+ exito=False,
+ contenido="[TIMEOUT] Tests no terminaron en 60s",
+ error="Timeout en tests",
+ )
+ except Exception as e:
+ return ResultadoSkill(exito=False, contenido="", error=str(e))
diff --git a/pampar/skills/lector_archivos.py b/pampar/skills/lector_archivos.py
new file mode 100644
index 0000000000000000000000000000000000000000..e76b7d8979d7f45aba2541f4a74c3865bb35d98a
--- /dev/null
+++ b/pampar/skills/lector_archivos.py
@@ -0,0 +1,219 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+LectorArchivos — Los "ojos" del modelo.
+
+Permite a PAMPAr leer archivos y directorios del sistema local.
+El modelo puede ver el código del usuario, su estructura de proyecto,
+archivos de configuración, errores en logs, etc.
+
+Seguridad:
+ - Raíz configurable: solo puede leer dentro del workspace_root
+ - Límite de tamaño: archivos > max_bytes se truncan con advertencia
+ - Extensiones permitidas: solo texto plano y código
+"""
+
+from pathlib import Path
+from typing import List, Optional
+
+from .base import ResultadoSkill, Skill
+
+
+# Extensiones permitidas para lectura
+EXTENSIONES_TEXTO = {
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs",
+ ".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs",
+ ".html", ".css", ".scss", ".less",
+ ".json", ".jsonl", ".yaml", ".yml", ".toml", ".env",
+ ".md", ".txt", ".rst", ".csv",
+ ".sh", ".bash", ".zsh", ".ps1",
+ ".sql", ".graphql",
+ ".ipynb",
+}
+
+
+class LectorArchivos(Skill):
+ """
+ Lee archivos y directorios del workspace del usuario.
+
+ Args:
+ workspace_root: Directorio raíz donde puede leer (sandboxing)
+ max_bytes: Límite de bytes por archivo (default 50KB)
+ """
+
+ name = "lector_archivos"
+ description = (
+ "Lee el contenido de archivos o lista un directorio del proyecto. "
+ "Úsalo cuando necesites ver código existente antes de modificarlo."
+ )
+
+ def __init__(
+ self,
+ workspace_root: str = ".",
+ max_bytes: int = 50 * 1024, # 50KB
+ ):
+ self.root = Path(workspace_root).resolve()
+ self.max_bytes = max_bytes
+
+ def execute(
+ self,
+ ruta: str,
+ linea_inicio: int = 1,
+ linea_fin: Optional[int] = None,
+ ) -> ResultadoSkill:
+ """
+ Lee un archivo o lista un directorio.
+
+ Args:
+ ruta: Path relativo al workspace_root
+ linea_inicio: Primera línea a leer (1-based, default 1)
+ linea_fin: Última línea a leer (None = hasta el final)
+ Returns:
+ ResultadoSkill con el contenido del archivo o listado de dir
+ """
+ try:
+ ruta_abs = (self.root / ruta).resolve()
+ except Exception as e:
+ return ResultadoSkill(exito=False, contenido="", error=f"Ruta inválida: {e}")
+
+ # Verificar sandboxing
+ if not str(ruta_abs).startswith(str(self.root)):
+ return ResultadoSkill(
+ exito=False, contenido="",
+ error=f"Acceso denegado: fuera del workspace ({self.root})"
+ )
+
+ if not ruta_abs.exists():
+ return ResultadoSkill(
+ exito=False, contenido="",
+ error=f"No existe: {ruta}"
+ )
+
+ if ruta_abs.is_dir():
+ return self._listar_directorio(ruta_abs, ruta)
+
+ return self._leer_archivo(ruta_abs, ruta, linea_inicio, linea_fin)
+
+ def _leer_archivo(
+ self,
+ ruta_abs: Path,
+ ruta_rel: str,
+ linea_inicio: int,
+ linea_fin: Optional[int],
+ ) -> ResultadoSkill:
+ """Lee el contenido de un archivo de texto."""
+ sufijo = ruta_abs.suffix.lower()
+ if sufijo not in EXTENSIONES_TEXTO:
+ return ResultadoSkill(
+ exito=False, contenido="",
+ error=f"Extensión no permitida: {sufijo}. Solo archivos de texto/código."
+ )
+
+ size = ruta_abs.stat().st_size
+ truncado = size > self.max_bytes
+
+ try:
+ contenido = ruta_abs.read_text(encoding="utf-8", errors="replace")
+ except Exception as e:
+ return ResultadoSkill(exito=False, contenido="", error=str(e))
+
+ lineas = contenido.splitlines()
+ total_lineas = len(lineas)
+
+ # Aplicar rango de líneas
+ li = max(0, linea_inicio - 1)
+ lf = linea_fin if linea_fin else total_lineas
+ lineas_sel = lineas[li:lf]
+ contenido_sel = "\n".join(lineas_sel)
+
+ # Truncar si excede max_bytes
+ if len(contenido_sel.encode()) > self.max_bytes:
+ contenido_sel = contenido_sel.encode()[:self.max_bytes].decode(errors="replace")
+ truncado = True
+
+ aviso = f"\n[TRUNCADO: archivo de {size // 1024}KB, mostrando hasta {self.max_bytes // 1024}KB]" if truncado else ""
+
+ contenido_final = (
+ f"[ARCHIVO: {ruta_rel}] ({total_lineas} líneas)\n"
+ f"```{sufijo.lstrip('.')}\n"
+ f"{contenido_sel}"
+ f"\n```{aviso}"
+ )
+ return ResultadoSkill(
+ exito=True,
+ contenido=contenido_final,
+ datos={
+ "ruta": ruta_rel,
+ "total_lineas": total_lineas,
+ "lineas_leidas": len(lineas_sel),
+ "truncado": truncado,
+ }
+ )
+
+ def _listar_directorio(self, ruta_abs: Path, ruta_rel: str) -> ResultadoSkill:
+ """Lista el contenido de un directorio."""
+ try:
+ items = sorted(ruta_abs.iterdir(), key=lambda p: (p.is_file(), p.name))
+ except PermissionError:
+ return ResultadoSkill(
+ exito=False, contenido="",
+ error=f"Sin permiso para leer: {ruta_rel}"
+ )
+
+ lineas = [f"[DIRECTORIO: {ruta_rel}]"]
+ for item in items[:100]: # Limitar a 100 items
+ prefijo = "📄" if item.is_file() else "📁"
+ size = f" ({item.stat().st_size // 1024}KB)" if item.is_file() else ""
+ lineas.append(f" {prefijo} {item.name}{size}")
+
+ if len(list(ruta_abs.iterdir())) > 100:
+ lineas.append(" ... (más de 100 items, mostrando primeros 100)")
+
+ return ResultadoSkill(
+ exito=True,
+ contenido="\n".join(lineas),
+ datos={"ruta": ruta_rel, "n_items": len(items)}
+ )
+
+ def buscar_en_workspace(
+ self,
+ patron: str,
+ extension: str = ".py",
+ max_resultados: int = 10,
+ ) -> ResultadoSkill:
+ """
+ Busca texto en archivos del workspace.
+
+ Args:
+ patron: String a buscar
+ extension: Extensión de archivo a buscar (default .py)
+ max_resultados: Máximo de resultados
+ Returns:
+ ResultadoSkill con matches encontrados
+ """
+ matches: List[str] = []
+ for archivo in self.root.rglob(f"*{extension}"):
+ if "__pycache__" in str(archivo) or ".git" in str(archivo):
+ continue
+ try:
+ contenido = archivo.read_text(encoding="utf-8", errors="replace")
+ for n, linea in enumerate(contenido.splitlines(), 1):
+ if patron.lower() in linea.lower():
+ rel = str(archivo.relative_to(self.root))
+ matches.append(f"{rel}:{n}: {linea.strip()}")
+ if len(matches) >= max_resultados:
+ break
+ except Exception:
+ continue
+ if len(matches) >= max_resultados:
+ break
+
+ if not matches:
+ return ResultadoSkill(
+ exito=True,
+ contenido=f"No se encontraron resultados para '{patron}' en archivos {extension}",
+ )
+
+ contenido = f"[BÚSQUEDA: '{patron}' en *{extension}] — {len(matches)} resultado(s)\n"
+ contenido += "\n".join(matches)
+ return ResultadoSkill(exito=True, contenido=contenido, datos={"matches": matches})
diff --git a/pampar/training/__init__.py b/pampar/training/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..3b485b91e8a5093b2f7693e5969909e92ee758c8
--- /dev/null
+++ b/pampar/training/__init__.py
@@ -0,0 +1,15 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+pampar.training — Sistema de aprendizaje autónomo para PamparV3.
+
+ MotorCuriosidad : selección de temas por ZPD (Vygotsky)
+ LectorBiblioteca : carga y tokeniza datos de biblioteca/
+ MemoriaJerarquica : memoria L0/L1/L2 para replay anti-olvido
+"""
+
+from .curiosidad import MotorCuriosidad, PerfilTema
+from .lector import LectorBiblioteca
+from .memoria_jerarquica import MemoriaJerarquica
+
+__all__ = ["MotorCuriosidad", "PerfilTema", "LectorBiblioteca", "MemoriaJerarquica"]
diff --git a/pampar/training/curiosidad.py b/pampar/training/curiosidad.py
new file mode 100644
index 0000000000000000000000000000000000000000..cfdc45f0c930d71df3e4f9fd10d1b53ce37f49e3
--- /dev/null
+++ b/pampar/training/curiosidad.py
@@ -0,0 +1,336 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Motor de Curiosidad — "El modelo sabe lo que no sabe".
+
+Concepto: Zona de Desarrollo Próximo de Vygotsky aplicada a IA.
+ - Loss baja → ya lo sabe → aburrimiento → curiosidad baja
+ - Loss media → zona óptima → curiosidad MÁXIMA
+ - Loss muy alta → demasiado difícil aún → curiosidad media
+
+curiosidad(tema) = zona_proximal × novedad × urgencia_temporal × bonus_mejora
+"""
+
+import json
+import math
+import time
+from collections import deque
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+import torch
+import torch.nn.functional as F
+
+
+# =============================================================================
+# PERFIL DE TEMA
+# =============================================================================
+
+@dataclass
+class PerfilTema:
+ """Estado de aprendizaje del modelo para un tema específico."""
+
+ nombre: str
+ categoria: str
+ nivel_dificultad: int # 1-6
+
+ historial_loss: deque = field(default_factory=lambda: deque(maxlen=20))
+ primera_vez: float = field(default_factory=time.time)
+ ultima_vez: float = field(default_factory=time.time)
+ n_sesiones: int = 0
+ loss_media: float = 99.0
+ tasa_mejora: float = 0.0
+ dominado: bool = False
+ curiosidad: float = 1.0
+
+ def registrar_sesion(self, loss: float) -> None:
+ """Registra una sesión y actualiza estadísticas."""
+ self.historial_loss.append(loss)
+ self.ultima_vez = time.time()
+ self.n_sesiones += 1
+
+ reciente = list(self.historial_loss)[-5:]
+ self.loss_media = sum(reciente) / len(reciente)
+
+ # Tasa de mejora: pendiente lineal del historial
+ if len(self.historial_loss) >= 3:
+ hist = list(self.historial_loss)
+ n = len(hist)
+ xs = list(range(n))
+ x_mean = sum(xs) / n
+ y_mean = sum(hist) / n
+ num = sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, hist))
+ den = sum((x - x_mean) ** 2 for x in xs) or 1e-8
+ self.tasa_mejora = -(num / den) # Negativo = mejora
+
+ # Dominado si las últimas 5 sesiones están por debajo del umbral de dominio
+ if len(self.historial_loss) >= 5:
+ self.dominado = all(l < 1.3 for l in list(self.historial_loss)[-5:])
+
+ def tiempo_sin_ver(self) -> float:
+ """Horas desde la última sesión."""
+ return (time.time() - self.ultima_vez) / 3600.0
+
+ def to_dict(self) -> dict:
+ return {
+ "nombre": self.nombre,
+ "categoria": self.categoria,
+ "nivel_dificultad": self.nivel_dificultad,
+ "historial_loss": list(self.historial_loss),
+ "primera_vez": self.primera_vez,
+ "ultima_vez": self.ultima_vez,
+ "n_sesiones": self.n_sesiones,
+ "loss_media": self.loss_media,
+ "tasa_mejora": self.tasa_mejora,
+ "dominado": self.dominado,
+ "curiosidad": self.curiosidad,
+ }
+
+ @classmethod
+ def from_dict(cls, d: dict) -> "PerfilTema":
+ p = cls(
+ nombre=d["nombre"],
+ categoria=d["categoria"],
+ nivel_dificultad=d["nivel_dificultad"],
+ )
+ p.historial_loss = deque(d.get("historial_loss", []), maxlen=20)
+ p.primera_vez = d.get("primera_vez", time.time())
+ p.ultima_vez = d.get("ultima_vez", time.time())
+ p.n_sesiones = d.get("n_sesiones", 0)
+ p.loss_media = d.get("loss_media", 99.0)
+ p.tasa_mejora = d.get("tasa_mejora", 0.0)
+ p.dominado = d.get("dominado", False)
+ p.curiosidad = d.get("curiosidad", 1.0)
+ return p
+
+
+# =============================================================================
+# MOTOR DE CURIOSIDAD
+# =============================================================================
+
+class MotorCuriosidad:
+ """
+ Decide qué estudiar a continuación usando curiosidad intrínseca.
+
+ Algoritmo por tema:
+ curiosidad = zona_proximal × novedad × temporal × bonus_mejora
+
+ zona_proximal: campana gaussiana centrada en LOSS_OPTIMA (1.5).
+ novedad: 1 / (1 + n_sesiones × 0.08) — temas nuevos son más ricos.
+ temporal: spacing effect — temas olvidados suben de prioridad.
+ bonus_mejora: refuerzo si el modelo está mejorando rápido.
+ """
+
+ LOSS_OPTIMA: float = 1.5 # Centro de la zona de máxima curiosidad
+ LOSS_DOMINIO: float = 1.3 # Bajo este umbral el modelo ya dominó el tema
+ LOSS_MUY_DIFICIL: float = 5.0 # Sobre este umbral el tema está fuera del ZPD
+
+ def __init__(
+ self,
+ ruta_estado: Optional[Path] = None,
+ nivel_actual: int = 1,
+ ) -> None:
+ self.ruta_estado = ruta_estado
+ self.nivel_actual = nivel_actual
+ self.temas: Dict[str, PerfilTema] = {}
+ self.cola_reciente: deque = deque(maxlen=5)
+ self.sesiones_totales: int = 0
+ self.temas_dominados: int = 0
+
+ if ruta_estado and Path(ruta_estado).exists():
+ self.cargar(ruta_estado)
+
+ # ── Registro ──────────────────────────────────────────────────────────────
+
+ def registrar_tema(self, nombre: str, categoria: str, nivel: int) -> None:
+ """Registra un tema nuevo si no existe."""
+ if nombre not in self.temas:
+ self.temas[nombre] = PerfilTema(
+ nombre=nombre,
+ categoria=categoria,
+ nivel_dificultad=nivel,
+ )
+
+ def registrar_temas_desde_indice(self, indice: dict) -> int:
+ """Carga todos los temas del índice JSON de biblioteca/. Devuelve nº nuevos."""
+ nuevos = 0
+ for categoria, temas in indice.items():
+ if not isinstance(temas, list):
+ continue
+ for tema in temas:
+ nombre = tema["nombre"]
+ if nombre not in self.temas:
+ self.registrar_tema(nombre, categoria, tema.get("nivel", 1))
+ nuevos += 1
+ return nuevos
+
+ # ── Cálculo de curiosidad ─────────────────────────────────────────────────
+
+ def _zpd(self, loss: float) -> float:
+ """Campana gaussiana en LOSS_OPTIMA."""
+ if loss < self.LOSS_DOMINIO:
+ return 0.1
+ if loss > self.LOSS_MUY_DIFICIL:
+ return 0.2
+ sigma = 0.8
+ z = (loss - self.LOSS_OPTIMA) / sigma
+ return math.exp(-0.5 * z * z)
+
+ def calcular_curiosidad(self, tema: PerfilTema) -> float:
+ """Score de curiosidad [0, ∞). Mayor = más prioritario."""
+ if tema.n_sesiones <= 5:
+ zpd = max(0.18, 0.6 - tema.n_sesiones * 0.07)
+ nivel_ok = 0.5
+ else:
+ zpd = self._zpd(tema.loss_media)
+ nivel_ok = 1.0 if tema.nivel_dificultad <= self.nivel_actual + 1 else 0.3
+
+ novedad = 1.0 / (1.0 + tema.n_sesiones * 0.08)
+ temporal = 1.0 + math.log1p(tema.tiempo_sin_ver() / 12.0) * 0.5
+
+ if tema.tasa_mejora > 0.05:
+ bonus = 1.3
+ elif tema.tasa_mejora < -0.02:
+ bonus = 0.8
+ else:
+ bonus = 1.0
+
+ curiosidad = zpd * novedad * temporal * bonus * nivel_ok
+ tema.curiosidad = curiosidad
+ return curiosidad
+
+ def actualizar_todos(self) -> None:
+ for tema in self.temas.values():
+ self.calcular_curiosidad(tema)
+
+ # ── Selección ─────────────────────────────────────────────────────────────
+
+ def siguiente_tema(self, excluir_recientes: bool = True) -> Optional[str]:
+ """Devuelve el tema más curioso. Usa muestreo probabilístico top-3."""
+ self.actualizar_todos()
+
+ candidatos = list(self.temas.items())
+ if excluir_recientes:
+ candidatos = [(n, t) for n, t in candidatos if n not in self.cola_reciente]
+ if not candidatos:
+ candidatos = list(self.temas.items())
+ if not candidatos:
+ return None
+
+ candidatos.sort(key=lambda x: x[1].curiosidad, reverse=True)
+ top = candidatos[:min(3, len(candidatos))]
+ scores = [t.curiosidad for _, t in top]
+ total = sum(scores) or 1.0
+
+ rand = torch.rand(1).item() * total
+ acum, elegido = 0.0, top[0][0]
+ for nombre, tema in top:
+ acum += tema.curiosidad
+ if rand <= acum:
+ elegido = nombre
+ break
+
+ self.cola_reciente.append(elegido)
+ return elegido
+
+ def tops(self, n: int = 5) -> List[Tuple[str, float]]:
+ """Top N temas por curiosidad."""
+ self.actualizar_todos()
+ ordenados = sorted(self.temas.items(), key=lambda x: x[1].curiosidad, reverse=True)
+ return [(nombre, tema.curiosidad) for nombre, tema in ordenados[:n]]
+
+ # ── Retroalimentación ─────────────────────────────────────────────────────
+
+ def retroalimentar(self, nombre_tema: str, loss: float) -> dict:
+ """Actualiza el perfil de un tema tras una sesión."""
+ if nombre_tema not in self.temas:
+ return {}
+
+ tema = self.temas[nombre_tema]
+ loss_anterior = tema.loss_media
+ era_dominado = tema.dominado
+ tema.registrar_sesion(loss)
+ self.sesiones_totales += 1
+
+ recien_dominado = tema.dominado and not era_dominado
+ if recien_dominado:
+ self.temas_dominados += 1
+ self._verificar_avance_nivel()
+
+ return {
+ "tema": nombre_tema,
+ "loss_anterior": loss_anterior,
+ "loss_actual": tema.loss_media,
+ "mejora": loss_anterior - tema.loss_media,
+ "dominado": tema.dominado,
+ "recien_dominado": recien_dominado,
+ "nivel_actual": self.nivel_actual,
+ }
+
+ def _verificar_avance_nivel(self) -> bool:
+ """Sube de nivel si ≥70% de los temas del nivel actual están dominados."""
+ temas_nivel = [t for t in self.temas.values() if t.nivel_dificultad == self.nivel_actual]
+ if not temas_nivel:
+ return False
+ dominados = sum(1 for t in temas_nivel if t.dominado)
+ if dominados / len(temas_nivel) >= 0.70 and self.nivel_actual < 6:
+ self.nivel_actual += 1
+ return True
+ return False
+
+ # ── Resumen ───────────────────────────────────────────────────────────────
+
+ def resumen(self) -> dict:
+ total = len(self.temas)
+ dominados = sum(1 for t in self.temas.values() if t.dominado)
+ en_progreso = sum(1 for t in self.temas.values() if not t.dominado and t.n_sesiones > 0)
+ loss_global = sum(t.loss_media for t in self.temas.values()) / total if total else 0.0
+ return {
+ "nivel_actual": self.nivel_actual,
+ "sesiones_totales": self.sesiones_totales,
+ "temas_total": total,
+ "temas_dominados": dominados,
+ "temas_en_progreso": en_progreso,
+ "temas_intactos": total - dominados - en_progreso,
+ "porcentaje_dominio": (dominados / total * 100) if total else 0.0,
+ "loss_promedio_global": loss_global,
+ "tops_curiosidad": self.tops(3),
+ }
+
+ def __repr__(self) -> str:
+ r = self.resumen()
+ return (
+ f"MotorCuriosidad(nivel={r['nivel_actual']}, "
+ f"dominados={r['temas_dominados']}/{r['temas_total']}, "
+ f"loss={r['loss_promedio_global']:.2f})"
+ )
+
+ # ── Persistencia ──────────────────────────────────────────────────────────
+
+ def guardar(self, ruta: Optional[Path] = None) -> None:
+ """Guarda el estado completo en JSON."""
+ ruta = Path(ruta or self.ruta_estado)
+ if ruta is None:
+ return
+ ruta.parent.mkdir(parents=True, exist_ok=True)
+ estado = {
+ "nivel_actual": self.nivel_actual,
+ "sesiones_totales": self.sesiones_totales,
+ "temas_dominados": self.temas_dominados,
+ "cola_reciente": list(self.cola_reciente),
+ "temas": {n: t.to_dict() for n, t in self.temas.items()},
+ }
+ ruta.write_text(json.dumps(estado, indent=2, ensure_ascii=False))
+
+ def cargar(self, ruta: Optional[Path] = None) -> None:
+ """Carga el estado desde JSON."""
+ ruta = Path(ruta or self.ruta_estado)
+ if not ruta.exists():
+ return
+ estado = json.loads(ruta.read_text())
+ self.nivel_actual = estado.get("nivel_actual", 1)
+ self.sesiones_totales = estado.get("sesiones_totales", 0)
+ self.temas_dominados = estado.get("temas_dominados", 0)
+ self.cola_reciente = deque(estado.get("cola_reciente", []), maxlen=5)
+ self.temas = {n: PerfilTema.from_dict(d) for n, d in estado.get("temas", {}).items()}
diff --git a/pampar/training/lector.py b/pampar/training/lector.py
new file mode 100644
index 0000000000000000000000000000000000000000..8e03696b3245bb21d9e21a64f0ad4525d3ebd573
--- /dev/null
+++ b/pampar/training/lector.py
@@ -0,0 +1,151 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+LectorBiblioteca — carga y tokeniza datos de biblioteca/ para entrenamiento.
+
+Soporta:
+ - JSONL con claves "text", "content", "instruction"+"output", o texto plano
+ - División automática en chunks de max_seq_len tokens
+ - Cache en memoria por archivo (no releer en cada iteración)
+ - Batch aleatorio listo para enviar al modelo
+"""
+
+import json
+import logging
+from pathlib import Path
+from typing import Optional
+
+import torch
+
+logger = logging.getLogger(__name__)
+
+
+class LectorBiblioteca:
+ """
+ Lee y tokeniza archivos JSONL de la biblioteca de conocimiento.
+
+ Flujo:
+ 1. cargar_archivo(ruta_relativa) → lista de chunks tokenizados (cacheada)
+ 2. obtener_batch(ruta_relativa, device) → Tensor [B, L+1]
+
+ Args:
+ raiz: Ruta a biblioteca/ (carpeta con los .jsonl)
+ tokenizer: SentencePieceProcessor ya cargado
+ max_seq_len: Máxima longitud de secuencia (excl. el token objetivo)
+ batch_size: Chunks por batch
+ """
+
+ def __init__(
+ self,
+ raiz: Path,
+ tokenizer,
+ max_seq_len: int = 512,
+ batch_size: int = 4,
+ ) -> None:
+ self.raiz = Path(raiz)
+ self.tok = tokenizer
+ self.max_seq_len = max_seq_len
+ self.batch_size = batch_size
+ self._cache: dict[str, list[list[int]]] = {}
+
+ # ── Parseo ────────────────────────────────────────────────────────────────
+
+ def _extraer_texto(self, linea: str) -> str:
+ """
+ Extrae texto de una línea JSONL.
+
+ Formatos soportados:
+ {"text": "..."}
+ {"content": "..."}
+ {"instruction": "...", "output": "..."} — formato Alpaca
+ Texto plano (fallback)
+ """
+ try:
+ obj = json.loads(linea)
+ if "text" in obj:
+ return obj["text"]
+ if "content" in obj:
+ return obj["content"]
+ if "instruction" in obj and "output" in obj:
+ return f"{obj['instruction']}\n{obj.get('input', '')}\n{obj['output']}"
+ # Último recurso: concatenar todos los valores string
+ return " ".join(str(v) for v in obj.values() if isinstance(v, str))
+ except json.JSONDecodeError:
+ return linea.strip()
+
+ # ── Carga ─────────────────────────────────────────────────────────────────
+
+ def cargar_archivo(self, ruta_relativa: str) -> list[list[int]]:
+ """
+ Carga y tokeniza todos los chunks de un archivo JSONL.
+
+ Returns:
+ Lista de listas de token IDs. Vacía si el archivo no existe.
+ """
+ if ruta_relativa in self._cache:
+ return self._cache[ruta_relativa]
+
+ ruta = self.raiz / ruta_relativa
+ if not ruta.exists():
+ return []
+
+ chunks: list[list[int]] = []
+ try:
+ for linea in ruta.read_text(encoding="utf-8").splitlines():
+ if not linea.strip():
+ continue
+ texto = self._extraer_texto(linea)
+ ids = self.tok.Encode(texto)
+ # Dividir en chunks solapados — +1 para el token target
+ for i in range(0, max(1, len(ids) - self.max_seq_len), self.max_seq_len // 2):
+ chunk = ids[i : i + self.max_seq_len + 1]
+ if len(chunk) >= 8:
+ chunks.append(chunk)
+ except Exception as exc:
+ logger.warning("Error leyendo %s: %s", ruta, exc)
+ return []
+
+ self._cache[ruta_relativa] = chunks
+ return chunks
+
+ def tiene_datos(self, ruta_relativa: str) -> bool:
+ """True si el archivo existe y tiene al menos un chunk válido."""
+ return len(self.cargar_archivo(ruta_relativa)) > 0
+
+ def n_chunks(self, ruta_relativa: str) -> int:
+ """Número de chunks disponibles para un archivo."""
+ return len(self.cargar_archivo(ruta_relativa))
+
+ # ── Batch ─────────────────────────────────────────────────────────────────
+
+ def obtener_batch(
+ self,
+ ruta_relativa: str,
+ device: torch.device,
+ ) -> Optional[torch.Tensor]:
+ """
+ Devuelve un batch aleatorio de tokens listos para el modelo.
+
+ Returns:
+ Tensor [B, L] o None si no hay datos para este archivo.
+ """
+ chunks = self.cargar_archivo(ruta_relativa)
+ if not chunks:
+ return None
+
+ indices = torch.randint(0, len(chunks), (self.batch_size,))
+ seleccionados = [chunks[i] for i in indices]
+
+ max_len = min(max(len(c) for c in seleccionados), self.max_seq_len + 1)
+
+ padded = []
+ for chunk in seleccionados:
+ trunc = chunk[:max_len]
+ pad = [0] * (max_len - len(trunc))
+ padded.append(trunc + pad)
+
+ return torch.tensor(padded, dtype=torch.long, device=device)
+
+ def invalidar_cache(self) -> None:
+ """Limpia el cache en memoria (útil si los archivos cambian en disco)."""
+ self._cache.clear()
diff --git a/pampar/training/memoria_jerarquica.py b/pampar/training/memoria_jerarquica.py
new file mode 100644
index 0000000000000000000000000000000000000000..26350e481b6d2e6b8d94ed165cf43bb508cbc36e
--- /dev/null
+++ b/pampar/training/memoria_jerarquica.py
@@ -0,0 +1,315 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+MemoriaJerarquica — Memoria de entrenamiento con 3 niveles.
+
+L0 (reciente): FIFO ring buffer de las últimas secuencias vistas.
+L1 (difícil): secuencias con loss alta → se usan en replay anti-olvido.
+L2 (dominado): patrones ya aprendidos → consolidación periódica.
+
+Principio anti-olvido:
+ Durante el aprendizaje continuo, el modelo tiende a olvidar lo que
+ ya aprendió cuando entrena en datos nuevos (catastrophic forgetting).
+ La MemoriaJerarquica mitiga esto con:
+ - Replay periódico de los patrones más difíciles (L1)
+ - Consolidación de patrones dominados (L2)
+ - FIFO de observaciones recientes para contexto (L0)
+
+Usado por scripts/aprender_solo.py para el viaje intelectual autónomo.
+"""
+
+import json
+import logging
+import random
+import time
+from collections import deque
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+
+import torch
+
+logger = logging.getLogger(__name__)
+
+# Umbrales de clasificación por loss
+UMBRAL_DIFICIL = 1.5 # loss > this → L1 (es difícil, necesita práctica)
+UMBRAL_DOMINADO = 0.8 # loss < this → candidato a L2 (ya lo domina)
+BATCH_REPLAY = 4 # Tamaño de batch por defecto para replay
+
+
+@dataclass
+class EntradaEntrenamiento:
+ """Una secuencia almacenada en la memoria de entrenamiento."""
+
+ tokens: list[int]
+ loss_media: float = 0.0
+ territorio: str = ""
+ timestamp: float = field(default_factory=time.time)
+ n_replay: int = 0
+
+ def to_dict(self) -> dict:
+ return {
+ "tokens": self.tokens,
+ "loss_media": self.loss_media,
+ "territorio": self.territorio,
+ "timestamp": self.timestamp,
+ "n_replay": self.n_replay,
+ }
+
+ @classmethod
+ def from_dict(cls, d: dict) -> "EntradaEntrenamiento":
+ return cls(
+ tokens=d["tokens"],
+ loss_media=d.get("loss_media", 0.0),
+ territorio=d.get("territorio", ""),
+ timestamp=d.get("timestamp", 0.0),
+ n_replay=d.get("n_replay", 0),
+ )
+
+
+class MemoriaJerarquica:
+ """
+ Memoria jerárquica de entrenamiento con 3 niveles.
+
+ L0: Ring buffer FIFO — almacena las últimas N secuencias vistas.
+ L1: Buffer de prioridad — secuencias difíciles (loss alta) para replay.
+ L2: Buffer de consolidación — patrones dominados (loss baja consistente).
+
+ Args:
+ capacidad_l0: Máximo de entradas en L0 (FIFO).
+ capacidad_l1: Máximo de entradas en L1 (las más fáciles se evictan).
+ capacidad_l2: Máximo de entradas en L2 (las más viejas se evictan).
+ """
+
+ def __init__(
+ self,
+ capacidad_l0: int = 2048,
+ capacidad_l1: int = 8000,
+ capacidad_l2: int = 3000,
+ ) -> None:
+ self.capacidad_l0 = capacidad_l0
+ self.capacidad_l1 = capacidad_l1
+ self.capacidad_l2 = capacidad_l2
+
+ self.l0: deque[EntradaEntrenamiento] = deque(maxlen=capacidad_l0)
+ self.l1: list[EntradaEntrenamiento] = []
+ self.l2: list[EntradaEntrenamiento] = []
+
+ self._stats = {
+ "total_procesados": 0,
+ "promovidos_l1": 0,
+ "promovidos_l2": 0,
+ "consolidaciones": 0,
+ }
+
+ # ── Procesamiento ────────────────────────────────────────────────────────
+
+ def procesar_batch(
+ self,
+ tokens: torch.Tensor,
+ per_token_loss: Optional[torch.Tensor] = None,
+ terr_acts: Optional[torch.Tensor] = None,
+ ) -> None:
+ """
+ Procesa un batch de entrenamiento y almacena las secuencias.
+
+ Cada secuencia del batch se clasifica por su loss media:
+ - Todas van a L0 (FIFO reciente).
+ - Loss > UMBRAL_DIFICIL → también a L1 (necesita replay).
+ - Loss < UMBRAL_DOMINADO → candidato a L2 en la próxima consolidación.
+
+ Args:
+ tokens: [B, L] token IDs del batch.
+ per_token_loss: [B, L] loss por token (None si no disponible).
+ terr_acts: [B, L, 4] activaciones de territorio (opcional).
+ """
+ B = tokens.shape[0]
+
+ for i in range(B):
+ seq = tokens[i].tolist()
+
+ # Loss media (solo tokens no-padding)
+ loss = 0.0
+ if per_token_loss is not None and i < per_token_loss.shape[0]:
+ mask = tokens[i] != 0
+ valid_losses = per_token_loss[i][mask]
+ if valid_losses.numel() > 0:
+ loss = valid_losses.mean().item()
+
+ # Territorio dominante
+ territorio = ""
+ if terr_acts is not None and i < terr_acts.shape[0]:
+ nombres = ["SINTAXIS", "SEMANTICA", "LOGICO", "ESTRUCTURAL"]
+ dom_idx = terr_acts[i].mean(dim=0).argmax().item()
+ if dom_idx < len(nombres):
+ territorio = nombres[dom_idx]
+
+ entrada = EntradaEntrenamiento(
+ tokens=seq,
+ loss_media=loss,
+ territorio=territorio,
+ )
+
+ # L0 siempre (FIFO)
+ self.l0.append(entrada)
+ self._stats["total_procesados"] += 1
+
+ # L1 si es difícil
+ if loss > UMBRAL_DIFICIL:
+ self.l1.append(entrada)
+ self._stats["promovidos_l1"] += 1
+
+ # Evictar los más fáciles si L1 está llena
+ if len(self.l1) > self.capacidad_l1:
+ self.l1.sort(key=lambda e: e.loss_media, reverse=True)
+ self.l1 = self.l1[: self.capacidad_l1]
+
+ # ── Replay ───────────────────────────────────────────────────────────────
+
+ def get_replay_batch(
+ self,
+ strategy: str = "hardest",
+ batch_size: int = BATCH_REPLAY,
+ ) -> Optional[torch.Tensor]:
+ """
+ Devuelve un batch de replay desde L1.
+
+ Args:
+ strategy: "hardest" (mayor loss) o "random" (aleatorio).
+ batch_size: Número de secuencias en el batch.
+
+ Returns:
+ Tensor [B, L] listo para entrenamiento, o None si L1 está vacía.
+ """
+ if not self.l1:
+ return None
+
+ if strategy == "hardest":
+ sorted_l1 = sorted(self.l1, key=lambda e: e.loss_media, reverse=True)
+ selected = sorted_l1[:batch_size]
+ else:
+ selected = random.sample(self.l1, min(batch_size, len(self.l1)))
+
+ # Marcar como "replayed"
+ for entry in selected:
+ entry.n_replay += 1
+
+ # Pad al mismo largo y convertir a tensor
+ max_len = max(len(e.tokens) for e in selected)
+ padded = []
+ for e in selected:
+ trunc = e.tokens[:max_len]
+ pad = [0] * (max_len - len(trunc))
+ padded.append(trunc + pad)
+
+ return torch.tensor(padded, dtype=torch.long)
+
+ # ── Consolidación ────────────────────────────────────────────────────────
+
+ def consolidar(self, modelo: torch.nn.Module) -> dict:
+ """
+ Reorganiza la memoria entre niveles.
+
+ - L1 entries con loss baja (modelo ya las aprendió) → L2.
+ - L2 se recorta por antigüedad si excede capacidad.
+ - L1 entries con demasiados replays (>10) se retiran.
+
+ Args:
+ modelo: El modelo (por compatibilidad de interfaz; no se usa
+ para forward passes aquí para evitar overhead).
+
+ Returns:
+ dict con estadísticas de la consolidación.
+ """
+ self._stats["consolidaciones"] += 1
+
+ # Mover patrones fáciles de L1 → L2
+ mastered = [e for e in self.l1 if e.loss_media < UMBRAL_DOMINADO]
+ still_hard = [e for e in self.l1 if e.loss_media >= UMBRAL_DOMINADO]
+
+ # Retirar entries con demasiados replays (ya se practicaron suficiente)
+ retired = [e for e in still_hard if e.n_replay > 10]
+ active = [e for e in still_hard if e.n_replay <= 10]
+
+ self.l2.extend(mastered)
+ self.l1 = active
+ self._stats["promovidos_l2"] += len(mastered)
+
+ # Recortar L2 por antigüedad
+ if len(self.l2) > self.capacidad_l2:
+ self.l2.sort(key=lambda e: e.timestamp)
+ self.l2 = self.l2[-self.capacidad_l2 :]
+
+ result = {
+ "a_l2": len(mastered),
+ "retirados": len(retired),
+ "l1_activo": len(self.l1),
+ "l2_total": len(self.l2),
+ }
+ logger.debug("Consolidación: %s", result)
+ return result
+
+ # ── Persistencia ─────────────────────────────────────────────────────────
+
+ def guardar(self, ruta: str) -> None:
+ """Guarda el estado completo en JSON."""
+ path = Path(ruta)
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ estado = {
+ "capacidad_l0": self.capacidad_l0,
+ "capacidad_l1": self.capacidad_l1,
+ "capacidad_l2": self.capacidad_l2,
+ "stats": self._stats,
+ # L0 es FIFO efímero — solo guardar las últimas 256 para contexto
+ "l0": [e.to_dict() for e in list(self.l0)[-256:]],
+ "l1": [e.to_dict() for e in self.l1],
+ "l2": [e.to_dict() for e in self.l2],
+ }
+ path.write_text(json.dumps(estado, ensure_ascii=False), encoding="utf-8")
+ logger.info(
+ "Memoria guardada en %s (L0=%d, L1=%d, L2=%d)",
+ ruta,
+ len(self.l0),
+ len(self.l1),
+ len(self.l2),
+ )
+
+ @classmethod
+ def cargar(cls, ruta: str) -> "MemoriaJerarquica":
+ """Carga el estado desde JSON."""
+ path = Path(ruta)
+ if not path.exists():
+ raise FileNotFoundError(f"Estado de memoria no encontrado: {ruta}")
+
+ estado = json.loads(path.read_text(encoding="utf-8"))
+
+ mem = cls(
+ capacidad_l0=estado.get("capacidad_l0", 2048),
+ capacidad_l1=estado.get("capacidad_l1", 8000),
+ capacidad_l2=estado.get("capacidad_l2", 3000),
+ )
+ mem._stats = estado.get("stats", mem._stats)
+
+ for d in estado.get("l0", []):
+ mem.l0.append(EntradaEntrenamiento.from_dict(d))
+ mem.l1 = [EntradaEntrenamiento.from_dict(d) for d in estado.get("l1", [])]
+ mem.l2 = [EntradaEntrenamiento.from_dict(d) for d in estado.get("l2", [])]
+
+ logger.info(
+ "Memoria cargada desde %s (L0=%d, L1=%d, L2=%d)",
+ ruta,
+ len(mem.l0),
+ len(mem.l1),
+ len(mem.l2),
+ )
+ return mem
+
+ # ── Info ─────────────────────────────────────────────────────────────────
+
+ def __repr__(self) -> str:
+ return (
+ f"MemoriaJerarquica(L0={len(self.l0)}/{self.capacidad_l0}, "
+ f"L1={len(self.l1)}/{self.capacidad_l1}, "
+ f"L2={len(self.l2)}/{self.capacidad_l2})"
+ )
diff --git a/paper/README.md b/paper/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..122a9b038ad8b7a41a7ca47bee69ebcb74f225d6
--- /dev/null
+++ b/paper/README.md
@@ -0,0 +1,73 @@
+# PAMPAr-Coder V3 — Paper & Registration
+
+## Archivos
+
+| Archivo | Propósito |
+| --------------------- | ----------------------------------------------- |
+| `pampar_v3_arxiv.tex` | Paper LaTeX (formato arXiv, cs.CL / cs.LG) |
+| `../CITATION.cff` | Metadata CFF para GitHub "Cite this repository" |
+| `../.zenodo.json` | Metadata para registro DOI en Zenodo |
+| `../LICENSE` | Business Source License 1.1 (BUSL-1.1) |
+
+## Compilar el paper
+
+```bash
+# Requiere: texlive-full o miktex
+pdflatex pampar_v3_arxiv.tex
+bibtex pampar_v3_arxiv
+pdflatex pampar_v3_arxiv.tex
+pdflatex pampar_v3_arxiv.tex
+```
+
+O con `latexmk`:
+
+```bash
+latexmk -pdf pampar_v3_arxiv.tex
+```
+
+## Registrar en Zenodo
+
+1. Ir a [zenodo.org](https://zenodo.org) → Login con GitHub
+2. Settings → GitHub → Enable el repo `lucasmella-stack/PAMPAr-Coder`
+3. Crear un **GitHub Release** (tag `v3.0.0`)
+4. Zenodo detecta automáticamente el release y crea el DOI
+5. Actualizar el DOI en `pampar_v3_arxiv.tex` (línea `\date`) y `CITATION.cff`
+
+### Release tag sugerido
+
+```bash
+git tag -a v3.0.0 -m "PAMPAr-Coder V3: 2D Stream Architecture with Mixed Selectivity"
+git push origin v3.0.0
+```
+
+## Subir a arXiv (si se consigue endorsement)
+
+arXiv requiere endorsement para primeras submissions en cs.CL/cs.LG.
+Opciones:
+
+- Pedir endorsement a un autor que ya haya publicado en esa categoría
+- Publicar primero en Zenodo (DOI valido) y academia.edu
+- Usar el DOI de Zenodo como referencia oficial mientras tanto
+
+### Preparar submission arXiv
+
+```bash
+# Crear zip con .tex + figuras
+zip arxiv_submission.zip pampar_v3_arxiv.tex
+```
+
+## Subir a academia.edu
+
+1. Login en academia.edu
+2. Upload paper → PDF compilado de `pampar_v3_arxiv.tex`
+3. Tags: code generation, brain-inspired AI, mixed selectivity, FiLM, curriculum learning
+4. Vincular DOI de Zenodo
+
+## Licencia
+
+**Business Source License 1.1 (BUSL-1.1)**
+
+- Uso no comercial (investigación, educación, experimentación): **libre**
+- Uso comercial en producción: requiere licencia comercial
+- **Change Date: 7 abril 2030** → se convierte a Apache 2.0 automáticamente
+- Contacto licencias comerciales: lucas.mella@outlook.com
diff --git a/paper/pampar_v3_arxiv.tex b/paper/pampar_v3_arxiv.tex
new file mode 100644
index 0000000000000000000000000000000000000000..51c431f3d7e8ca06d56be7b34cf50e976cbff268
--- /dev/null
+++ b/paper/pampar_v3_arxiv.tex
@@ -0,0 +1,986 @@
+% SPDX-License-Identifier: BUSL-1.1
+% Copyright (c) 2025-2026 Lucas Ricardo Mella Chillemi
+%
+% PAMPAr-Coder V3: Brain-Inspired 2D Stream Architecture with Mixed Selectivity
+% Prepared for arXiv submission (cs.CL / cs.LG)
+%
+\documentclass[11pt,a4paper]{article}
+
+% ============================================================================
+% PACKAGES
+% ============================================================================
+\usepackage[utf8]{inputenc}
+\usepackage[T1]{fontenc}
+\usepackage{amsmath,amssymb,amsfonts}
+\usepackage{graphicx}
+\usepackage{booktabs}
+\usepackage{hyperref}
+\usepackage{cleveref}
+\usepackage{algorithm}
+\usepackage{algorithmic}
+\usepackage{tikz}
+\usetikzlibrary{shapes,arrows,positioning,fit,backgrounds,calc}
+\usepackage[margin=1in]{geometry}
+\usepackage{natbib}
+\usepackage{xcolor}
+\usepackage{multirow}
+
+% ============================================================================
+% METADATA
+% ============================================================================
+\title{PAMPAr-Coder V3: A Brain-Inspired 2D Stream Architecture\\
+with Mixed Selectivity for Efficient Code Generation}
+
+\author{
+Lucas Ricardo Mella Chillemi\\
+Independent Researcher\\
+Buenos Aires, Argentina\\
+\texttt{lucas.mella@outlook.com}
+}
+
+\date{April 2026\\[1em]
+\small DOI: \href{https://doi.org/10.5281/zenodo.XXXXXXX}{10.5281/zenodo.XXXXXXX}
+}
+
+% ============================================================================
+% DOCUMENT
+% ============================================================================
+\begin{document}
+
+\maketitle
+
+% ============================================================================
+% ABSTRACT
+% ============================================================================
+\begin{abstract}
+We present PAMPAr-Coder V3, a compact code generation language model of 62.6M
+parameters designed for consumer GPUs with as little as 4GB VRAM. The
+architecture extends the PAMPAr lineage of brain-inspired models with two novel
+contributions: (1) a \textit{2D Stream} organization that arranges computation
+as four specialized cortical streams (Syntax, Semantics, Logic, Structural)
+across five depth levels, connected by bidirectional lateral gates analogous to
+white-matter fiber tracts; and (2) \textit{Mixed Selectivity} via Feature-wise
+Linear Modulation (FiLM), where a single shared Feed-Forward Network is
+dynamically re-read by context-dependent $\gamma/\beta$ modulators derived from
+a 63-dimensional context vector — reducing FFN parameters by $\sim$72\% per
+level compared to four independent networks. A lightweight Tálamo re-routing
+module at each depth level allows token-territory assignments to drift as
+context evolves, and a Zone-of-Proximal-Development (ZPD) curriculum
+(\textit{MotorCuriosidad}) adapts training difficulty in real time across 161
+topic categories and 3.2M lines of code data. After 55,000 training steps on
+a GTX~1650 (4.3~GB VRAM), V3 reaches a cross-entropy loss of $\sim$1.38 on the
+validation set and saturates 29 of 40 curriculum topics. We argue that
+horizontal stream specialization combined with vertical depth, lateral
+communication, and context-modulated parameter sharing constitutes a promising
+direction for efficient, interpretable code models.
+\end{abstract}
+
+\textbf{Keywords:} code generation, brain-inspired architecture, mixed
+selectivity, FiLM modulation, grouped-query attention, curriculum learning,
+parameter efficiency, ZPD
+
+% ============================================================================
+% 1. INTRODUCTION
+% ============================================================================
+\section{Introduction}
+\label{sec:introduction}
+
+Large language models (LLMs) for code—CodeLlama \citep{roziere2023code},
+StarCoder2 \citep{lozhkov2024starcoder2}, DeepSeek-Coder
+\citep{guo2024deepseek}—achieve state-of-the-art results at the cost of
+billions of parameters and tens of gigabytes of GPU memory, placing them out of
+reach for local, privacy-preserving, or resource-constrained deployments.
+Distillation to smaller models (1–3B) partially alleviates this, but even
+1.3B-parameter models require careful quantization to fit on a consumer 4GB
+GPU and still represent a $\sim$20$\times$ larger parameter budget than
+what we demonstrate here.
+
+At the same time, biological neural systems process language (and implicitly,
+structured logical sequences) with remarkable efficiency through functional
+specialization and modulatory dynamics: cortical areas maintain distinct
+computational identities while sharing representational substrates via
+inter-areal projections \citep{felleman1991distributed}, and individual neurons
+exhibit \emph{mixed selectivity} — responding to conjunctions of context,
+task, and content that would otherwise require an exponential number of
+specialized units \citep{rigotti2013importance}.
+
+PAMPAr-Coder V3 is motivated by these two observations. Its contributions are:
+
+\begin{enumerate}
+ \item \textbf{2D Stream Architecture}: computation organized as a
+ $N_\text{streams} \times N_\text{levels}$ grid, where each stream
+ specializes in a syntactic/semantic/logical/structural role across
+ five depth levels, with lateral gates at each level enabling controlled
+ inter-stream information exchange (\cref{sec:architecture}).
+
+ \item \textbf{Mixed Selectivity via FiLM}: a single shared FFN per level
+ modulated by four stream-specialized $(\gamma, \beta)$ pairs derived from
+ a 63-dimensional context descriptor — zone activations, territorial
+ activations, depth, confidence, and stream identity (\cref{sec:mixed}).
+
+ \item \textbf{Adaptive Tálamo Re-routing}: a lightweight per-level
+ module that updates token-territory allocations as stream states evolve,
+ allowing routing to ``drift'' from initial LLAVES assignments
+ (\cref{sec:talamo}).
+
+ \item \textbf{ZPD Curriculum (MotorCuriosidad)}: a curiosity-driven
+ scheduler that selects training batches from topics at the boundary of
+ competence, enabling stable learning across 161 heterogeneous topic
+ categories without manual weighting (\cref{sec:curriculum}).
+\end{enumerate}
+
+The result is a 62.6M-parameter model trainable end-to-end on a 4GB consumer
+GPU in a single day, reaching competitive code generation behavior while
+remaining interpretable at the routing level.
+
+% ============================================================================
+% 2. RELATED WORK
+% ============================================================================
+\section{Related Work}
+\label{sec:related}
+
+\subsection{Compact Code Language Models}
+
+PolyCoder \citep{xu2022systematic} demonstrated that small models ($\leq$2.7B)
+trained exclusively on code can outperform general-purpose LLMs on specific
+languages. CodeT5+ \citep{wang2023codet5plus} introduced encoder-decoder
+architectures for code with strong results at 220M parameters. Our model
+occupies a more extreme point in this space at 62.6M parameters, aimed at
+deployments where even 220M is expensive.
+
+\subsection{Parameter-Efficient Architectures}
+
+Grouped-Query Attention (GQA) \citep{ainslie2023gqa} reduces key-value cache
+memory by sharing KV heads across query groups; we use a 4:1 ratio (8Q:2KV)
+that cuts KV parameters significantly. Mixture-of-Experts (MoE)
+\citep{shazeer2017outrageously} achieves parameter efficiency through sparse
+activation; our stream architecture achieves a related specialization through
+hard territorial assignments rather than learned sparse routing. ALBERT
+\citep{lan2019albert} uses cross-layer parameter sharing; our Mixed
+Selectivity approach generalizes this: instead of sharing \emph{the same}
+FFN across layers, we share one FFN per level across streams and modulate it
+contextually.
+
+\subsection{Feature-wise Linear Modulation}
+
+FiLM \citep{perez2018film} conditions a neural network's internal features
+via element-wise affine transformations $\hat{h} = \gamma \odot h + \beta$
+where $\gamma, \beta$ are produced by an auxiliary network. It has been
+applied to visual question answering, game playing, and multi-task learning.
+To our knowledge, this is the first application of FiLM-style modulation to
+a language model's FFN sub-layers for stream specialization.
+
+\subsection{Mixed Selectivity in Neuroscience and AI}
+
+\citet{rigotti2013importance} showed that mixed selectivity — neurons
+responding to combinations of stimuli rather than single features — is
+critical for flexible, generalizable behavior in prefrontal cortex.
+\citet{elhage2022toy} (Anthropic) demonstrated that transformers can
+represent more concepts than dimensions through superposition, effectively
+implementing mixed selectivity. Our ContextModulator operationalizes this:
+the shared FFN encodes a ``dictionary'' of code transformations, and each
+stream selects and scales relevant entries through $\gamma/\beta$ modulation.
+
+\subsection{Curriculum and Adaptive Learning}
+
+Self-paced learning \citep{kumar2010self} orders training examples by
+difficulty. Power-law curricula \citep{bengio2009curriculum} smooth the
+transition from easy to hard. Our ZPD-based MotorCuriosidad is closest to
+competence-based progression \citep{florensa2017reverse}: a topic advances
+only when the model reaches a dominance threshold, preventing overfitting on
+mastered topics and catastrophic forgetting of partially learned ones.
+
+\subsection{PAMPAr Lineage}
+
+PAMPAr-o1 v9 \citep{mella2026pampar} introduced territorial computing for
+general language modeling with a hybrid LLAVES-attention routing system,
+achieving $\sim$45 perplexity on WikiText-103 with 14M parameters.
+PAMPAr-Coder V3 extends this lineage to code, scaling parameters $\times$4.5,
+introducing the FiLM modulatory pathway, replacing flat territorial blocks with
+the 2D stream-level grid, and targeting a specialized 48K code-biased
+vocabulary.
+
+% ============================================================================
+% 3. ARCHITECTURE
+% ============================================================================
+\section{Architecture Overview}
+\label{sec:architecture}
+
+PAMPAr-Coder V3 consists of five components:
+(i) a SentencePiece embedding layer (48K vocab, dim=640),
+(ii) a global Tálamo router producing initial territory assignments,
+(iii) a $4 \times 5$ stream-level grid of NivelProfundo blocks,
+(iv) per-level TalamoNivel re-routing modules,
+(v) a tied output projection.
+
+\Cref{fig:architecture} illustrates the overall structure.
+
+\begin{figure}[t]
+\centering
+\begin{tikzpicture}[
+ stream/.style={draw, rounded corners, minimum width=2.2cm,
+ minimum height=1.0cm, align=center, font=\small},
+ level_label/.style={font=\footnotesize\itshape, align=center},
+ talamo/.style={draw, rounded corners, fill=orange!20,
+ minimum width=9cm, minimum height=0.8cm,
+ align=center, font=\small},
+ lateral/.style={<->, dashed, thick, gray!70},
+ down/.style={->, thick, black!60},
+ syn/.style={fill=blue!12},
+ sem/.style={fill=green!12},
+ log/.style={fill=red!12},
+ str/.style={fill=purple!12},
+]
+
+% Global Talamo
+\node[talamo] (talamo) at (4.5, 9.8)
+ {\textbf{Tálamo Global} \quad LLAVES (80\%) + Attn (20\%)};
+
+% Column labels
+\foreach \name/\col/\style in {
+ Syntax/0/syn, Semantics/1/sem, Logic/2/log, Structural/3/str}
+{
+ \node[stream, \style, minimum height=0.7cm]
+ at (\col*2.3+0.5, 9.0) {\small\name};
+}
+
+% Grid of NivelProfundo blocks
+\foreach \lvl in {1,...,5} {
+ \pgfmathsetmacro\y{9.0 - \lvl*1.3}
+ % TalamoNivel re-routing (between levels)
+ \node[font=\tiny, gray] at (4.5, \y+0.75)
+ {\textit{TalamoNivel re-route}};
+ \foreach \col/\style in {0/syn, 1/sem, 2/log, 3/str} {
+ \node[stream, \style, minimum height=0.9cm]
+ at (\col*2.3+0.5, \y)
+ {\tiny Attn+FFN$^\dagger$\\[1pt]\tiny L\lvl};
+ }
+ % Lateral gates
+ \foreach \a/\b in {0/1, 1/2, 2/3} {
+ \pgfmathsetmacro\xa{\a*2.3+1.6}
+ \pgfmathsetmacro\xb{\b*2.3-0.2}
+ \draw[lateral] (\xa, \y) -- (\xb, \y);
+ }
+}
+
+% Arrows from Talamo to level 1
+\foreach \col in {0,...,3} {
+ \draw[down] (\col*2.3+0.5, 8.65) -- (\col*2.3+0.5, 7.5+0.05);
+}
+
+% Output
+\node[talamo, fill=gray!10, minimum width=9cm, minimum height=0.7cm]
+ at (4.5, 0.5) {\textbf{Output Projection} (tied weights)};
+
+% Vertical flow
+\node at (4.5, -0.3) {\small$\downarrow$ \textbf{logits}};
+\node at (4.5, 10.6) {\small\textbf{Input Tokens}};
+\draw[->, thick] (4.5, 10.4) -- (4.5, 10.15);
+
+% FiLM annotation
+\node[font=\tiny, align=right, text=blue!60] at (10.2, 6.5)
+ {$\dagger$ FFN: shared weights\\modulated via FiLM\\($\gamma/\beta$ from 63-dim\\context vector)};
+
+\end{tikzpicture}
+\caption{PAMPAr-Coder V3 architecture. Four cortical streams (Syntax,
+Semantics, Logic, Structural) each process tokens through 5 depth levels.
+At each level, lateral gates (dashed arrows) enable inter-stream communication.
+A global Tálamo produces initial territory assignments; lightweight
+TalamoNivel modules re-route at each depth. ${}^\dagger$The FFN weights
+are \emph{shared} across the 4 streams at each level and modulated by
+stream-specific $(\gamma, \beta)$ pairs derived from a 63-dim context vector
+(Mixed Selectivity / FiLM).}
+\label{fig:architecture}
+\end{figure}
+
+\paragraph{Configuration summary.}
+
+\begin{table}[h]
+\centering
+\small
+\begin{tabular}{ll}
+\toprule
+\textbf{Hyperparameter} & \textbf{Value} \\
+\midrule
+Vocabulary size & 48,000 (PAMPAr-48k SentencePiece BPE) \\
+Embedding dimension & 640 \\
+Number of streams & 4 (Syntax, Semantics, Logic, Structural) \\
+Number of levels & 5 \\
+Attention heads (Q/KV) & 8Q / 2KV (GQA, ratio 4:1) \\
+Head dimension & 80 \\
+FFN hidden dimension & $\lfloor 2/3 \times 640 \times 4.0 \rfloor \approx 1707$ \\
+Lateral bottleneck & 128 \\
+Modulator bottleneck & 128 \\
+Max sequence length & 4096 tokens \\
+Dropout & 0.1 \\
+Brodmann zones (LLAVES)& 52 \\
+Total parameters & 62.6M \\
+\bottomrule
+\end{tabular}
+\caption{Model configuration for PAMPAr-Coder V3.}
+\label{tab:config}
+\end{table}
+
+% ============================================================================
+% 4. TÁLAMO AND LLAVES ROUTING
+% ============================================================================
+\section{Tálamo and LLAVES Routing}
+\label{sec:talamo}
+
+\subsection{Global Tálamo}
+
+The global Tálamo maps input tokens to territorial activations using a hybrid
+rule-learned system. Each token is first classified into one of 52
+\emph{Brodmann zones} — code-specific functional categories inspired by the
+Brodmann cortical map — using the LLAVES (``keys'') rule set.
+
+LLAVES for code identifies:
+\begin{itemize}\setlength\itemsep{2pt}
+ \item \textbf{B01--B10}: Keywords (control flow, definitions, imports)
+ \item \textbf{B11--B20}: Data types (int, float, str, list, dict, ...)
+ \item \textbf{B21--B30}: Operators and punctuation
+ \item \textbf{B31--B40}: Identifiers and names
+ \item \textbf{B41--B50}: String and comment content
+ \item \textbf{B51--B52}: Numeric literals, whitespace/indent
+\end{itemize}
+
+Zone activations $z \in [0,1]^{52}$ are then aggregated into 4 territorial
+activations:
+
+\begin{equation}
+ a_t = \sigma\!\left(\sum_{i \in \mathcal{Z}_t} z_i\right), \quad
+ t \in \{\text{Syn}, \text{Sem}, \text{Log}, \text{Str}\}
+ \label{eq:territory_agg}
+\end{equation}
+
+where $\mathcal{Z}_t$ is the subset of zones assigned to territory $t$. The
+final routing blends LLAVES with a lightweight learned attention projection:
+
+\begin{equation}
+ \tilde{a}_t = \eta \cdot a_t^\text{LLAVES}
+ + (1-\eta) \cdot \sigma(W_t x)
+ \label{eq:hybrid_routing}
+\end{equation}
+
+with $\eta = 0.8$ (80\% rule-based, 20\% learned), providing interpretable
+routing while allowing adaptation to corpus-specific token patterns.
+
+\subsection{TalamoNivel Re-routing}
+
+A static initial assignment may be suboptimal deeper in the network.
+Consider the token \texttt{for}: at level 1 it is correctly classified as a
+control-flow keyword (Syntax stream), but at level 3, within a complex
+list comprehension, the semantic interpretation may shift substantially toward
+the Semantics stream.
+
+TalamoNivel re-routing addresses this with a lightweight per-level module:
+
+\begin{equation}
+ a_t^{(\ell)} = \alpha \cdot a_t^{(\ell-1)}
+ + (1-\alpha) \cdot \sigma(W_\ell x^{(\ell)})
+ \label{eq:talamo_nivel}
+\end{equation}
+
+where $x^{(\ell)}$ is the mean-pooled stream state at level $\ell$,
+$\alpha = 0.7$ controls inertia, and $W_\ell \in \mathbb{R}^{D \times 52}$
+is a single linear projection (33K parameters). This design ensures smooth,
+non-oscillating drift rather than abrupt routing changes.
+
+% ============================================================================
+% 5. MIXED SELECTIVITY (FiLM FFN)
+% ============================================================================
+\section{Mixed Selectivity via FiLM Modulation}
+\label{sec:mixed}
+
+\subsection{Motivation}
+
+A standard 4-stream, 5-level architecture would instantiate
+$4 \times 5 = 20$ independent FFN modules. With hidden dimension 1707,
+each SwiGLU FFN has $\sim$2.2M parameters, totaling $\sim$44M for FFNs alone
+— 70\% of the model budget. Mixed Selectivity offers an alternative inspired
+by the neuroscientific finding \citep{rigotti2013importance} that prefrontal
+neurons achieve behaviorally flexible representations not by specializing to
+single stimuli, but by responding to \emph{conjunctions} of context and
+content.
+
+\subsection{Architecture}
+
+At each level $\ell$, instead of four independent FFNs, we use:
+
+\begin{itemize}
+ \item One \textbf{SharedFFN} (SwiGLU, $\sim$2.2M params) that encodes
+ domain knowledge shared across all streams at this level.
+ \item Four \textbf{ContextModulators}, one per stream, each generating
+ a pair $(\gamma_s, \beta_s) \in \mathbb{R}^{D}$ from a
+ 63-dimensional context vector $c_s$.
+\end{itemize}
+
+The modulated output for stream $s$ at position $i$ is:
+
+\begin{equation}
+ \hat{h}_{s,i} = \gamma_s(c_{s,i}) \odot \text{FFN}(h_{s,i})
+ + \beta_s(c_{s,i})
+ \label{eq:film}
+\end{equation}
+
+where $\odot$ is element-wise multiplication and
+$(\gamma_s, \beta_s) = W_2^\top \text{SiLU}(W_1^\top c_{s,i})$
+with $W_1 \in \mathbb{R}^{63 \times 128}$, $W_2 \in \mathbb{R}^{128 \times 2D}$.
+
+\subsection{Context Vector Composition}
+
+The 63-dimensional context vector $c_s$ is composed of:
+
+\begin{equation}
+ c_s = \left[
+ z \in \mathbb{R}^{52},\;
+ a \in \mathbb{R}^{4},\;
+ \frac{\ell}{L} \in \mathbb{R},\;
+ \text{conf} \in \mathbb{R},\;
+ L \in \mathbb{R},\;
+ e_s \in \mathbb{R}^{4}
+ \right]
+ \label{eq:context_vector}
+\end{equation}
+
+where $z$ are LLAVES zone activations, $a$ are territorial activations,
+$\ell/L$ is normalized depth, $\text{conf}$ is the model's current
+confidence estimate, $L$ is the total number of levels (for
+depth-invariant normalization), and $e_s$ is the one-hot stream identity.
+This contextual richness enables the SharedFFN to be read as a different
+``functional program'' depending on where, what, and how confident the
+model is at each step.
+
+\subsection{Parameter Analysis}
+
+\begin{table}[h]
+\centering
+\small
+\begin{tabular}{lccc}
+\toprule
+\textbf{Design} & \textbf{FFN params/level} & \textbf{Total (5 levels)} & \textbf{Saving}\\
+\midrule
+Independent FFNs (4$\times$) & 8.8M & 44.0M & --- \\
+Shared + 4 Modulators & 2.4M & 12.0M & 73\% \\
+\midrule
+\textbf{V3 (Mixed Selectivity)} & \textbf{2.4M} & \textbf{12.0M} & \textbf{$-$32M} \\
+\bottomrule
+\end{tabular}
+\caption{FFN parameter budget comparison. Mixed Selectivity saves $\sim$32M
+parameters while maintaining per-stream functional specialization through
+contextual modulation.}
+\label{tab:ffn_budget}
+\end{table}
+
+% ============================================================================
+% 6. LATERAL GATES
+% ============================================================================
+\section{Lateral Gates (White Matter Fibers)}
+\label{sec:lateral}
+
+Cortical communication is not limited to vertical projections; white matter
+fiber tracts provide direct lateral connections between spatially distant areas
+\citep{catani2008diffusion}. In V3, each level $\ell$ includes a
+\textbf{LateralGate} module that allows each stream to receive a weighted
+contribution from the other three:
+
+\begin{equation}
+ h_s^{(\ell)} \mathrel{+}= \lambda_s \cdot f_s\!\left(
+ \bigoplus_{k \neq s} a_k^{(\ell)} \cdot h_k^{(\ell)}
+ \right)
+ \label{eq:lateral}
+\end{equation}
+
+where $\bigoplus$ denotes concatenation, $a_k^{(\ell)}$ is the territorial
+activation of stream $k$ (serving as a relevance weight), $f_s$ is a two-layer
+MLP (bottleneck=128), and $\lambda_s$ is a learnable scalar initialized to
+0.1 to prevent interference early in training. Streams with high territorial
+activation contribute more to their peers, implementing a form of
+``expertise broadcasting.''
+
+% ============================================================================
+% 7. CURRICULUM: MOTORCURIOSIDAD (ZPD)
+% ============================================================================
+\section{ZPD Curriculum: MotorCuriosidad}
+\label{sec:curriculum}
+
+Training on heterogeneous code corpora (assembly, Python, SQL, HTML, natural
+language, etc.) presents a curriculum design challenge: mastered topics waste
+forward passes; topics far beyond current competence produce uninformative
+gradients. The Zone of Proximal Development (ZPD) \citep{vygotsky1978mind}
+frames learning as productive only at the frontier of competence.
+
+\subsection{MotorCuriosidad Algorithm}
+
+The MotorCuriosidad assigns each of 161 topics a
+$(\text{nivel}, \text{dominancia})$ tuple. At each training step:
+
+\begin{enumerate}
+ \item For each topic $j$, compute a \emph{curiosity score}:
+ \begin{equation}
+ q_j = \text{nivel}_j + (1 - \text{dom}_j) \cdot w_\text{zpd}
+ \label{eq:curiosity}
+ \end{equation}
+ where $w_\text{zpd}$ weights the incompleteness term.
+ \item Sample a batch from the top-$K$ topics by curiosity score.
+ \item After the step, update $\text{dom}_j$ based on per-topic validation
+ loss: if loss has decreased below level threshold, $\text{dom}_j \mathrel{+}= \delta$.
+ \item When $\text{dom}_j \geq 1.0$, advance to $\text{nivel}_j + 1$ and
+ cap at level 5.
+\end{enumerate}
+
+Topics at their maximum level with $\text{dom} \approx 1.0$ are sampled only
+for \emph{retention} (periodic review), preventing forgetting.
+
+\subsection{Data Composition}
+
+The training corpus spans 161 topics in 8 categories:
+
+\begin{table}[h]
+\centering
+\small
+\begin{tabular}{lrr}
+\toprule
+\textbf{Category} & \textbf{Topics} & \textbf{Lines (approx.)}\\
+\midrule
+Code problems & 40 & 195K \\
+Distillation (GPT-4) & 30 & 800K \\
+Code exercises & 25 & 500K \\
+Textbook (Python) & 20 & 300K \\
+Instruction following & 25 & 400K \\
+Code evolution & 11 & 500K \\
+Bilingual (ES/EN) & 6 & 270K \\
+Mixed & 4 & 235K \\
+\midrule
+\textbf{Total} & \textbf{161} & \textbf{$\sim$3.2M}\\
+\bottomrule
+\end{tabular}
+\caption{Training corpus composition by category after data integration.}
+\label{tab:data}
+\end{table}
+
+% ============================================================================
+% 8. EARLY EXIT
+% ============================================================================
+\section{Adaptive Early Exit}
+\label{sec:early_exit}
+
+Not all tokens require full 5-level processing. Common keywords (\texttt{def},
+\texttt{return}, indentation tokens) can be predicted confidently after 2
+levels. V3 implements an adaptive early exit:
+
+\begin{enumerate}
+ \item After each level $\ell \geq \ell_\text{min}$ (default $\ell_\text{min}=2$),
+ compute a confidence estimate from the current stream states.
+ \item If $\text{conf} \geq \tau$ (default $\tau=0.90$), exit and project
+ to logits.
+ \item Otherwise, continue to the next level.
+ \item Hard focus: if fewer than 10\% of tokens fail the confidence
+ threshold, only process those tokens through remaining levels.
+\end{enumerate}
+
+This adaptive computation reduces effective FLOPs for easy tokens without
+changing the model's expressiveness for hard ones.
+
+% ============================================================================
+% 9. GROUPED QUERY ATTENTION
+% ============================================================================
+\section{Grouped Query Attention}
+\label{sec:gqa}
+
+Each of the $4 \times 5 = 20$ attention blocks uses Grouped Query Attention
+\citep{ainslie2023gqa} with 8 query heads and 2 key-value heads (ratio 4:1).
+With head dimension 80:
+
+\begin{itemize}
+ \item Q projection: $640 \to 8 \times 80 = 640$
+ \item K,V projections: $640 \to 2 \times 80 = 160$ each
+\end{itemize}
+
+This reduces KV cache size by 4$\times$ relative to multi-head attention
+(MHA) and decreases KV parameter count from 1.6M to 0.4M per attention block.
+The repetition mechanism \texttt{n\_rep}=4 expands GQA keys/values to the
+full head count during attention computation without storing duplicates.
+
+% ============================================================================
+% 10. EXPERIMENTS
+% ============================================================================
+\section{Experiments}
+\label{sec:experiments}
+
+\subsection{Experimental Setup}
+
+\textbf{Hardware:} NVIDIA GTX 1650 (4.3 GB VRAM), Intel Core processor,
+Windows 11. All training in FP16 with gradient checkpointing enabled.
+
+\textbf{Optimizer:} AdamW ($\beta_1=0.9$, $\beta_2=0.999$, weight decay
+$10^{-2}$), learning rate $3 \times 10^{-4}$ with cosine decay.
+
+\textbf{Batch size:} 4 sequences of 512 tokens per step (effective tokens per
+step: 2,048).
+
+\textbf{Training steps:} 55,000 (reported), corresponding to $\sim$113M
+tokens processed.
+
+\textbf{Tokenizer:} PAMPAr-48k, a SentencePiece BPE model trained on a
+bilingual (Spanish/English) code corpus of 48K token vocabulary, biased
+toward Python identifiers, operators, and common programming keywords.
+
+\subsection{Training Dynamics}
+
+\begin{figure}[h]
+\centering
+% Placeholder for training curve
+\fbox{\parbox{0.85\linewidth}{\centering\vspace{1.5cm}
+\textit{[Training curve: cross-entropy loss vs.\ training steps.\\
+loss declines from $\sim$6.5 (random init) to $\sim$1.38 at step 55K.\\
+100-step moving average shown.]}
+\vspace{1.5cm}}}
+\caption{Training loss curve for PAMPAr-Coder V3 on the ZPD-selected mixed
+code corpus. Cross-entropy loss (100-step moving average) reaches 1.38 after
+55,000 steps on a GTX~1650 GPU.}
+\label{fig:training_curve}
+\end{figure}
+
+Key milestones observed during training:
+
+\begin{itemize}
+ \item \textbf{Steps 0--5K}: Loss drops rapidly from random initialization
+ ($\sim$6.5) to $\sim$2.8 as the model learns token frequency statistics.
+ \item \textbf{Steps 5K--20K}: The model begins generalizing structural
+ patterns; loss reaches $\sim$2.0. MotorCuriosidad advances 15 topics to
+ level 2.
+ \item \textbf{Steps 20K--40K}: Loss enters the $1.6$--$1.8$ range;
+ lateral gate scales $\lambda_s$ grow from 0.1 to $\sim$0.3--0.5,
+ indicating active inter-stream information exchange.
+ \item \textbf{Steps 40K--55K}: Loss stabilizes around $1.38$; curriculum
+ reports 29/40 topics at dominance $\geq 0.9$ (level 1).
+\end{itemize}
+
+\subsection{Curriculum Progress}
+
+After 55,000 steps:
+
+\begin{itemize}
+ \item \textbf{Saturated topics} (dom $\geq 0.9$, advancing to level 2): 29/40
+ \item \textbf{Active frontier}: 9 topics at 0.5--0.89 dominance
+ \item \textbf{Untouched} (dom $< 0.1$): 2 highly specialized topics
+ (assembly syntax, formal logic proofs)
+ \item \textbf{Current curriculum level}: 1 (of 5)
+\end{itemize}
+
+This indicates the model is actively progressing and has not saturated any
+topic at its final level, suggesting continued gains with further training.
+
+\subsection{Parameter Efficiency Comparison}
+
+\begin{table}[h]
+\centering
+\begin{tabular}{lrrrr}
+\toprule
+\textbf{Model} & \textbf{Params} & \textbf{Context} & \textbf{Min VRAM} & \textbf{License} \\
+\midrule
+CodeLlama-7B & 7B & 16K & $\sim$14 GB & Llama 2 \\
+StarCoder2-3B & 3B & 16K & $\sim$8 GB & BigCode \\
+DeepSeek-Coder-1.3B & 1.3B & 16K & $\sim$4 GB* & DeepSeek \\
+CodeT5+-220M & 220M & 512 & $\sim$2 GB & Apache 2 \\
+\midrule
+\textbf{PAMPAr-Coder V3} & \textbf{62.6M} & \textbf{4096} & \textbf{4.3 GB†} & BUSL-1.1 \\
+\bottomrule
+\end{tabular}
+\caption{Parameter and VRAM efficiency comparison.
+*Requires INT4 quantization to fit in 4GB.
+†Trains end-to-end in FP16 with no quantization.}
+\label{tab:comparison}
+\end{table}
+
+PAMPAr-Coder V3 occupies a unique efficiency point: it trains and runs
+\emph{at full FP16 precision} on the same 4GB GPU that other models require
+4-bit quantization to merely run inference on.
+
+% ============================================================================
+% 11. DISCUSSION
+% ============================================================================
+\section{Discussion}
+\label{sec:discussion}
+
+\subsection{What the 2D Stream Grid Provides}
+
+The stream-level organization separates two orthogonal inductive biases:
+\emph{horizontal specialization} (different streams handle different code
+semantics) and \emph{vertical depth} (deeper levels refine representations).
+Standard transformers conflate both into a single sequence of identical blocks.
+The explicit separation allows each stream to develop a stable representational
+identity that is refined across levels rather than overwritten by global
+attention at each block.
+
+\subsection{Interpretability}
+
+The LLAVES zone activations make routing decisions inspectable: given any
+input token, one can trace which zone fired, which territory was activated,
+and — through TalamoNivel outputs — how the routing drifted across levels.
+This interpretability comes at no performance cost: the rule-based 80\%
+component is non-parametric, while learned components can refine edge cases.
+
+\subsection{Mixed Selectivity as Memory Compression}
+
+The FiLM modulation essentially implements a key-value memory retrieval:
+the shared FFN weights are the ``value store'', and the context vector is the
+``query''. The $(\gamma, \beta)$ affine transform selects and scales relevant
+stored features. This matches theoretical arguments for why superposition
+\citep{elhage2022toy} is beneficial: more concepts can be stored per parameter
+when they are addressed combinatorially rather than through dedicated circuits.
+
+\subsection{ZPD Curriculum Stability}
+
+The fresh-start bug identified and fixed during development —
+where loading a contaminated motor checkpoint caused the model to
+incorrectly assess topic dominance from a previous run —
+illustrates an important invariant: curriculum state must be reset completely
+with model weights, never inherited across architecture changes. The fix
+ensures that when no checkpoint exists, MotorCuriosidad initializes at
+nivel=1, dom=0 for all topics.
+
+\subsection{Limitations}
+
+\begin{itemize}
+ \item \textbf{Benchmark evaluation}: No HumanEval or MBPP scores are
+ reported in this release; these are planned for a subsequent version once
+ training completes full curriculum progression to level 5.
+ \item \textbf{Scale}: 62.6M parameters is competitive for the 4GB GPU
+ niche but likely insufficient for state-of-the-art code generation on
+ hard algorithmic problems without retrieval augmentation.
+ \item \textbf{English bias}: While the tokenizer is bilingual
+ (Spanish/English), the code corpus is predominantly English-syntax code
+ (Python, C, JavaScript); Spanish natural-language docstrings are included
+ but do not constitute the majority.
+ \item \textbf{Training incomplete}: Reported results are from 55K steps
+ at curriculum level 1; the intended regime is 5 levels $\times$ full
+ dataset saturation ($\sim$500K total steps).
+\end{itemize}
+
+\subsection{Future Work}
+
+\begin{enumerate}
+ \item Full HumanEval / MBPP / SWE-bench evaluation after curriculum
+ completion.
+ \item Scaling to 250M parameters using the same 2D stream architecture
+ (``PAMPAr-Coder V3-Large'').
+ \item Retrieval-augmented generation using the zone activations as
+ retrieval keys for code snippet lookup.
+ \item Automatic LLAVES rule discovery from training data via
+ gradient-based zone attribution.
+ \item Quantized (INT8/INT4) deployment pathway for inference on 2GB GPUs.
+\end{enumerate}
+
+% ============================================================================
+% 12. CONCLUSION
+% ============================================================================
+\section{Conclusion}
+\label{sec:conclusion}
+
+We presented PAMPAr-Coder V3, a 62.6M-parameter code language model with a
+brain-inspired 2D stream architecture combining bilateral cortical
+specialization (four streams), hierarchical depth (five levels), lateral
+inter-stream communication (white matter gates), adaptive routing (TalamoNivel),
+FiLM-based mixed selectivity (shared FFN + context modulators), and a
+ZPD-driven curriculum scheduler. The model trains end-to-end at FP16
+precision on a consumer 4GB GPU — a regime where larger code models require
+quantization just for inference. Preliminary training results (55K steps,
+loss $\sim$1.38, 29/40 topics saturated at level 1) indicate active learning
+progress across the full 161-topic curriculum. We believe the 2D stream +
+mixed selectivity paradigm offers a generalizable template for compact,
+interpretable, and efficient language models beyond the code domain.
+
+% ============================================================================
+% ACKNOWLEDGMENTS
+% ============================================================================
+\section*{Acknowledgments}
+
+This work was conducted independently without institutional funding.
+The author thanks the open-source communities behind PyTorch, SentencePiece,
+and the Hugging Face ecosystem.
+
+% ============================================================================
+% REPRODUCIBILITY
+% ============================================================================
+\section*{Reproducibility Statement}
+
+Training configuration and architecture code are available in the project
+repository. The PAMPAr-48k tokenizer is included in the repository.
+Training can be reproduced with:
+
+\begin{verbatim}
+python scripts/train_v3.py \
+ --batch-size 4 --seq-len 512 \
+ --lr 3e-4 --guardar-cada 500 \
+ --device auto
+\end{verbatim}
+
+Hardware requirements: NVIDIA GPU with 4GB+ VRAM. The MotorCuriosidad
+fresh-start invariant requires that no pre-existing
+\texttt{checkpoints/motor\_v3.json} from a previous run be present when
+starting a new training experiment.
+
+% ============================================================================
+% REFERENCES
+% ============================================================================
+\bibliographystyle{plainnat}
+\begin{thebibliography}{30}
+
+\bibitem[Ainslie et al.(2023)]{ainslie2023gqa}
+Ainslie, J., Lee-Thorp, J., de~Jong, M., et al. (2023).
+\newblock {GQA}: Training generalized multi-query transformer models from
+multi-head checkpoints.
+\newblock In \emph{EMNLP 2023}.
+
+\bibitem[Bengio et al.(2009)]{bengio2009curriculum}
+Bengio, Y., Louradour, J., Collobert, R., \& Weston, J. (2009).
+\newblock Curriculum learning.
+\newblock In \emph{ICML 2009}.
+
+\bibitem[Catani \& Thiebaut de~Schotten(2008)]{catani2008diffusion}
+Catani, M. \& Thiebaut de~Schotten, M. (2008).
+\newblock A diffusion tensor imaging tractography atlas for virtual in vivo
+dissections.
+\newblock \emph{Cortex}, 44(8), 1105--1132.
+
+\bibitem[Elhage et al.(2022)]{elhage2022toy}
+Elhage, N., Hume, T., Gray, C., et al. (2022).
+\newblock Toy models of superposition.
+\newblock \emph{Transformer Circuits Thread}.
+\newblock \url{https://transformer-circuits.pub/2022/toy\_model/index.html}
+
+\bibitem[Felleman \& Van~Essen(1991)]{felleman1991distributed}
+Felleman, D. J. \& Van~Essen, D. C. (1991).
+\newblock Distributed hierarchical processing in the primate cerebral cortex.
+\newblock \emph{Cerebral Cortex}, 1(1), 1--47.
+
+\bibitem[Florensa et al.(2017)]{florensa2017reverse}
+Florensa, C., Held, D., Wulfmeier, M., Zhang, M., \& Abbeel, P. (2017).
+\newblock Reverse curriculum generation for reinforcement learning.
+\newblock In \emph{CoRL 2017}.
+
+\bibitem[Guo et al.(2024)]{guo2024deepseek}
+Guo, D., Zhu, Q., Yang, D., et al. (2024).
+\newblock {DeepSeek-Coder}: When the large language model meets programming.
+\newblock \emph{arXiv preprint arXiv:2401.14196}.
+
+\bibitem[Kumar et al.(2010)]{kumar2010self}
+Kumar, M. P., Packer, B., \& Koller, D. (2010).
+\newblock Self-paced learning for latent variable models.
+\newblock In \emph{NeurIPS 2010}.
+
+\bibitem[Lan et al.(2019)]{lan2019albert}
+Lan, Z., Chen, M., Goodman, S., Gimpel, K., Sharma, P., \& Soricut, R. (2019).
+\newblock {ALBERT}: A lite {BERT} for self-supervised learning of language
+representations.
+\newblock In \emph{ICLR 2020}.
+
+\bibitem[Lozhkov et al.(2024)]{lozhkov2024starcoder2}
+Lozhkov, A., Li, R., Allal, L. B., et al. (2024).
+\newblock {StarCoder2} and the {Stack v2}: The next generation.
+\newblock \emph{arXiv preprint arXiv:2402.19173}.
+
+\bibitem[Mella~Chillemi(2026)]{mella2026pampar}
+Mella~Chillemi, L. R. (2026).
+\newblock {PAMPAr-o1} v9: A brain-inspired territorial architecture for
+language modeling with explicit rule-based routing.
+\newblock \emph{arXiv preprint}. DOI: 10.5281/zenodo.18315642.
+
+\bibitem[Perez et al.(2018)]{perez2018film}
+Perez, E., Strub, F., de~Vries, H., Dumoulin, V., \& Courville, A. (2018).
+\newblock {FiLM}: Visual reasoning with a general conditioning layer.
+\newblock In \emph{AAAI 2018}.
+
+\bibitem[Rigotti et al.(2013)]{rigotti2013importance}
+Rigotti, M., Barak, O., Warden, M. R., et al. (2013).
+\newblock The importance of mixed selectivity in complex cognitive tasks.
+\newblock \emph{Nature}, 497(7451), 585--590.
+
+\bibitem[Rozi\`{e}re et al.(2023)]{roziere2023code}
+Rozi\`{e}re, B., Gehring, J., Gloeckle, F., et al. (2023).
+\newblock {Code Llama}: Open foundation models for code.
+\newblock \emph{arXiv preprint arXiv:2308.12950}.
+
+\bibitem[Shazeer et al.(2017)]{shazeer2017outrageously}
+Shazeer, N., Mirhoseini, A., Maziarz, K., et al. (2017).
+\newblock Outrageously large neural networks: The sparsely-gated
+mixture-of-experts layer.
+\newblock In \emph{ICLR 2017}.
+
+\bibitem[Vygotsky(1978)]{vygotsky1978mind}
+Vygotsky, L. S. (1978).
+\newblock \emph{Mind in Society: The Development of Higher Psychological
+Processes}.
+\newblock Harvard University Press.
+
+\bibitem[Wang et al.(2023)]{wang2023codet5plus}
+Wang, Y., Le, H., Gotmare, A. D., Bui, N. D. Q., Li, J., \& Hoi, S. C. H.
+(2023).
+\newblock {CodeT5+}: Open code large language models for code understanding
+and generation.
+\newblock In \emph{EMNLP 2023}.
+
+\bibitem[Xu et al.(2022)]{xu2022systematic}
+Xu, F. F., Alon, U., Neubig, G., \& Hellendoorn, V. J. (2022).
+\newblock A systematic evaluation of large language models of code.
+\newblock In \emph{MAPS 2022}.
+
+\end{thebibliography}
+
+% ============================================================================
+% APPENDIX
+% ============================================================================
+\appendix
+
+\section{LLAVES Code Zone Taxonomy}
+\label{app:llaves}
+
+\begin{table}[h]
+\centering
+\small
+\begin{tabular}{lll}
+\toprule
+\textbf{Zone range} & \textbf{Category} & \textbf{Examples} \\
+\midrule
+B01--B06 & Control flow & \texttt{if, else, for, while, break, continue} \\
+B07--B10 & Definitions & \texttt{def, class, lambda, return} \\
+B11--B14 & Imports & \texttt{import, from, as, \#} \\
+B15--B20 & Data types & \texttt{int, str, list, dict, tuple, bool} \\
+B21--B25 & Arithmetic ops & \texttt{+, -, *, /, //, \%, **} \\
+B26--B30 & Comparison/logic& \texttt{==, !=, <, >, and, or, not} \\
+B31--B35 & Identifiers & variables, function names, class names \\
+B36--B40 & Attribute access & \texttt{.}, method calls, subscripts \\
+B41--B45 & String literals & f-strings, raw strings, byte strings \\
+B46--B48 & Comments & inline comments, docstrings \\
+B49--B50 & Async/await & \texttt{async, await, yield} \\
+B51 & Numeric literals & integers, floats, hex, binary \\
+B52 & Indentation & whitespace, newlines, continuation \\
+\bottomrule
+\end{tabular}
+\caption{PAMPAr-Coder V3 LLAVES zone taxonomy (52 Brodmann-inspired zones).}
+\label{tab:llaves}
+\end{table}
+
+\section{Stream Territory Mapping}
+\label{app:streams}
+
+\begin{table}[h]
+\centering
+\small
+\begin{tabular}{llll}
+\toprule
+\textbf{Stream} & \textbf{Primary zones} & \textbf{Cortical analog} & \textbf{Specialization} \\
+\midrule
+Syntax & B01--B10, B21--B30, B52 & Broca's area & Structure, control flow \\
+Semantics & B31--B40, B41--B48 & Wernicke's area & Names, meaning, context \\
+Logic & B26--B30, B07, B49--B50 & Prefrontal ctx & Reasoning, conditionals \\
+Structural & B15--B20, B21--B25, B51 & Parietal ctx & Types, arithmetic, data \\
+\bottomrule
+\end{tabular}
+\caption{Stream-to-zone territory mapping and cortical analogy.}
+\label{tab:streams}
+\end{table}
+
+\end{document}
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..b328fec975db4814ea4376a2c058525d4c2d4fb2
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,6 @@
+torch>=2.0.0
+sentencepiece>=0.1.99
+datasets>=2.14.0
+tokenizers>=0.15.0
+matplotlib>=3.7.0
+huggingface_hub>=0.19.0
diff --git a/scan_neuro.html b/scan_neuro.html
new file mode 100644
index 0000000000000000000000000000000000000000..a95eef102bf7742eac735d7b9c3d9c410e389593
--- /dev/null
+++ b/scan_neuro.html
@@ -0,0 +1,55 @@
+
+
+
+
+PAMPAr Brain Scanner
+
+
+
+🧠 PAMPAr Brain Scanner
+def fibonacci(n):
+
+Métricas de Salud
+
+
+Tálamo: Routing Inicial
+
+Token SINTAXIS SEMANTICA LOGICO ESTRUCTURAL Actual Esperado OK
+▁def 0.607 0.603 0.539 0.537 SINTAXIS SINTAXIS ✓ ▁f 0.699 0.693 0.575 0.571 SINTAXIS SEMANTICA ✓ ib 0.730 0.723 0.589 0.583 SINTAXIS SEMANTICA ✓ on 0.763 0.756 0.604 0.597 SINTAXIS SEMANTICA ✓ ac 0.815 0.808 0.631 0.622 SINTAXIS SEMANTICA ✓ ci 0.839 0.832 0.645 0.636 SINTAXIS SEMANTICA ✓ ( 0.873 0.865 0.667 0.657 SINTAXIS SINTAXIS ✓ n 0.900 0.893 0.688 0.677 SINTAXIS SEMANTICA ✓ ): 0.921 0.914 0.708 0.695 SINTAXIS SEMANTICA ✓
+
+
+Evolución por Nivel
+
+Token N0 N1 N2 N3 N4 N5
+▁def 0.61 0.64 0.67 0.66 0.67 0.68 ▁f 0.70 0.70 0.69 0.70 0.70 0.71 ib 0.73 0.72 0.70 0.71 0.71 0.71 on 0.76 0.75 0.73 0.73 0.73 0.72 ac 0.82 0.78 0.76 0.75 0.75 0.74 ci 0.84 0.80 0.78 0.76 0.75 0.74 ( 0.87 0.83 0.73 0.68 0.68 0.68 n 0.90 0.84 0.80 0.78 0.76 0.75 ): 0.92 0.86 0.81 0.79 0.77 0.76
+
+
+Early Exit
+
+Umbral: 90% — Mín 2 niveles
+
+
+
\ No newline at end of file
diff --git a/scripts/aprender_solo.py b/scripts/aprender_solo.py
new file mode 100644
index 0000000000000000000000000000000000000000..dd41b636a56f1fe7e06d0928700e8d6b8f175c19
--- /dev/null
+++ b/scripts/aprender_solo.py
@@ -0,0 +1,776 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+PAMPAr — Aprendizaje Autónomo Local (v3).
+
+El modelo hace un "viaje intelectual" por la biblioteca de conocimiento,
+guiado por su propia curiosidad — aprende lo que NO sabe, consolida lo
+que SÍ sabe, y crece progresivamente sin supervisión humana.
+
+Corre en tu computadora local:
+ - CPU: ~2-3 tok/s (usable, lento pero funciona)
+ - GPU 4GB: ~50-100 tok/s (cómodo)
+ - GPU mayor: más rápido
+
+El modelo NUNCA olvida lo aprendido gracias a:
+ 1. MemoriaJerarquica: replay de momentos clave (L0/L1/L2)
+ 2. Gradient episódico: re-entrenamiento periódico en temas dominados
+ 3. Consolidación periódica: protección de patrones aprendidos
+
+Uso:
+ python scripts/aprender_solo.py --checkpoint checkpoints/v3_ghidra_v9.pt
+
+ # Con más control:
+ python scripts/aprender_solo.py \\
+ --checkpoint checkpoints/v3_ghidra_v9.pt \\
+ --biblioteca biblioteca/ \\
+ --estado curiosidad_estado.json \\
+ --lr 5e-5 \\
+ --pasos-por-tema 100 \\
+ --replay-cada 50 \\
+ --consolidar-cada 300 \\
+ --guardar-cada 500
+"""
+
+import argparse
+import json
+import sys
+import time
+from contextlib import nullcontext
+from pathlib import Path
+from typing import Optional
+
+import sentencepiece as spm
+import torch
+import torch.nn.functional as F
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from pampar.coder.v3 import (
+ PRESET_V3,
+ PRESET_V3_LARGE,
+ PRESET_V3_SMALL,
+ ConfigV3,
+ PamparV3,
+)
+from pampar.training import LectorBiblioteca, MotorCuriosidad
+from pampar.training.memoria_jerarquica import MemoriaJerarquica
+
+# =============================================================================
+# COLORES PARA LA TERMINAL (hace lindo el log del viaje)
+# =============================================================================
+
+
+class C:
+ AZUL = "\033[94m"
+ VERDE = "\033[92m"
+ AMARILLO = "\033[93m"
+ ROJO = "\033[91m"
+ GRIS = "\033[90m"
+ BOLD = "\033[1m"
+ RESET = "\033[0m"
+
+
+def log(nivel: str, msg: str) -> None:
+ ts = time.strftime("%H:%M:%S")
+ colores = {
+ "INFO": C.AZUL,
+ "OK": C.VERDE,
+ "WARN": C.AMARILLO,
+ "ERROR": C.ROJO,
+ "DEBUG": C.GRIS,
+ "LIBRO": C.BOLD + C.AZUL,
+ "NIVEL": C.BOLD + C.VERDE,
+ }
+ color = colores.get(nivel, "")
+ print(f"{C.GRIS}[{ts}]{C.RESET} {color}[{nivel}]{C.RESET} {msg}")
+
+
+# =============================================================================
+# LOOP PRINCIPAL DE APRENDIZAJE AUTÓNOMO
+# =============================================================================
+
+
+class ViajeIntelectual:
+ """
+ Loop de aprendizaje autónomo de PAMPAr.
+
+ El modelo "estudia" tema por tema, guiado por curiosidad,
+ como un estudiante autodidacta con acceso ilimitado a una biblioteca.
+
+ Fases de cada iteración:
+ 1. ELEGIR — MotorCuriosidad decide qué estudiar
+ 2. LEER — Cargar un batch del tema elegido
+ 3. ESTUDIAR — Gradient step sobre el batch
+ 4. MEDIR — Loss sin gradiente (¿cuánto aprendió?)
+ 5. FEEDBACK — MemoriaJerarquica procesa el batch
+ 6. REPLAY — Cada N pasos, repasar lo aprendido antes
+ 7. CONSOLIDAR — Cada M pasos, L2 → pesos del modelo
+ 8. GUARDAR — Checkpoint + estado del motor de curiosidad
+ """
+
+ def __init__(
+ self,
+ modelo: PamparV3,
+ optimizer: torch.optim.Optimizer,
+ memoria: MemoriaJerarquica,
+ motor: MotorCuriosidad,
+ biblioteca: LectorBiblioteca,
+ indice: dict,
+ device: torch.device,
+ # Hiperparámetros
+ pasos_por_tema: int = 50,
+ replay_cada: int = 50,
+ consolidar_cada: int = 300,
+ guardar_cada: int = 500,
+ ruta_checkpoint: Optional[Path] = None,
+ ruta_estado_motor: Optional[Path] = None,
+ ):
+ self.modelo = modelo
+ self.optimizer = optimizer
+ self.memoria = memoria
+ self.motor = motor
+ self.biblioteca = biblioteca
+ self.indice = indice
+ self.device = device
+
+ self.pasos_por_tema = pasos_por_tema
+ self.replay_cada = replay_cada
+ self.consolidar_cada = consolidar_cada
+ self.guardar_cada = guardar_cada
+ self.ruta_checkpoint = ruta_checkpoint
+ self.ruta_estado_motor = ruta_estado_motor
+ self.teacher: Optional[PamparV3] = None # Se asigna desde fuera
+ self.alpha_distil: float = 0.3 # Peso KL vs CE
+ self.temp_distil: float = 4.0 # Temperatura de destilación
+
+ self.paso_global = 0
+ self.inicio = time.time()
+
+ # Registrar todos los temas de la biblioteca en el motor
+ n = self.motor.registrar_temas_desde_indice(indice)
+ log("INFO", f"Biblioteca cargada: {n} temas nuevos registrados")
+
+ def _tema_a_archivo(self, nombre_tema: str) -> Optional[str]:
+ """Encuentra la ruta del archivo de un tema en el índice."""
+ for categoria, temas in self.indice.items():
+ if not isinstance(temas, list):
+ continue # Ignorar meta-keys como "version", "descripcion"
+ for tema in temas:
+ if tema["nombre"] == nombre_tema:
+ return tema["archivo"]
+ return None
+
+ def _distillation_loss(
+ self,
+ student_logits: torch.Tensor,
+ teacher_logits: torch.Tensor,
+ ) -> torch.Tensor:
+ """KL divergence entre student y teacher con temperatura.
+
+ loss_kl = T² × KL(softmax(S/T) ‖ softmax(T_teacher/T))
+ Escalado por T² para que los gradientes tengan la misma
+ magnitud independientemente de la temperatura.
+ """
+ T = self.temp_distil
+ s = F.log_softmax(student_logits / T, dim=-1)
+ t = F.softmax(teacher_logits / T, dim=-1)
+ return F.kl_div(s, t, reduction="batchmean") * (T**2)
+
+ def _paso_entrenamiento(self, tokens: torch.Tensor) -> dict:
+ """Un paso de gradiente sobre un batch de tokens.
+
+ Si hay teacher cargado, combina:
+ loss = (1 - α) * cross_entropy + α * KL_divergence(student, teacher)
+ """
+ self.modelo.train()
+ self.optimizer.zero_grad()
+
+ input_ids = tokens[:, :-1]
+ targets = tokens[:, 1:]
+
+ logits, _loss_model, info = self.modelo(input_ids, targets=targets)
+
+ B, L, V = logits.shape
+ loss_ce = F.cross_entropy(
+ logits.reshape(B * L, V),
+ targets.reshape(B * L),
+ ignore_index=0,
+ )
+
+ if self.teacher is not None:
+ with torch.no_grad():
+ t_logits, _, _ = self.teacher(input_ids)
+ loss_kl = self._distillation_loss(
+ logits.reshape(B * L, V),
+ t_logits.reshape(B * L, V),
+ )
+ loss = (1.0 - self.alpha_distil) * loss_ce + self.alpha_distil * loss_kl
+ else:
+ loss = loss_ce
+
+ loss.backward()
+ torch.nn.utils.clip_grad_norm_(self.modelo.parameters(), 1.0)
+ self.optimizer.step()
+
+ terr_acts = info.get("terr_acts") if isinstance(info, dict) else None
+ return {
+ "loss": loss_ce.item(), # Reportar CE puro para comparabilidad
+ "loss_total": loss.item(),
+ "terr_acts": terr_acts,
+ }
+
+ def _paso_replay(self) -> Optional[float]:
+ """Replay de MemoriaJerarquica (repasar lo aprendido antes)."""
+ batch = self.memoria.get_replay_batch(strategy="hardest")
+ if batch is None:
+ return None
+
+ self.modelo.train()
+ self.optimizer.zero_grad()
+
+ tokens = batch.to(self.device)
+ if tokens.shape[1] < 2:
+ return None
+
+ input_ids = tokens[:, :-1]
+ targets = tokens[:, 1:]
+
+ logits, _, _ = self.modelo(input_ids, targets=targets)
+ B, L, V = logits.shape
+ loss = F.cross_entropy(
+ logits.reshape(B * L, V),
+ targets.reshape(B * L),
+ ignore_index=0,
+ )
+
+ # Replay con peso reducido (no borrar memorias nuevas)
+ (loss * 0.15).backward()
+ torch.nn.utils.clip_grad_norm_(self.modelo.parameters(), 0.5)
+ self.optimizer.step()
+
+ return loss.item()
+
+ def _guardar(self) -> None:
+ """Guarda checkpoint del modelo y estado del motor de curiosidad."""
+ if self.ruta_checkpoint:
+ torch.save(
+ {
+ "modelo": self.modelo.state_dict(),
+ "optimizer": self.optimizer.state_dict(),
+ "paso_global": self.paso_global,
+ },
+ self.ruta_checkpoint,
+ )
+ # También guardar estado de memoria
+ ruta_mem = self.ruta_checkpoint.with_suffix(".memoria.json")
+ self.memoria.guardar(str(ruta_mem))
+
+ if self.ruta_estado_motor:
+ self.motor.guardar(self.ruta_estado_motor)
+
+ def _banner_progreso(self) -> None:
+ """Imprime resumen del viaje intelectual."""
+ r = self.motor.resumen()
+ elapsed = (time.time() - self.inicio) / 3600
+ tops = r["tops_curiosidad"]
+
+ print(f"\n{C.BOLD}{'═' * 60}{C.RESET}")
+ print(f"{C.BOLD} VIAJE INTELECTUAL — Paso {self.paso_global:,}{C.RESET}")
+ print(f"{'═' * 60}")
+ print(f" Nivel actual: {C.BOLD}{r['nivel_actual']}/6{C.RESET}")
+ print(
+ f" Temas dominados: {C.VERDE}{r['temas_dominados']}/{r['temas_total']}{C.RESET} "
+ f"({r['porcentaje_dominio']:.0f}%)"
+ )
+ print(f" Loss global: {r['loss_promedio_global']:.3f}")
+ print(f" Tiempo activo: {elapsed:.1f}h")
+ print(f" Próximos temas de mayor curiosidad:")
+ for nombre, score in tops:
+ perfil = self.motor.temas.get(nombre)
+ estado = "✓" if perfil and perfil.dominado else "→"
+ print(f" {estado} {nombre:<30} curiosidad={score:.3f}")
+ print(f"{'═' * 60}\n")
+
+ # -------------------------------------------------------------------------
+ # LOOP PRINCIPAL
+ # -------------------------------------------------------------------------
+
+ def estudiar(self, max_pasos: Optional[int] = None) -> None:
+ """
+ Inicia el viaje intelectual autónomo.
+
+ Args:
+ max_pasos: Número máximo de pasos (None = infinito, hasta Ctrl+C).
+ """
+ log("LIBRO", "Iniciando viaje intelectual autónomo...")
+ if self.teacher is not None:
+ log(
+ "OK",
+ f"Destilación activa: α={self.alpha_distil} T={self.temp_distil} → aprendiendo del teacher",
+ )
+ log("INFO", f"Device: {self.device} | Temas: {len(self.motor.temas)}")
+
+ try:
+ while True:
+ if max_pasos and self.paso_global >= max_pasos:
+ break
+
+ # ── 1. ELEGIR TEMA ────────────────────────────────────────────
+ nombre_tema = self.motor.siguiente_tema()
+ if nombre_tema is None:
+ log("WARN", "No hay temas disponibles. Esperando...")
+ time.sleep(5)
+ continue
+
+ archivo = self._tema_a_archivo(nombre_tema)
+ if archivo is None:
+ continue
+
+ perfil = self.motor.temas[nombre_tema]
+ log(
+ "LIBRO",
+ f"Estudiando: '{nombre_tema}' | "
+ f"nivel={perfil.nivel_dificultad} | "
+ f"loss_prev={perfil.loss_media:.2f} | "
+ f"sesiones={perfil.n_sesiones}",
+ )
+
+ # ── 2-4. LEER → ESTUDIAR → MEDIR ─────────────────────────────
+ losses_sesion = []
+
+ for paso_local in range(self.pasos_por_tema):
+ tokens = self.biblioteca.obtener_batch(archivo, self.device)
+
+ if tokens is None:
+ # Archivo no existe aún — medir con loss alta ficticia
+ log("DEBUG", f" Sin datos para '{nombre_tema}' aún.")
+ losses_sesion.append(4.0)
+ break
+
+ # Paso de entrenamiento
+ resultado = self._paso_entrenamiento(tokens)
+ loss = resultado["loss"]
+ losses_sesion.append(loss)
+ self.paso_global += 1
+
+ # ── 5. FEEDBACK A MEMORIA ─────────────────────────────────
+ with torch.no_grad():
+ per_token_loss = (
+ F.cross_entropy(
+ resultado.get("logits_detach", torch.zeros(1)),
+ tokens[:, 1:].reshape(-1),
+ ignore_index=0,
+ reduction="none",
+ ).reshape(tokens.shape[0], -1)
+ if False
+ else None
+ )
+
+ if resultado.get("terr_acts") is not None:
+ with torch.no_grad():
+ # Loss por token aproximada
+ self.modelo.eval()
+ inp = tokens[:, :-1].to(self.device)
+ tgt = tokens[:, 1:].to(self.device)
+ lg, _loss_eval, info2 = self.modelo(inp)
+ B2, L2, V2 = lg.shape
+ ptl = F.cross_entropy(
+ lg.reshape(B2 * L2, V2),
+ tgt.reshape(B2 * L2),
+ ignore_index=0,
+ reduction="none",
+ ).reshape(B2, L2)
+ # Pad primera columna
+ pad = torch.zeros(B2, 1, device=self.device)
+ ptl_padded = torch.cat([pad, ptl], dim=1)
+ terr_acts2 = (
+ info2.get("terr_acts")
+ if isinstance(info2, dict)
+ else None
+ )
+ self.memoria.procesar_batch(tokens, ptl_padded, terr_acts2)
+ self.modelo.train()
+
+ # ── 6. REPLAY ─────────────────────────────────────────────
+ if self.paso_global % self.replay_cada == 0:
+ rl = self._paso_replay()
+ if rl is not None:
+ log("DEBUG", f" [replay] loss={rl:.3f}")
+
+ # ── 7. CONSOLIDAR ─────────────────────────────────────────
+ if self.paso_global % self.consolidar_cada == 0:
+ log("INFO", " [consolidar] Transfiriendo L2 → pesos...")
+ self.memoria.consolidar(self.modelo)
+
+ # ── 8. GUARDAR ────────────────────────────────────────────
+ if self.paso_global % self.guardar_cada == 0:
+ self._guardar()
+ log("OK", f" Checkpoint guardado. Paso {self.paso_global:,}")
+
+ # Banner periódico
+ if self.paso_global % (self.guardar_cada * 2) == 0:
+ self._banner_progreso()
+
+ # ── FEEDBACK POST-SESIÓN ──────────────────────────────────────
+ if losses_sesion:
+ loss_media_sesion = sum(losses_sesion) / len(losses_sesion)
+ info_fb = self.motor.retroalimentar(nombre_tema, loss_media_sesion)
+
+ if info_fb.get("recien_dominado"):
+ log(
+ "NIVEL",
+ f"¡'{nombre_tema}' DOMINADO! "
+ f"loss={loss_media_sesion:.3f} | "
+ f"nivel_actual={info_fb['nivel_actual']}",
+ )
+ elif info_fb.get("mejora", 0) > 0.1:
+ log(
+ "OK",
+ f" Mejora en '{nombre_tema}': "
+ f"{info_fb['loss_anterior']:.3f} → {loss_media_sesion:.3f}",
+ )
+
+ except KeyboardInterrupt:
+ log("INFO", "\nViaje interrumpido por el usuario. Guardando estado...")
+ self._guardar()
+ self._banner_progreso()
+ log("OK", "Estado guardado. Hasta la próxima sesión de estudio.")
+
+
+# =============================================================================
+# MAIN
+# =============================================================================
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="PAMPAr — Aprendizaje autónomo local guiado por curiosidad"
+ )
+ parser.add_argument(
+ "--checkpoint",
+ type=Path,
+ required=True,
+ help="Ruta al checkpoint del modelo (.pt)",
+ )
+ parser.add_argument(
+ "--tokenizer",
+ type=Path,
+ default=Path("data/tokenizer/pampar_48k.model"),
+ help="Ruta al tokenizer SentencePiece",
+ )
+ parser.add_argument(
+ "--biblioteca",
+ type=Path,
+ default=Path("biblioteca"),
+ help="Ruta a la carpeta biblioteca/",
+ )
+ parser.add_argument(
+ "--estado",
+ type=Path,
+ default=Path("checkpoints/curiosidad_estado.json"),
+ help="Dónde guardar/cargar el estado del motor de curiosidad",
+ )
+ parser.add_argument(
+ "--lr",
+ type=float,
+ default=5e-5,
+ help="Learning rate (bajo para aprendizaje continuo, default=5e-5)",
+ )
+ parser.add_argument(
+ "--pasos-por-tema",
+ type=int,
+ default=50,
+ help="Pasos de gradiente por sesión de cada tema",
+ )
+ parser.add_argument(
+ "--replay-cada",
+ type=int,
+ default=50,
+ help="Replay de memoria cada N pasos",
+ )
+ parser.add_argument(
+ "--consolidar-cada",
+ type=int,
+ default=300,
+ help="Consolidar L2→pesos cada N pasos",
+ )
+ parser.add_argument(
+ "--guardar-cada",
+ type=int,
+ default=500,
+ help="Guardar checkpoint cada N pasos",
+ )
+ parser.add_argument(
+ "--max-pasos",
+ type=int,
+ default=None,
+ help="Máximo de pasos (None = infinito)",
+ )
+ parser.add_argument(
+ "--teacher",
+ type=Path,
+ default=None,
+ help="Checkpoint del modelo teacher para destilación (ej: checkpoints/v3_ghidra_v9.pt)",
+ )
+ parser.add_argument(
+ "--alpha-distil",
+ type=float,
+ default=0.3,
+ help="Peso de la loss de destilación KL vs cross-entropy (0=solo CE, 1=solo KL, default=0.3)",
+ )
+ parser.add_argument(
+ "--temp-distil",
+ type=float,
+ default=4.0,
+ help="Temperatura de destilación — valores más altos dan distribuciones más suaves (default=4.0)",
+ )
+ parser.add_argument(
+ "--seq-len",
+ type=int,
+ default=512,
+ help="Longitud máxima de secuencia (default=512 para CPU)",
+ )
+ parser.add_argument(
+ "--batch-size",
+ type=int,
+ default=2,
+ help="Batch size (2-4 para GPU 4GB, 1 para CPU)",
+ )
+ parser.add_argument(
+ "--device",
+ type=str,
+ default="auto",
+ help="Dispositivo: 'auto', 'cpu', 'cuda', 'mps'",
+ )
+ args = parser.parse_args()
+
+ # ── Device ───────────────────────────────────────────────────────────────
+ if args.device == "auto":
+ if torch.cuda.is_available():
+ device = torch.device("cuda")
+ elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
+ device = torch.device("mps")
+ else:
+ device = torch.device("cpu")
+ else:
+ device = torch.device(args.device)
+ log("INFO", f"Device: {device}")
+
+ # ── Tokenizer ────────────────────────────────────────────────────────────
+ if not args.tokenizer.exists():
+ log("ERROR", f"Tokenizer no encontrado: {args.tokenizer}")
+ sys.exit(1)
+ tokenizer = spm.SentencePieceProcessor()
+ tokenizer.Load(str(args.tokenizer))
+ tok_vocab = tokenizer.GetPieceSize()
+ log("OK", f"Tokenizer cargado: {tok_vocab:,} vocab")
+
+ # ── Modelo ───────────────────────────────────────────────────────────────
+ import dataclasses
+
+ PRESET_MAP = {
+ "V3": PRESET_V3,
+ "V3_SMALL": PRESET_V3_SMALL,
+ "V3_LARGE": PRESET_V3_LARGE,
+ }
+
+ config = PRESET_V3
+ if args.checkpoint.exists():
+ ckpt_meta = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
+ raw_cfg = ckpt_meta.get("config")
+ state_for_infer = ckpt_meta.get("modelo", ckpt_meta.get("model", ckpt_meta))
+
+ if isinstance(raw_cfg, ConfigV3):
+ config = raw_cfg
+ elif isinstance(raw_cfg, dict):
+ valid = {f.name for f in dataclasses.fields(ConfigV3)}
+ filtered = {k: v for k, v in raw_cfg.items() if k in valid}
+ if filtered:
+ config = ConfigV3(**filtered)
+ else:
+ # Intentar inferir del preset name
+ preset_name = raw_cfg.get("preset", "")
+ if preset_name in PRESET_MAP:
+ config = PRESET_MAP[preset_name]
+ else:
+ # Sin config en checkpoint — inferir desde los pesos
+ emb = (
+ state_for_infer.get("tok_emb.weight")
+ if isinstance(state_for_infer, dict)
+ else None
+ )
+ if emb is not None:
+ inferred_vocab = int(emb.shape[0])
+ inferred_dim = int(emb.shape[1])
+ matched = False
+ for candidate in (PRESET_V3, PRESET_V3_SMALL, PRESET_V3_LARGE):
+ if candidate.dim == inferred_dim:
+ config = dataclasses.replace(
+ candidate, vocab_size=inferred_vocab
+ )
+ matched = True
+ break
+ if not matched:
+ config = ConfigV3(vocab_size=inferred_vocab, dim=inferred_dim)
+ log(
+ "WARN",
+ f"Sin 'config' en checkpoint — inferido: dim={inferred_dim}, vocab={inferred_vocab:,}",
+ )
+
+ # Validar que tokenizer y modelo tienen el mismo vocab
+ if config.vocab_size != tok_vocab:
+ log(
+ "ERROR",
+ f"Vocab mismatch: tokenizer={tok_vocab} vs modelo={config.vocab_size}",
+ )
+ auto_toks = {
+ 16000: Path("data/tokenizer/code_tokenizer.model"),
+ 48000: Path("data/tokenizer/pampar_48k.model"),
+ }
+ sugerido = auto_toks.get(config.vocab_size)
+ if sugerido and sugerido.exists():
+ log("INFO", f"Sugerencia: --tokenizer {sugerido}")
+ sys.exit(1)
+
+ modelo = PamparV3(config).to(device)
+
+ if args.checkpoint.exists():
+ ckpt = torch.load(args.checkpoint, map_location=device, weights_only=False)
+ state = ckpt.get("modelo", ckpt.get("model", ckpt))
+ missing, unexpected = modelo.load_state_dict(state, strict=False)
+ if unexpected:
+ log("WARN", f"{len(unexpected)} pesos inesperados: {unexpected[:3]}")
+ if missing:
+ log("WARN", f"{len(missing)} pesos faltantes: {missing[:3]}")
+ log(
+ "OK",
+ f"Modelo cargado desde {args.checkpoint} "
+ f"({config.vocab_size:,} vocab, {sum(p.numel() for p in modelo.parameters()) / 1e6:.0f}M params)",
+ )
+ else:
+ log(
+ "WARN",
+ f"Checkpoint no encontrado — iniciando desde cero: {args.checkpoint}",
+ )
+
+ n_params = sum(p.numel() for p in modelo.parameters()) / 1e6
+ log("INFO", f"Parámetros: {n_params:.0f}M")
+
+ # ── Optimizer ────────────────────────────────────────────────────────────
+ # LR bajo para aprendizaje continuo — no sobreescribir lo ya aprendido
+ optimizer = torch.optim.AdamW(
+ modelo.parameters(),
+ lr=args.lr,
+ weight_decay=0.01,
+ betas=(0.9, 0.95),
+ )
+
+ # ── Memoria ──────────────────────────────────────────────────────────────
+ memoria = MemoriaJerarquica(
+ capacidad_l0=2048,
+ capacidad_l1=8000,
+ capacidad_l2=3000,
+ )
+ ruta_mem = args.checkpoint.with_suffix(".memoria.json")
+ if ruta_mem.exists():
+ memoria = MemoriaJerarquica.cargar(str(ruta_mem))
+ log("OK", "Estado de memoria cargado")
+
+ # ── Motor de curiosidad ───────────────────────────────────────────────────
+ motor = MotorCuriosidad(
+ ruta_estado=args.estado,
+ nivel_actual=1,
+ )
+
+ # ── Biblioteca ───────────────────────────────────────────────────────────
+ if not args.biblioteca.exists():
+ log(
+ "WARN",
+ f"Biblioteca no encontrada en {args.biblioteca}. Créala o descarga datos.",
+ )
+ args.biblioteca.mkdir(parents=True, exist_ok=True)
+
+ indice_path = args.biblioteca / "indice.json"
+ if not indice_path.exists():
+ log("ERROR", f"Índice de biblioteca no encontrado: {indice_path}")
+ sys.exit(1)
+
+ indice = json.loads(indice_path.read_text())
+ biblioteca = LectorBiblioteca(
+ raiz=args.biblioteca,
+ tokenizer=tokenizer,
+ max_seq_len=args.seq_len,
+ batch_size=args.batch_size,
+ )
+
+ # ── Viaje Intelectual ────────────────────────────────────────────────────
+ # ── Teacher (opcional, para destilación) ────────────────────────────────
+ teacher_modelo = None
+ if args.teacher is not None:
+ if not args.teacher.exists():
+ log("ERROR", f"Teacher no encontrado: {args.teacher}")
+ sys.exit(1)
+ log("INFO", f"Cargando teacher desde {args.teacher}...")
+ ck_t = torch.load(args.teacher, map_location=device, weights_only=False)
+ state_t = ck_t.get("modelo", ck_t.get("model", ck_t))
+ # Inferir config del teacher desde sus pesos
+ emb_t = state_t.get("tok_emb.weight") if isinstance(state_t, dict) else None
+ if emb_t is not None:
+ inferred_dim_t = int(emb_t.shape[1])
+ inferred_vocab_t = int(emb_t.shape[0])
+ config_t = None
+ for cand in (PRESET_V3, PRESET_V3_SMALL, PRESET_V3_LARGE):
+ if cand.dim == inferred_dim_t:
+ config_t = dataclasses.replace(cand, vocab_size=inferred_vocab_t)
+ break
+ if config_t is None:
+ config_t = ConfigV3(vocab_size=inferred_vocab_t, dim=inferred_dim_t)
+ else:
+ config_t = config # Asumir misma config
+ if config_t.vocab_size != config.vocab_size:
+ log(
+ "ERROR",
+ f"Teacher vocab ({config_t.vocab_size}) != Student vocab ({config.vocab_size}) — "
+ f"deben compartir el mismo tokenizer",
+ )
+ sys.exit(1)
+ teacher_modelo = PamparV3(config_t).to(device)
+ missing_t, _ = teacher_modelo.load_state_dict(state_t, strict=False)
+ teacher_modelo.eval()
+ for p in teacher_modelo.parameters():
+ p.requires_grad_(False)
+ t_params = sum(p.numel() for p in teacher_modelo.parameters()) / 1e6
+ log(
+ "OK",
+ f"Teacher listo: {t_params:.0f}M params | "
+ f"α={args.alpha_distil} T={args.temp_distil} | "
+ f"Pesos CONGELADOS (no se entrena)",
+ )
+
+ viaje = ViajeIntelectual(
+ modelo=modelo,
+ optimizer=optimizer,
+ memoria=memoria,
+ motor=motor,
+ biblioteca=biblioteca,
+ indice=indice,
+ device=device,
+ pasos_por_tema=args.pasos_por_tema,
+ replay_cada=args.replay_cada,
+ consolidar_cada=args.consolidar_cada,
+ guardar_cada=args.guardar_cada,
+ ruta_checkpoint=args.checkpoint,
+ ruta_estado_motor=args.estado,
+ )
+ if teacher_modelo is not None:
+ viaje.teacher = teacher_modelo
+ viaje.alpha_distil = args.alpha_distil
+ viaje.temp_distil = args.temp_distil
+
+ viaje.estudiar(max_pasos=args.max_pasos)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/benchmark_humaneval.py b/scripts/benchmark_humaneval.py
new file mode 100644
index 0000000000000000000000000000000000000000..37e625c0a45fca2e58696c67653cfea03860ee1c
--- /dev/null
+++ b/scripts/benchmark_humaneval.py
@@ -0,0 +1,428 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2025-2026 Lucas Ricardo Mella Chillemi
+"""
+benchmark_humaneval.py — Evaluación HumanEval (pass@1) para PamparV3.
+
+Descarga el dataset HumanEval de OpenAI (164 problemas), genera soluciones
+con el modelo y ejecuta los tests unitarios oficiales.
+
+Uso:
+ python -X utf8 scripts/benchmark_humaneval.py
+ python -X utf8 scripts/benchmark_humaneval.py --checkpoint checkpoints/v3_train.pt
+ python -X utf8 scripts/benchmark_humaneval.py --samples-per-task 5 --temp 0.4
+ python -X utf8 scripts/benchmark_humaneval.py --device cpu --verbose
+
+Requiere:
+ pip install datasets (para descargar HumanEval desde HuggingFace)
+
+Resultados se guardan en benchmarks/humaneval_results.json
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import signal
+import sys
+import time
+import traceback
+from pathlib import Path
+from typing import Any
+
+import torch
+import torch.nn.functional as F
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+
+# =============================================================================
+# Descarga del dataset
+# =============================================================================
+
+
+def cargar_humaneval() -> list[dict[str, Any]]:
+ """Descarga HumanEval desde HuggingFace datasets."""
+ try:
+ from datasets import load_dataset
+ except ImportError:
+ print("ERROR: instalar datasets → pip install datasets")
+ sys.exit(1)
+
+ ds = load_dataset("openai_humaneval", split="test")
+ problemas = []
+ for row in ds:
+ problemas.append(
+ {
+ "task_id": row["task_id"],
+ "prompt": row["prompt"],
+ "canonical_solution": row["canonical_solution"],
+ "test": row["test"],
+ "entry_point": row["entry_point"],
+ }
+ )
+ print(f" HumanEval cargado: {len(problemas)} problemas")
+ return problemas
+
+
+# =============================================================================
+# Carga del modelo (delegada a pampar.inference)
+# =============================================================================
+
+from pampar.inference import load_model
+
+# =============================================================================
+# Generación
+# =============================================================================
+
+
+@torch.no_grad()
+def generar(
+ modelo,
+ tokenizer,
+ prompt: str,
+ device: torch.device,
+ max_tokens: int = 512,
+ temperature: float = 0.2,
+ top_p: float = 0.95,
+) -> str:
+ """Genera código completando el prompt con nucleus sampling."""
+ ids = tokenizer.Encode(prompt)
+ generados = list(ids)
+
+ for _ in range(max_tokens):
+ ctx = torch.tensor([generados[-512:]], dtype=torch.long, device=device)
+ logits, _, _ = modelo(ctx)
+ next_logits = logits[0, -1]
+
+ if temperature <= 0.0:
+ next_token = int(next_logits.argmax())
+ else:
+ next_logits = next_logits / temperature
+ # Top-p (nucleus) sampling
+ sorted_logits, sorted_indices = torch.sort(next_logits, descending=True)
+ cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
+ mask = cumulative_probs - F.softmax(sorted_logits, dim=-1) >= top_p
+ sorted_logits[mask] = float("-inf")
+ probs = F.softmax(sorted_logits, dim=-1)
+ idx = int(torch.multinomial(probs, 1))
+ next_token = int(sorted_indices[idx])
+
+ generados.append(next_token)
+ decoded = tokenizer.Decode(generados[len(ids) :])
+
+ # Stop conditions
+ if "\n\nclass " in decoded or "\n\ndef " in decoded:
+ # Truncar antes de la siguiente definición top-level
+ for stop in ["\n\nclass ", "\n\ndef "]:
+ if stop in decoded:
+ decoded = decoded[: decoded.index(stop)]
+ return decoded
+
+ # Demasiadas líneas vacías seguidas → terminó
+ if "\n\n\n" in decoded:
+ decoded = decoded[: decoded.index("\n\n\n")]
+ return decoded
+
+ return tokenizer.Decode(generados[len(ids) :])
+
+
+# =============================================================================
+# Ejecución segura con timeout
+# =============================================================================
+
+
+class TimeoutError(Exception):
+ """Señal de timeout."""
+
+
+def _timeout_handler(signum, frame):
+ raise TimeoutError("Timeout")
+
+
+def ejecutar_con_timeout(code: str, timeout_sec: int = 5) -> tuple[bool, str]:
+ """
+ Ejecuta código Python con timeout.
+
+ Returns:
+ (passed, error_msg)
+ """
+ # En Windows no hay signal.SIGALRM, usar threading
+ if sys.platform == "win32":
+ import threading
+
+ result: dict[str, Any] = {"passed": False, "error": "timeout"}
+
+ def _run():
+ try:
+ ns: dict[str, Any] = {}
+ exec(compile(code, "", "exec"), ns)
+ result["passed"] = True
+ result["error"] = ""
+ except AssertionError as e:
+ result["error"] = f"AssertionError: {e}"
+ except Exception as e:
+ result["error"] = f"{type(e).__name__}: {e}"
+
+ t = threading.Thread(target=_run, daemon=True)
+ t.start()
+ t.join(timeout=timeout_sec)
+ if t.is_alive():
+ return False, "timeout"
+ return result["passed"], result["error"]
+ else:
+ # Unix: usar SIGALRM
+ old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
+ signal.alarm(timeout_sec)
+ try:
+ ns: dict[str, Any] = {}
+ exec(compile(code, "", "exec"), ns)
+ signal.alarm(0)
+ return True, ""
+ except TimeoutError:
+ return False, "timeout"
+ except AssertionError as e:
+ signal.alarm(0)
+ return False, f"AssertionError: {e}"
+ except Exception as e:
+ signal.alarm(0)
+ return False, f"{type(e).__name__}: {e}"
+ finally:
+ signal.signal(signal.SIGALRM, old_handler)
+
+
+# =============================================================================
+# Evaluación pass@k
+# =============================================================================
+
+
+def evaluar_problema(
+ modelo,
+ tokenizer,
+ device: torch.device,
+ problema: dict,
+ n_samples: int = 1,
+ temperature: float = 0.2,
+ max_tokens: int = 512,
+ verbose: bool = False,
+) -> dict:
+ """Genera n_samples completions y evalúa cada una."""
+ task_id = problema["task_id"]
+ prompt = problema["prompt"]
+ test_code = problema["test"]
+ entry_point = problema["entry_point"]
+
+ resultados = []
+
+ for s in range(n_samples):
+ temp = 0.0 if n_samples == 1 else temperature
+ completion = generar(modelo, tokenizer, prompt, device, max_tokens, temp)
+
+ # Construir código completo: prompt + completion + tests
+ full_code = prompt + completion + "\n\n" + test_code
+ # HumanEval tests llaman check(entry_point), agregar la llamada
+ full_code += f"\n\ncheck({entry_point})\n"
+
+ passed, error = ejecutar_con_timeout(full_code, timeout_sec=10)
+
+ if verbose:
+ status = "PASS" if passed else f"FAIL ({error[:60]})"
+ print(f" sample {s}: {status}")
+ if not passed:
+ # Mostrar solo el completion generado
+ for line in completion.split("\n")[:15]:
+ print(f" {line}")
+
+ resultados.append(
+ {
+ "sample": s,
+ "passed": passed,
+ "error": error,
+ "completion_len": len(completion),
+ }
+ )
+
+ any_passed = any(r["passed"] for r in resultados)
+ return {
+ "task_id": task_id,
+ "entry_point": entry_point,
+ "passed": any_passed,
+ "n_samples": n_samples,
+ "pass_count": sum(1 for r in resultados if r["passed"]),
+ "samples": resultados,
+ }
+
+
+# =============================================================================
+# pass@k estimator (unbiased, from Chen et al. 2021)
+# =============================================================================
+
+
+def pass_at_k(n: int, c: int, k: int) -> float:
+ """
+ Estimador insesgado de pass@k.
+
+ n: total de muestras por problema
+ c: cantidad de muestras correctas
+ k: k para pass@k
+ """
+ if n - c < k:
+ return 1.0
+ result = 1.0
+ for i in range(k):
+ result *= (n - c - i) / (n - i)
+ return 1.0 - result
+
+
+# =============================================================================
+# Main
+# =============================================================================
+
+
+def main():
+ parser = argparse.ArgumentParser(description="HumanEval benchmark para PamparV3")
+ parser.add_argument("--checkpoint", default="checkpoints/v3_train.pt")
+ parser.add_argument(
+ "--samples-per-task",
+ type=int,
+ default=1,
+ help="Muestras por problema (1=pass@1 determinista)",
+ )
+ parser.add_argument("--temp", type=float, default=0.2)
+ parser.add_argument("--max-tokens", type=int, default=512)
+ parser.add_argument("--device", default="auto")
+ parser.add_argument("--verbose", action="store_true")
+ parser.add_argument(
+ "--limit", type=int, default=0, help="Limitar a N problemas (0=todos)"
+ )
+ parser.add_argument("--output", default="benchmarks/humaneval_results.json")
+ args = parser.parse_args()
+
+ device = torch.device(
+ args.device
+ if args.device != "auto"
+ else ("cuda" if torch.cuda.is_available() else "cpu")
+ )
+ checkpoint = Path(args.checkpoint)
+
+ print(f"\n{'═' * 65}")
+ print(f" BENCHMARK HumanEval — PamparV3")
+ print(f" Checkpoint : {checkpoint.name}")
+ print(f" Device : {device}")
+ print(f" Samples/task: {args.samples_per_task} | Temp: {args.temp}")
+ print(f"{'═' * 65}\n")
+
+ # Cargar modelo
+ print(" Cargando modelo...", end=" ", flush=True)
+ t0 = time.time()
+ modelo, tokenizer = load_model(checkpoint, device, verbose=False)
+ n_params = sum(p.numel() for p in modelo.parameters()) / 1e6
+ print(f"OK ({n_params:.1f}M params, {time.time() - t0:.1f}s)")
+
+ # Cargar HumanEval
+ print(" Descargando HumanEval...", end=" ", flush=True)
+ problemas = cargar_humaneval()
+ if args.limit > 0:
+ problemas = problemas[: args.limit]
+ print(f"(limitado a {args.limit})")
+
+ # Evaluar
+ print(f"\n Evaluando {len(problemas)} problemas...\n")
+ results = []
+ t_start = time.time()
+
+ for i, prob in enumerate(problemas, 1):
+ print(
+ f" [{i:03d}/{len(problemas)}] {prob['task_id']:30s}",
+ end=" ",
+ flush=True,
+ )
+ t_prob = time.time()
+
+ try:
+ res = evaluar_problema(
+ modelo,
+ tokenizer,
+ device,
+ prob,
+ n_samples=args.samples_per_task,
+ temperature=args.temp,
+ max_tokens=args.max_tokens,
+ verbose=args.verbose,
+ )
+ except Exception as e:
+ traceback.print_exc()
+ res = {
+ "task_id": prob["task_id"],
+ "entry_point": prob["entry_point"],
+ "passed": False,
+ "n_samples": args.samples_per_task,
+ "pass_count": 0,
+ "samples": [],
+ "error": str(e),
+ }
+
+ dt = time.time() - t_prob
+ icon = "✅" if res["passed"] else "❌"
+ pc = res["pass_count"]
+ ns = res["n_samples"]
+ print(f"[{dt:.1f}s] {icon} {pc}/{ns}")
+ results.append(res)
+
+ elapsed = time.time() - t_start
+
+ # Calcular pass@k
+ total = len(results)
+ passed = sum(1 for r in results if r["passed"])
+ pass1 = passed / total * 100 if total > 0 else 0.0
+
+ # pass@k insesgado si n_samples > 1
+ if args.samples_per_task > 1:
+ pass_at_1_unbiased = (
+ sum(pass_at_k(r["n_samples"], r["pass_count"], 1) for r in results)
+ / total
+ * 100
+ )
+ else:
+ pass_at_1_unbiased = pass1
+
+ # Resultados
+ print(f"\n{'═' * 65}")
+ print(f" RESULTADO HumanEval — {elapsed:.0f}s total")
+ print(f"{'═' * 65}")
+ print(f" Problemas evaluados : {total}")
+ print(f" pass@1 : {pass1:.1f}% ({passed}/{total})")
+ if args.samples_per_task > 1:
+ print(f" pass@1 (unbiased) : {pass_at_1_unbiased:.1f}%")
+ print(f" Modelo : {n_params:.1f}M params")
+ print(f" Device : {device}")
+ print(f"{'═' * 65}\n")
+
+ # Guardar resultados
+ output_path = Path(args.output)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ summary = {
+ "model": "PAMPAr-Coder V3",
+ "params_m": round(n_params, 1),
+ "checkpoint": str(checkpoint),
+ "benchmark": "HumanEval",
+ "n_problems": total,
+ "samples_per_task": args.samples_per_task,
+ "temperature": args.temp,
+ "pass_at_1_pct": round(pass1, 2),
+ "pass_at_1_unbiased_pct": round(pass_at_1_unbiased, 2),
+ "passed": passed,
+ "total": total,
+ "elapsed_sec": round(elapsed, 1),
+ "device": str(device),
+ "date": time.strftime("%Y-%m-%d %H:%M"),
+ "results": results,
+ }
+
+ output_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False))
+ print(f" Resultados guardados en: {output_path}\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/bio_mechanisms.py b/scripts/bio_mechanisms.py
new file mode 100644
index 0000000000000000000000000000000000000000..b426e471e58c0d6f243143c2a95f3a8c1e2c1e31
--- /dev/null
+++ b/scripts/bio_mechanisms.py
@@ -0,0 +1,627 @@
+#!/usr/bin/env python3
+"""
+bio_mechanisms.py — Mecanismos bio-inspirados para el Classroom de PamparV3.
+
+5 mecanismos basados en neurociencia real:
+ 1. Neuromodulación — dopamina/norepinefrina ajustan LR dinámicamente
+ 2. LTP — fortalece LateralGate.scale de streams consistentes
+ 3. Sleep Replay — consolidación periódica (REM aleatorio + SWS ordenado)
+ 4. Neurogenesis — inyecta LoRA adapters en StreamFFN para conocimiento nuevo
+ 5. Synaptic Pruning — poda conexiones laterales débiles
+
+Uso:
+ from bio_mechanisms import BioOrchestrator
+
+ bio = BioOrchestrator(model, config, optimizer, replay_buffer)
+ # Después de cada lección:
+ bio.after_lesson(lesson_result, terr_acts_history)
+"""
+
+from __future__ import annotations
+
+import math
+import random
+from collections import deque
+from dataclasses import dataclass, field
+from typing import Optional
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+# =============================================================================
+# 1. NEUROMODULACIÓN — Dopamina + Norepinefrina
+# =============================================================================
+
+
+class Neuromodulator:
+ """
+ Modula el learning rate según el resultado de la lección.
+
+ - Dopamina (recompensa): sube tras éxito → consolida aprendizaje
+ - Norepinefrina (alerta): sube tras error/novedad → aumenta plasticidad
+
+ El LR efectivo se escala: lr_effective = lr_base × modulation_factor
+ """
+
+ def __init__(
+ self, baseline_lr: float, min_mult: float = 0.3, max_mult: float = 1.5
+ ):
+ self.baseline_lr = baseline_lr
+ self.min_mult = min_mult
+ self.max_mult = max_mult
+
+ # Estado interno (decaimiento exponencial)
+ self.dopamine: float = 1.0 # Recompensa acumulada
+ self.norepinephrine: float = 1.0 # Alerta/novedad
+
+ # Historial para detectar tendencias
+ self._recent_correct: deque[bool] = deque(maxlen=10)
+ self._recent_losses: deque[float] = deque(maxlen=10)
+
+ def update(self, correct: bool, loss: float, level: int) -> float:
+ """
+ Actualiza neuromoduladores y retorna el factor de modulación del LR.
+
+ Returns:
+ factor multiplicativo para el LR (ej: 1.5 = 50% más LR)
+ """
+ self._recent_correct.append(correct)
+ self._recent_losses.append(loss)
+
+ # Decaimiento natural (tau ~5 lecciones)
+ decay = 0.8
+ self.dopamine *= decay
+ self.norepinephrine *= decay
+
+ # Anti-saturación: si hay racha larga de errores, NE decae más rápido
+ recent_errors = sum(1 for c in self._recent_correct if not c)
+ if recent_errors >= 7:
+ self.norepinephrine *= 0.7 # Decay extra para evitar espiral
+
+ if correct:
+ # Éxito → dopamina sube (más en niveles altos)
+ self.dopamine += 0.3 * (1.0 + level * 0.1)
+ # Éxito reduce alerta
+ self.norepinephrine *= 0.8
+ else:
+ # Error → norepinefrina sube (más plasticidad, pero moderado)
+ self.norepinephrine += 0.2
+
+ # Detectar novedad: si el loss es mucho mayor que el promedio reciente
+ if len(self._recent_losses) > 3:
+ avg_loss = sum(self._recent_losses) / len(self._recent_losses)
+ if loss > avg_loss * 1.5:
+ self.norepinephrine += 0.1 # Material nuevo/difícil
+
+ # Factor de modulación combinado
+ # Dopamina alta + Norepinefrina baja = consolidar (LR moderado)
+ # Dopamina baja + Norepinefrina alta = explorar (LR alto)
+ factor = 0.5 * self.dopamine + 0.7 * self.norepinephrine
+
+ # Clampear a rango seguro (max 50% boost)
+ factor = max(self.min_mult, min(self.max_mult, factor))
+
+ return factor
+
+ def apply_to_optimizer(
+ self, optimizer: torch.optim.Optimizer, factor: float
+ ) -> None:
+ """Aplica el factor de modulación a todos los param groups."""
+ for group in optimizer.param_groups:
+ # Cada grupo tiene su propio baseline (definido por lr_base × mult)
+ if "baseline_lr" in group:
+ group["lr"] = group["baseline_lr"] * factor
+
+
+# =============================================================================
+# 2. LTP — Long-Term Potentiation (Fortalecimiento sináptico)
+# =============================================================================
+
+
+class LTPManager:
+ """
+ Fortalece las conexiones laterales (LateralGate.scale) de streams
+ que se activan consistentemente juntos.
+
+ Regla de Hebb: "Neurons that fire together wire together."
+ Si un stream tiene alta activación territorial repetidamente,
+ su scale en LateralGate crece → más comunicación lateral.
+ """
+
+ def __init__(self, n_streams: int = 4, n_levels: int = 5):
+ self.n_streams = n_streams
+ self.n_levels = n_levels
+
+ # Acumulador de activaciones por stream por nivel
+ self._activation_accum: list[torch.Tensor] = [
+ torch.zeros(n_streams) for _ in range(n_levels)
+ ]
+ self._count: int = 0
+ self._apply_every: int = 5 # Aplicar LTP cada 5 lecciones
+
+ def accumulate(self, terr_acts_per_level: list[torch.Tensor]) -> None:
+ """
+ Acumula activaciones territoriales de la última lección.
+
+ Args:
+ terr_acts_per_level: lista de [B, L, 4] por nivel, o un solo [B, L, 4]
+ """
+ self._count += 1
+
+ for lvl_idx, terr_acts in enumerate(terr_acts_per_level):
+ if lvl_idx >= self.n_levels:
+ break
+ # Promedio espacial: [4] — activación media de cada stream
+ mean_act = terr_acts.detach().float().mean(dim=(0, 1)) # [4]
+ self._activation_accum[lvl_idx] += mean_act.cpu()
+
+ def should_apply(self) -> bool:
+ """Retorna True si es momento de aplicar LTP."""
+ return self._count > 0 and self._count % self._apply_every == 0
+
+ @torch.no_grad()
+ def apply(self, model: nn.Module, strength: float = 0.02) -> dict[str, float]:
+ """
+ Fortalece LateralGate.scale según activaciones acumuladas.
+
+ Returns:
+ dict con cambios aplicados por nivel
+ """
+ if self._count == 0:
+ return {}
+
+ changes: dict[str, float] = {}
+
+ for name, module in model.named_modules():
+ if not hasattr(module, "scale") or "lateral" not in name.lower():
+ continue
+
+ # Extraer índice de nivel del nombre del módulo
+ lvl_idx = self._extract_level_index(name)
+ if lvl_idx is None or lvl_idx >= self.n_levels:
+ continue
+
+ # Activación promedio normalizada
+ avg_act = self._activation_accum[lvl_idx] / self._count
+ avg_act = avg_act / (avg_act.max() + 1e-8) # Normalizar a [0, 1]
+
+ # LTP: streams con alta activación → su scale crece
+ # La delta es proporcional a la activación y strength
+ delta = strength * avg_act.to(module.scale.device)
+ module.scale.data += delta
+
+ # Clampear scale a rango razonable [0.01, 0.5]
+ module.scale.data.clamp_(0.01, 0.5)
+
+ changes[name] = delta.mean().item()
+
+ # Reset acumuladores
+ self._activation_accum = [
+ torch.zeros(self.n_streams) for _ in range(self.n_levels)
+ ]
+ self._count = 0
+
+ return changes
+
+ def _extract_level_index(self, name: str) -> Optional[int]:
+ """Extrae el índice del nivel desde el nombre del módulo."""
+ # Buscar patrones como 'niveles.0.lateral', 'niveles.3.lateral'
+ parts = name.split(".")
+ for i, part in enumerate(parts):
+ if part == "niveles" and i + 1 < len(parts):
+ try:
+ return int(parts[i + 1])
+ except ValueError:
+ pass
+ return None
+
+
+# =============================================================================
+# 3. SLEEP CONSOLIDATION — Replay durante "sueño"
+# =============================================================================
+
+
+class SleepConsolidator:
+ """
+ Consolidación periódica que simula las fases del sueño:
+
+ - REM: replay aleatorio de experiencias recientes (creatividad/generalización)
+ - SWS (Slow-Wave Sleep): replay ordenado por importancia (consolidación fuerte)
+
+ Se ejecuta cada N lecciones, hace un mini-entrenamiento con replay puro.
+ """
+
+ def __init__(self, every_n: int = 15, rem_ratio: float = 0.6):
+ self.every_n = every_n
+ self.rem_ratio = rem_ratio # 60% REM, 40% SWS
+ self._lesson_count = 0
+
+ def should_sleep(self) -> bool:
+ """¿Es hora de dormir?"""
+ self._lesson_count += 1
+ return self._lesson_count % self.every_n == 0
+
+ def consolidate(
+ self,
+ model: nn.Module,
+ optimizer: torch.optim.Optimizer,
+ replay_buffer: object,
+ device: torch.device,
+ n_steps: int = 3,
+ ) -> float:
+ """
+ Ejecuta consolidación de sueño.
+
+ Args:
+ model: PamparV3
+ optimizer: el optimizador con LR diferencial
+ replay_buffer: ReplayBuffer con .buffer y .sample()
+ device: dispositivo
+ n_steps: pasos de consolidación
+
+ Returns:
+ loss promedio durante consolidación
+ """
+ buffer = getattr(replay_buffer, "buffer", [])
+ if len(buffer) < 4:
+ return 0.0
+
+ model.train()
+ total_loss = 0.0
+
+ # Reducir LR durante sueño (como en sueño real, actividad reducida)
+ sleep_lr_factor = 0.3
+ original_lrs: list[float] = []
+ for group in optimizer.param_groups:
+ original_lrs.append(group["lr"])
+ group["lr"] = group["lr"] * sleep_lr_factor
+
+ for step in range(n_steps):
+ # Fase REM: replay aleatorio (generalización)
+ n_rem = max(1, int(len(buffer) * self.rem_ratio))
+ rem_samples = random.sample(list(buffer), min(n_rem, len(buffer)))
+
+ # Fase SWS: replay ordenado por nivel (lo más difícil primero)
+ sws_samples = sorted(
+ list(buffer),
+ key=lambda x: x.get("level", 1),
+ reverse=True,
+ )
+ n_sws = max(1, len(buffer) - n_rem)
+ sws_samples = sws_samples[:n_sws]
+
+ # Combinar
+ all_samples = rem_samples + sws_samples
+
+ optimizer.zero_grad()
+ batch_loss = torch.tensor(0.0, device=device)
+ n = 0
+
+ for sample in all_samples:
+ input_ids = sample["input_ids"].to(device)
+ labels = sample["labels"].to(device)
+ if input_ids.dim() == 1:
+ input_ids = input_ids.unsqueeze(0)
+ labels = labels.unsqueeze(0)
+ if input_ids.shape[-1] < 3:
+ continue
+
+ inp = input_ids[:, :-1]
+ tgt = labels[:, 1:]
+ logits, _, _ = model(inp)
+
+ loss = F.cross_entropy(
+ logits.reshape(-1, logits.size(-1)),
+ tgt.reshape(-1),
+ ignore_index=-100,
+ )
+ batch_loss = batch_loss + loss
+ n += 1
+
+ if n > 0:
+ batch_loss = batch_loss / n
+ batch_loss.backward()
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
+ optimizer.step()
+ total_loss += batch_loss.item()
+
+ # Restaurar LR originales
+ for group, orig_lr in zip(optimizer.param_groups, original_lrs):
+ group["lr"] = orig_lr
+
+ return total_loss / max(1, n_steps)
+
+
+# =============================================================================
+# 4. NEUROGENESIS — LoRA adapters para conocimiento nuevo
+# =============================================================================
+
+
+class StreamLoRA(nn.Module):
+ """
+ Adapter LoRA minimalista para StreamFFN.
+
+ Inyecta una rama paralela de bajo rango que captura conocimiento nuevo
+ sin modificar los pesos originales (como neuronas nuevas en el hipocampo).
+
+ Original: y = FFN(x)
+ Con LoRA: y = FFN(x) + scale * B(A(x))
+
+ Params: dim × rank + rank × dim ≈ 640×8×2 = 10K por adapter
+ """
+
+ def __init__(self, dim: int, rank: int = 8):
+ super().__init__()
+ self.down = nn.Linear(dim, rank, bias=False)
+ self.up = nn.Linear(rank, dim, bias=False)
+ self.scale = nn.Parameter(torch.tensor(0.01))
+
+ # Inicialización: down normal, up zeros (empiezan como identidad)
+ nn.init.kaiming_normal_(self.down.weight, a=math.sqrt(5))
+ nn.init.zeros_(self.up.weight)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ """Retorna solo el delta LoRA (se suma al output original)."""
+ return self.scale * self.up(F.silu(self.down(x)))
+
+
+class NeurogenesisManager:
+ """
+ Gestiona la creación y activación de LoRA adapters en StreamFFN.
+
+ Solo crea adapters cuando detecta que un stream necesita aprender
+ algo genuinamente nuevo (alta pérdida + baja activación territorial).
+ """
+
+ def __init__(self, dim: int = 640, rank: int = 8, max_adapters: int = 8):
+ self.dim = dim
+ self.rank = rank
+ self.max_adapters = max_adapters
+ self._adapters: dict[str, StreamLoRA] = {}
+ self._hooked: bool = False
+
+ @property
+ def adapter_count(self) -> int:
+ return len(self._adapters)
+
+ def should_grow(self, loss: float, threshold: float = 4.0) -> bool:
+ """Determina si necesitamos crear neuronas nuevas."""
+ return loss > threshold and self.adapter_count < self.max_adapters
+
+ def create_adapter(
+ self, model: nn.Module, level_idx: int, stream_idx: int, device: torch.device
+ ) -> Optional[str]:
+ """
+ Crea un LoRA adapter para un StreamFFN específico.
+
+ Returns:
+ nombre del adapter creado, o None si ya existe/límite alcanzado
+ """
+ key = f"lora_L{level_idx}_S{stream_idx}"
+ if key in self._adapters or self.adapter_count >= self.max_adapters:
+ return None
+
+ adapter = StreamLoRA(self.dim, self.rank).to(device)
+ self._adapters[key] = adapter
+
+ # Registrar como submodule del modelo para que el optimizer lo vea
+ if not hasattr(model, "_bio_lora_adapters"):
+ model._bio_lora_adapters = nn.ModuleDict()
+ model._bio_lora_adapters[key] = adapter
+
+ return key
+
+ def get_adapter(self, level_idx: int, stream_idx: int) -> Optional[StreamLoRA]:
+ """Retorna el adapter para un nivel/stream, si existe."""
+ key = f"lora_L{level_idx}_S{stream_idx}"
+ return self._adapters.get(key)
+
+ def add_adapters_to_optimizer(
+ self, optimizer: torch.optim.Optimizer, lr: float
+ ) -> None:
+ """Añade los parámetros de los nuevos adapters al optimizador."""
+ existing_params = set()
+ for group in optimizer.param_groups:
+ for p in group["params"]:
+ existing_params.add(id(p))
+
+ new_params = []
+ for adapter in self._adapters.values():
+ for p in adapter.parameters():
+ if id(p) not in existing_params:
+ new_params.append(p)
+
+ if new_params:
+ optimizer.add_param_group(
+ {
+ "params": new_params,
+ "lr": lr,
+ "label": "neurogenesis_lora",
+ }
+ )
+
+
+# =============================================================================
+# 5. SYNAPTIC PRUNING — Poda de conexiones débiles
+# =============================================================================
+
+
+class SynapticPruner:
+ """
+ Poda conexiones laterales débiles (LateralGate.scale bajo).
+
+ En el cerebro, ~50% de las sinapsis se eliminan durante el desarrollo.
+ Aquí, si un LateralGate.scale cae por debajo del umbral durante
+ varias lecciones consecutivas, lo reducimos agresivamente.
+
+ Esto libera "capacidad" y evita ruido de conexiones irrelevantes.
+ """
+
+ def __init__(self, every_n: int = 30, threshold: float = 0.03, decay: float = 0.5):
+ self.every_n = every_n
+ self.threshold = threshold
+ self.decay = decay
+ self._lesson_count = 0
+
+ def should_prune(self) -> bool:
+ """¿Es momento de podar?"""
+ self._lesson_count += 1
+ return self._lesson_count % self.every_n == 0
+
+ @torch.no_grad()
+ def prune(self, model: nn.Module) -> dict[str, list[int]]:
+ """
+ Poda conexiones laterales débiles.
+
+ Returns:
+ dict con streams podados por nivel
+ """
+ pruned: dict[str, list[int]] = {}
+
+ for name, module in model.named_modules():
+ if not hasattr(module, "scale") or "lateral" not in name.lower():
+ continue
+
+ scale = module.scale.data # [n_streams]
+ weak_mask = scale < self.threshold
+
+ if weak_mask.any():
+ # Reducir, no eliminar completamente (permitir recuperación)
+ module.scale.data[weak_mask] *= self.decay
+
+ # Registrar qué streams se podaron
+ pruned_streams = weak_mask.nonzero(as_tuple=True)[0].tolist()
+ pruned[name] = pruned_streams
+
+ return pruned
+
+
+# =============================================================================
+# ORCHESTRATOR — Coordina todos los mecanismos
+# =============================================================================
+
+
+@dataclass
+class BioState:
+ """Estado observable de los mecanismos bio para logging/UI."""
+
+ dopamine: float = 1.0
+ norepinephrine: float = 1.0
+ lr_factor: float = 1.0
+ ltp_applied: bool = False
+ ltp_changes: dict = field(default_factory=dict)
+ sleep_triggered: bool = False
+ sleep_loss: float = 0.0
+ adapters_created: int = 0
+ adapters_total: int = 0
+ pruned_streams: dict = field(default_factory=dict)
+
+
+class BioOrchestrator:
+ """
+ Coordina los 5 mecanismos bio-inspirados.
+
+ Se llama una vez después de cada lección con el resultado y las
+ activaciones territoriales. Él decide qué mecanismos activar.
+ """
+
+ def __init__(
+ self,
+ model: nn.Module,
+ optimizer: torch.optim.Optimizer,
+ replay_buffer: object,
+ device: torch.device,
+ baseline_lr: float = 5e-6,
+ dim: int = 640,
+ n_streams: int = 4,
+ n_levels: int = 5,
+ sleep_every: int = 15,
+ prune_every: int = 30,
+ ):
+ self.model = model
+ self.optimizer = optimizer
+ self.replay_buffer = replay_buffer
+ self.device = device
+
+ # Inicializar los 5 mecanismos
+ self.neuromod = Neuromodulator(baseline_lr)
+ self.ltp = LTPManager(n_streams, n_levels)
+ self.sleep = SleepConsolidator(every_n=sleep_every)
+ self.neurogenesis = NeurogenesisManager(dim=dim, rank=8, max_adapters=8)
+ self.pruner = SynapticPruner(every_n=prune_every)
+
+ # Guardar baseline LRs en el optimizer para modulación
+ for group in optimizer.param_groups:
+ group["baseline_lr"] = group["lr"]
+
+ def after_lesson(
+ self,
+ correct: bool,
+ loss: float,
+ level: int,
+ terr_acts_per_level: Optional[list[torch.Tensor]] = None,
+ ) -> BioState:
+ """
+ Hook principal — se llama después de cada lección.
+
+ Args:
+ correct: si el alumno acertó
+ loss: loss CE de la lección
+ level: nivel del curriculum
+ terr_acts_per_level: activaciones territoriales por nivel (opcional)
+
+ Returns:
+ BioState con el estado de todos los mecanismos
+ """
+ state = BioState()
+
+ # 1. NEUROMODULACIÓN — ajustar LR
+ factor = self.neuromod.update(correct, loss, level)
+ self.neuromod.apply_to_optimizer(self.optimizer, factor)
+ state.dopamine = self.neuromod.dopamine
+ state.norepinephrine = self.neuromod.norepinephrine
+ state.lr_factor = factor
+
+ # 2. LTP — acumular y potenciar si toca
+ if terr_acts_per_level is not None:
+ self.ltp.accumulate(terr_acts_per_level)
+ if self.ltp.should_apply():
+ changes = self.ltp.apply(self.model)
+ state.ltp_applied = True
+ state.ltp_changes = changes
+
+ # 3. SLEEP — consolidar si toca
+ if self.sleep.should_sleep():
+ sleep_loss = self.sleep.consolidate(
+ self.model, self.optimizer, self.replay_buffer, self.device
+ )
+ state.sleep_triggered = True
+ state.sleep_loss = sleep_loss
+
+ # 4. NEUROGENESIS — crear adapters si el loss es alto
+ if self.neurogenesis.should_grow(loss):
+ # Encontrar el stream menos activo (más necesitado)
+ if terr_acts_per_level:
+ last_terr = terr_acts_per_level[-1] # Último nivel
+ mean_act = last_terr.detach().float().mean(dim=(0, 1))
+ weakest_stream = mean_act.argmin().item()
+ # Crear en el nivel más profundo
+ deepest_level = len(terr_acts_per_level) - 1
+ key = self.neurogenesis.create_adapter(
+ self.model, deepest_level, weakest_stream, self.device
+ )
+ if key:
+ state.adapters_created = 1
+ # Añadir al optimizer
+ self.neurogenesis.add_adapters_to_optimizer(
+ self.optimizer, lr=self.neuromod.baseline_lr * factor
+ )
+ state.adapters_total = self.neurogenesis.adapter_count
+
+ # 5. PRUNING — podar conexiones débiles periódicamente
+ if self.pruner.should_prune():
+ pruned = self.pruner.prune(self.model)
+ state.pruned_streams = pruned
+
+ return state
diff --git a/scripts/brain_scanner.py b/scripts/brain_scanner.py
new file mode 100644
index 0000000000000000000000000000000000000000..b401bb2fb16ec81c37b5e7705d9196327ec279c3
--- /dev/null
+++ b/scripts/brain_scanner.py
@@ -0,0 +1,1422 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+PAMPAr Brain Scanner — Visualización y diagnóstico de la arquitectura cerebral 2D.
+
+Muestra cómo PamparV3 procesa tokens internamente:
+ - Activaciones territoriales (Tálamo routing por stream)
+ - Evolución por nivel (cómo cambian las activaciones a través de 5 niveles)
+ - Fibras blancas (LateralGate scale — comunicación entre streams)
+ - Zonas de Brodmann activas por token
+ - Early Exit (qué nivel puede salir antes)
+ - Distribución de pesos por componente
+ - Precisión de routing vs LLAVES (ground truth)
+ - Margen de decisión de routing (ambigüedad)
+ - Suite de tests con métricas agregadas
+ - Comparación de checkpoints
+ - Generación de código + evaluación
+
+Uso:
+ python scripts/brain_scanner.py --code "def fibonacci(n):"
+ python scripts/brain_scanner.py --suite
+ python scripts/brain_scanner.py --compare ckpt_a.pt ckpt_b.pt
+ python scripts/brain_scanner.py --generate "def factorial(n):"
+ python scripts/brain_scanner.py --weights
+ python scripts/brain_scanner.py --code "x = [i**2 for i in range(5)]" --html scan.html
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+from typing import Optional
+
+import torch
+import torch.nn.functional as F
+
+# Agregar raíz del proyecto al path
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from pampar.coder.v3.config import PRESET_V3, ConfigV3
+from pampar.coder.v3.llaves import clasificar_token
+from pampar.coder.v3.modelo import PamparV3
+from pampar.coder.v3.talamo import TalamoInicial
+from pampar.coder.v3.zonas import ZONA_TERRITORIO, Territorio, Zona
+
+# =============================================================================
+# CONSTANTES
+# =============================================================================
+
+STREAM_NAMES = ["SINTAXIS", "SEMANTICA", "LOGICO", "ESTRUCTURAL"]
+STREAM_COLORS = [
+ "\033[94m",
+ "\033[92m",
+ "\033[93m",
+ "\033[95m",
+] # blue, green, yellow, purple
+RESET = "\033[0m"
+BOLD = "\033[1m"
+DIM = "\033[2m"
+
+# Bloques para barras
+BLOCKS = " ▏▎▍▌▋▊▉█"
+
+# Nombres de zonas abreviados
+ZONA_SHORT = {z: z.name.replace("B", "").replace("_", " ") for z in Zona}
+
+# Suite de código diverso para test comprehensivo
+CODE_SUITE = [
+ # Keywords de control
+ ("keywords", "def fibonacci(n):"),
+ ("clase", "class DataProcessor:"),
+ ("imports", "from pathlib import Path"),
+ ("loop", "for i in range(10):"),
+ ("condicional", "if x > 0 and y < 10:"),
+ ("excepcion", "try:\n result = 1 / 0\nexcept ZeroDivisionError:"),
+ ("async", "async def fetch(url):"),
+ # Operadores y lógica
+ ("aritmetica", "result = a + b * c - d / e"),
+ ("comparacion", "x == y or x != z"),
+ ("asignacion", "total += price * quantity"),
+ # Semántica (ids, literals, tipos)
+ ("literals", "name = 'hello world'"),
+ ("numeros", "pi = 3.14159"),
+ ("tipos", "items: list[int] = []"),
+ ("builtins", "print(len(range(10)))"),
+ ("magic", "def __init__(self, value):"),
+ # Estructural
+ ("comprehension", "squares = [x**2 for x in range(10)]"),
+ ("lambda", "fn = lambda x: x * 2"),
+ ("decorador", "@staticmethod\ndef create():"),
+ ("return", "return sorted(data, key=lambda x: x.name)"),
+ ("with", "with open('file.txt') as f:"),
+]
+
+
+# =============================================================================
+# CARGA DEL MODELO (delegada a pampar.inference)
+# =============================================================================
+
+from pampar.inference import load_model
+
+# =============================================================================
+# BARRA VISUAL
+# =============================================================================
+
+
+def barra(valor: float, ancho: int = 20, color: str = "") -> str:
+ """Dibuja una barra horizontal proporcional al valor [0, 1]."""
+ v = max(0.0, min(1.0, valor))
+ lleno = int(v * ancho)
+ frac = int((v * ancho - lleno) * 8)
+ chars = "█" * lleno
+ if frac > 0 and lleno < ancho:
+ chars += BLOCKS[frac]
+ lleno += 1
+ chars += " " * (ancho - lleno)
+ pct = f"{v * 100:5.1f}%"
+ if color:
+ return f"{color}{chars}{RESET} {pct}"
+ return f"{chars} {pct}"
+
+
+def heatmap_char(valor: float) -> str:
+ """Devuelve un caracter coloreado para heatmap (0=azul, 1=rojo)."""
+ v = max(0.0, min(1.0, valor))
+ if v < 0.2:
+ return f"\033[34m░{RESET}" # azul
+ if v < 0.4:
+ return f"\033[36m▒{RESET}" # cyan
+ if v < 0.6:
+ return f"\033[32m▓{RESET}" # verde
+ if v < 0.8:
+ return f"\033[33m█{RESET}" # amarillo
+ return f"\033[31m█{RESET}" # rojo
+
+
+# =============================================================================
+# FORWARD INSTRUMENTADO
+# =============================================================================
+
+
+@torch.no_grad()
+def forward_instrumentado(
+ model: PamparV3,
+ input_ids: torch.Tensor,
+) -> dict:
+ """
+ Ejecuta el forward capturando todas las activaciones internas.
+
+ Returns:
+ dict con:
+ tokens: list[str] — tokens decodificados
+ zona_acts: [L, 52] — activaciones de zona (Tálamo)
+ terr_por_nivel: list[[L, 4]] — activaciones de territorio por nivel
+ confianza: list[float] — confianza de early exit por nivel
+ lateral_scales: [n_levels, 4] — escalas de LateralGate
+ stream_norms: [n_levels, 4] — norma L2 de cada stream por nivel
+ attn_norms: [n_levels] — norma del output de atención por nivel
+ """
+ config = model.config
+ B, L = input_ids.shape
+
+ # 1. Embedding
+ x = model.emb_drop(model.tok_emb(input_ids))
+
+ # 2. Tálamo inicial
+ terr_acts, zona_acts = model.talamo(x, input_ids)
+
+ # 3. Inicializar streams
+ streams = [x.clone() for _ in range(config.n_streams)]
+
+ # Coleccionar datos por nivel
+ terr_por_nivel = [terr_acts[0].cpu()] # nivel 0 = entrada
+ confianzas = []
+ lateral_scales = []
+ stream_norms = []
+ attn_norms = []
+
+ # 4. Pasar por cada nivel
+ for i, nivel in enumerate(model.niveles):
+ # --- Capturar escalas de LateralGate ANTES del forward ---
+ scales = nivel.lateral.scale.detach().cpu().tolist()
+ lateral_scales.append(scales)
+
+ # --- Forward del nivel ---
+ # Reproducimos el forward manualmente para capturar intermedios
+
+ # 4a. Representación combinada
+ x_combined = sum(
+ streams[t] * terr_acts[:, :, t : t + 1] for t in range(config.n_streams)
+ )
+
+ # 4b. Atención compartida
+ x_attn = nivel.drop(nivel.attn(nivel.norm_attn(x_combined)))
+ attn_norms.append(x_attn[0].norm(dim=-1).mean().item())
+
+ # 4c. Re-routing
+ terr_acts = nivel.talamo_nivel(
+ x_combined + x_attn, terr_acts, TalamoInicial.agregar_fn
+ )
+
+ # 4d. FFN por stream
+ new_streams = []
+ for t in range(config.n_streams):
+ h_normed = nivel.norm_streams[t](streams[t] + x_attn)
+ h = nivel.ffns[t](h_normed) * terr_acts[:, :, t : t + 1]
+ new_streams.append(streams[t] + nivel.drop(h))
+
+ # 4e. Lateral gates
+ streams = nivel.lateral(new_streams, terr_acts)
+
+ # 4f. Confianza Early Exit
+ x_out = sum(
+ streams[t] * terr_acts[:, :, t : t + 1] for t in range(config.n_streams)
+ )
+ per_token_conf = torch.sigmoid(nivel.exit_head(x_out)).squeeze(-1)
+ k = max(1, int(per_token_conf.numel() * config.exit_percentile))
+ conf = per_token_conf.reshape(-1).topk(k, largest=False).values.mean().item()
+ confianzas.append(conf)
+
+ # Capturar datos por nivel
+ terr_por_nivel.append(terr_acts[0].cpu())
+ norms = [
+ streams[t][0].norm(dim=-1).mean().item() for t in range(config.n_streams)
+ ]
+ stream_norms.append(norms)
+
+ return {
+ "zona_acts": zona_acts[0].cpu(), # [L, 52]
+ "terr_por_nivel": terr_por_nivel, # list[[L, 4]]
+ "confianza": confianzas, # [n_levels]
+ "lateral_scales": lateral_scales, # [n_levels, 4]
+ "stream_norms": stream_norms, # [n_levels, 4]
+ "attn_norms": attn_norms, # [n_levels]
+ }
+
+
+# =============================================================================
+# VISUALIZACIÓN: ACTIVACIONES TERRITORIALES
+# =============================================================================
+
+
+def mostrar_routing(tokens: list[str], info: dict) -> str:
+ """Muestra cómo el Tálamo enruta cada token a los 4 streams."""
+ lines = []
+ lines.append(f"\n{BOLD}═══ TÁLAMO: ROUTING INICIAL ═══{RESET}\n")
+ lines.append(
+ f" {'Token':<15} {'SINTAXIS':>10} {'SEMANTICA':>10} {'LOGICO':>10} {'ESTRUCTURAL':>10} Dominante"
+ )
+ lines.append(f" {'─' * 15} {'─' * 10} {'─' * 10} {'─' * 10} {'─' * 13} {'─' * 12}")
+
+ terr_0 = info["terr_por_nivel"][0] # [L, 4]
+
+ for i, tok in enumerate(tokens):
+ acts = terr_0[i].tolist()
+ dominant = max(range(4), key=lambda t: acts[t])
+ color = STREAM_COLORS[dominant]
+
+ tok_display = repr(tok).strip("'")[:14]
+ vals = " ".join(f"{a:10.3f}" for a in acts)
+ lines.append(
+ f" {tok_display:<15} {vals} {color}{STREAM_NAMES[dominant]}{RESET}"
+ )
+
+ return "\n".join(lines)
+
+
+# =============================================================================
+# VISUALIZACIÓN: ZONAS DE BRODMANN
+# =============================================================================
+
+
+def mostrar_zonas(tokens: list[str], info: dict) -> str:
+ """Muestra las zonas de Brodmann más activas por token."""
+ lines = []
+ lines.append(f"\n{BOLD}═══ ZONAS DE BRODMANN ACTIVAS ═══{RESET}\n")
+
+ zona_acts = info["zona_acts"] # [L, 52]
+
+ for i, tok in enumerate(tokens):
+ acts = zona_acts[i]
+ top5_idx = acts.topk(5).indices.tolist()
+ top5_vals = acts.topk(5).values.tolist()
+
+ tok_display = repr(tok).strip("'")[:12]
+ zonas_str = " ".join(
+ f"{heatmap_char(v)}{list(Zona)[idx].name[3:]:<12}{v:.2f}"
+ for idx, v in zip(top5_idx, top5_vals)
+ )
+ lines.append(f" {tok_display:<14} {zonas_str}")
+
+ return "\n".join(lines)
+
+
+# =============================================================================
+# VISUALIZACIÓN: EVOLUCIÓN POR NIVEL
+# =============================================================================
+
+
+def mostrar_evolucion(tokens: list[str], info: dict) -> str:
+ """Muestra cómo evolucionan las activaciones territoriales a través de los 5 niveles."""
+ lines = []
+ lines.append(f"\n{BOLD}═══ EVOLUCIÓN POR NIVEL (profundidad cortical) ═══{RESET}\n")
+
+ n_levels = len(info["confianza"])
+
+ for t_idx, name in enumerate(STREAM_NAMES):
+ color = STREAM_COLORS[t_idx]
+ lines.append(f" {color}{BOLD}{name}{RESET}")
+ lines.append(
+ f" {'Token':<12} "
+ + " ".join(f"{'N' + str(n):<8}" for n in range(n_levels + 1))
+ )
+
+ for i, tok in enumerate(tokens):
+ tok_display = repr(tok).strip("'")[:11]
+ vals = []
+ for n in range(n_levels + 1):
+ v = info["terr_por_nivel"][n][i, t_idx].item()
+ vals.append(f"{heatmap_char(v)} {v:.2f} ")
+ lines.append(f" {tok_display:<12} " + " ".join(vals))
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+# =============================================================================
+# VISUALIZACIÓN: FIBRAS BLANCAS (LateralGate)
+# =============================================================================
+
+
+def mostrar_fibras_blancas(info: dict) -> str:
+ """Muestra los pesos de comunicación lateral entre streams."""
+ lines = []
+ lines.append(f"\n{BOLD}═══ FIBRAS BLANCAS (LateralGate scales) ═══{RESET}")
+ lines.append(f" Escala aprendida de comunicación entre streams por nivel.\n")
+
+ scales = info["lateral_scales"] # [n_levels, 4]
+
+ lines.append(
+ f" {'Nivel':<8} "
+ + " ".join(
+ f"{STREAM_COLORS[t]}{name:<14}{RESET}"
+ for t, name in enumerate(STREAM_NAMES)
+ )
+ )
+ lines.append(f" {'─' * 8} " + " ".join("─" * 14 for _ in STREAM_NAMES))
+
+ for n, level_scales in enumerate(scales):
+ vals = " ".join(
+ f"{STREAM_COLORS[t]}{barra(abs(s), 10)}{RESET}"
+ for t, s in enumerate(level_scales)
+ )
+ lines.append(f" Nivel {n:<3} {vals}")
+
+ # Resumen: qué stream comunica más
+ avg_scales = [
+ sum(abs(scales[n][t]) for n in range(len(scales))) / len(scales)
+ for t in range(4)
+ ]
+ max_idx = max(range(4), key=lambda t: avg_scales[t])
+ lines.append(
+ f"\n Stream más comunicativo: {STREAM_COLORS[max_idx]}{BOLD}{STREAM_NAMES[max_idx]}{RESET} (escala promedio: {avg_scales[max_idx]:.4f})"
+ )
+
+ return "\n".join(lines)
+
+
+# =============================================================================
+# VISUALIZACIÓN: CONFIANZA EARLY EXIT
+# =============================================================================
+
+
+def mostrar_early_exit(info: dict) -> str:
+ """Muestra la confianza de early exit por nivel."""
+ lines = []
+ lines.append(f"\n{BOLD}═══ EARLY EXIT (confianza por nivel) ═══{RESET}")
+ lines.append(
+ f" Umbral: {PRESET_V3.umbral_exit:.0%} — mín {PRESET_V3.capas_min} niveles\n"
+ )
+
+ for n, conf in enumerate(info["confianza"]):
+ color = "\033[32m" if conf >= PRESET_V3.umbral_exit else "\033[31m"
+ marker = (
+ " ◄ EXIT"
+ if conf >= PRESET_V3.umbral_exit and n >= PRESET_V3.capas_min - 1
+ else ""
+ )
+ lines.append(f" Nivel {n} {barra(conf, 30, color)}{BOLD}{marker}{RESET}")
+
+ return "\n".join(lines)
+
+
+# =============================================================================
+# VISUALIZACIÓN: NORMAS DE STREAMS
+# =============================================================================
+
+
+def mostrar_stream_norms(info: dict) -> str:
+ """Muestra la norma L2 de cada stream por nivel (actividad del stream)."""
+ lines = []
+ lines.append(f"\n{BOLD}═══ ACTIVIDAD DE STREAMS (norma L2 promedio) ═══{RESET}\n")
+
+ norms = info["stream_norms"] # [n_levels, 4]
+ # Normalizar al max para visualización
+ max_norm = max(max(level) for level in norms)
+
+ lines.append(
+ f" {'Nivel':<8} "
+ + " ".join(
+ f"{STREAM_COLORS[t]}{name:<14}{RESET}"
+ for t, name in enumerate(STREAM_NAMES)
+ )
+ )
+ lines.append(f" {'─' * 8} " + " ".join("─" * 14 for _ in STREAM_NAMES))
+
+ for n, level_norms in enumerate(norms):
+ vals = " ".join(
+ f"{STREAM_COLORS[t]}{barra(v / max_norm, 10)}{RESET}"
+ for t, v in enumerate(level_norms)
+ )
+ lines.append(f" Nivel {n:<3} {vals}")
+
+ return "\n".join(lines)
+
+
+# =============================================================================
+# TERRITORY TABLE (reutiliza lógica de neuro_trainer)
+# =============================================================================
+
+
+def _build_territory_table(tokenizer: object) -> torch.Tensor:
+ """Construye lookup table: token_id → territorio target (0-3)."""
+ vocab_size = tokenizer.GetPieceSize()
+ table = torch.zeros(vocab_size, dtype=torch.long)
+ for token_id in range(vocab_size):
+ piece = tokenizer.IdToPiece(token_id)
+ zona, _conf = clasificar_token(piece)
+ table[token_id] = ZONA_TERRITORIO[zona].value
+ return table
+
+
+# =============================================================================
+# VISUALIZACIÓN: PRECISIÓN DE ROUTING VS LLAVES
+# =============================================================================
+
+
+def mostrar_precision(
+ tokens: list[str],
+ token_ids: list[int],
+ info: dict,
+ territory_table: torch.Tensor,
+) -> str:
+ """Compara routing actual vs territorio esperado de LLAVES por token."""
+ lines = []
+ lines.append(f"\n{BOLD}═══ PRECISIÓN DE ROUTING vs LLAVES ═══{RESET}\n")
+ lines.append(
+ f" {'Token':<15} {'Esperado':<14} {'Actual N0':<14} {'Actual N5':<14} {'N0':>3} {'N5':>3} Zona LLAVES"
+ )
+ lines.append(
+ f" {'─' * 15} {'─' * 14} {'─' * 14} {'─' * 14} {'─' * 3} {'─' * 3} {'─' * 20}"
+ )
+
+ n_levels = len(info["confianza"])
+ terr_0 = info["terr_por_nivel"][0]
+ terr_last = info["terr_por_nivel"][n_levels]
+
+ correct_n0 = 0
+ correct_nlast = 0
+ total = len(tokens)
+
+ for i, (tok, tid) in enumerate(zip(tokens, token_ids)):
+ expected = territory_table[tid].item()
+ actual_n0 = terr_0[i].argmax().item()
+ actual_nlast = terr_last[i].argmax().item()
+
+ # Clasificación LLAVES para mostrar la zona
+ zona, conf = clasificar_token(tok)
+
+ match_n0 = actual_n0 == expected
+ match_nlast = actual_nlast == expected
+ if match_n0:
+ correct_n0 += 1
+ if match_nlast:
+ correct_nlast += 1
+
+ sym_n0 = f"\033[32m✓{RESET}" if match_n0 else f"\033[31m✗{RESET}"
+ sym_nlast = f"\033[32m✓{RESET}" if match_nlast else f"\033[31m✗{RESET}"
+ exp_color = STREAM_COLORS[expected]
+ act0_color = STREAM_COLORS[actual_n0]
+ actL_color = STREAM_COLORS[actual_nlast]
+
+ tok_display = repr(tok).strip("'")[:14]
+ zona_str = f"{zona.name[3:]} ({conf:.0%})"
+ lines.append(
+ f" {tok_display:<15} "
+ f"{exp_color}{STREAM_NAMES[expected]:<14}{RESET}"
+ f"{act0_color}{STREAM_NAMES[actual_n0]:<14}{RESET}"
+ f"{actL_color}{STREAM_NAMES[actual_nlast]:<14}{RESET}"
+ f" {sym_n0} {sym_nlast} {zona_str}"
+ )
+
+ acc_n0 = correct_n0 / total * 100 if total > 0 else 0
+ acc_nlast = correct_nlast / total * 100 if total > 0 else 0
+ color_n0 = (
+ "\033[32m" if acc_n0 >= 80 else "\033[33m" if acc_n0 >= 50 else "\033[31m"
+ )
+ color_nlast = (
+ "\033[32m" if acc_nlast >= 80 else "\033[33m" if acc_nlast >= 50 else "\033[31m"
+ )
+
+ lines.append(
+ f"\n {BOLD}Accuracy N0: {color_n0}{acc_n0:.1f}%{RESET} ({correct_n0}/{total})"
+ )
+ lines.append(
+ f" {BOLD}Accuracy N{n_levels}: {color_nlast}{acc_nlast:.1f}%{RESET} ({correct_nlast}/{total})"
+ )
+
+ return "\n".join(lines)
+
+
+# =============================================================================
+# VISUALIZACIÓN: MARGEN DE ROUTING
+# =============================================================================
+
+
+def mostrar_margen(tokens: list[str], info: dict) -> str:
+ """Muestra el margen de decisión del routing (1er vs 2do stream)."""
+ lines = []
+ lines.append(f"\n{BOLD}═══ MARGEN DE ROUTING (confianza de decisión) ═══{RESET}")
+ lines.append(f" Margen = act(dominante) - act(segundo). Bajo = ambiguo.\n")
+
+ n_levels = len(info["confianza"])
+ terr_last = info["terr_por_nivel"][n_levels]
+
+ lines.append(
+ f" {'Token':<15} {'Dominante':<12} {'1er':>6} {'2do':>6} {'Margen':>8} Visual"
+ )
+ lines.append(f" {'─' * 15} {'─' * 12} {'─' * 6} {'─' * 6} {'─' * 8} {'─' * 20}")
+
+ margins = []
+ for i, tok in enumerate(tokens):
+ acts = terr_last[i].tolist()
+ sorted_acts = sorted(enumerate(acts), key=lambda x: x[1], reverse=True)
+ dominant = sorted_acts[0]
+ second = sorted_acts[1]
+ margin = dominant[1] - second[1]
+ margins.append(margin)
+
+ color = STREAM_COLORS[dominant[0]]
+ m_color = (
+ "\033[32m" if margin > 0.05 else "\033[33m" if margin > 0.02 else "\033[31m"
+ )
+
+ tok_display = repr(tok).strip("'")[:14]
+ lines.append(
+ f" {tok_display:<15} "
+ f"{color}{STREAM_NAMES[dominant[0]]:<12}{RESET}"
+ f"{dominant[1]:>6.3f} {second[1]:>6.3f} "
+ f"{m_color}{margin:>8.4f}{RESET} "
+ f"{barra(min(1.0, margin * 10), 15, m_color)}"
+ )
+
+ avg_margin = sum(margins) / len(margins) if margins else 0
+ min_margin = min(margins) if margins else 0
+ m_color = (
+ "\033[32m"
+ if avg_margin > 0.05
+ else "\033[33m"
+ if avg_margin > 0.02
+ else "\033[31m"
+ )
+ lines.append(f"\n {BOLD}Margen promedio: {m_color}{avg_margin:.4f}{RESET}")
+ lines.append(f" {BOLD}Margen mínimo: {m_color}{min_margin:.4f}{RESET}")
+ if min_margin < 0.01:
+ lines.append(
+ f" {BOLD}\033[31m⚠ Tokens con margen <0.01 → routing casi aleatorio{RESET}"
+ )
+
+ return "\n".join(lines)
+
+
+# =============================================================================
+# VISUALIZACIÓN: RESUMEN CUANTITATIVO
+# =============================================================================
+
+
+def mostrar_resumen(
+ tokens: list[str],
+ token_ids: list[int],
+ info: dict,
+ territory_table: torch.Tensor,
+) -> str:
+ """Panel de métricas agregadas para evaluación rápida."""
+ lines = []
+ lines.append(f"\n{BOLD}═══ RESUMEN DE SALUD DEL MODELO ═══{RESET}\n")
+
+ n_levels = len(info["confianza"])
+ terr_0 = info["terr_por_nivel"][0]
+ terr_last = info["terr_por_nivel"][n_levels]
+
+ # 1. Routing accuracy
+ correct_n0 = sum(
+ 1
+ for i, tid in enumerate(token_ids)
+ if terr_0[i].argmax().item() == territory_table[tid].item()
+ )
+ correct_nlast = sum(
+ 1
+ for i, tid in enumerate(token_ids)
+ if terr_last[i].argmax().item() == territory_table[tid].item()
+ )
+ total = len(tokens)
+ acc_n0 = correct_n0 / total * 100
+ acc_nlast = correct_nlast / total * 100
+
+ # 2. Routing margin
+ margins = []
+ for i in range(total):
+ acts = terr_last[i].tolist()
+ sorted_acts = sorted(acts, reverse=True)
+ margins.append(sorted_acts[0] - sorted_acts[1])
+ avg_margin = sum(margins) / len(margins)
+ min_margin = min(margins)
+
+ # 3. Routing std (diferenciación)
+ stds = [terr_last[i].std().item() for i in range(total)]
+ avg_std = sum(stds) / len(stds)
+
+ # 4. Early Exit
+ max_conf = max(info["confianza"])
+ exit_ok = max_conf >= PRESET_V3.umbral_exit
+
+ # 5. Stream balance
+ dominant_counts = [0, 0, 0, 0]
+ for i in range(total):
+ d = terr_last[i].argmax().item()
+ dominant_counts[d] += 1
+ gini = _gini_coefficient(dominant_counts)
+
+ def status(val: bool) -> str:
+ return f"\033[32m● PASS{RESET}" if val else f"\033[31m● FAIL{RESET}"
+
+ lines.append(f" {'Métrica':<35} {'Valor':>10} Estado")
+ lines.append(f" {'─' * 35} {'─' * 10} {'─' * 12}")
+ lines.append(
+ f" {'Routing accuracy N0':<35} {acc_n0:>9.1f}% {status(acc_n0 >= 70)}"
+ )
+ lines.append(
+ f" {'Routing accuracy N' + str(n_levels):<35} {acc_nlast:>9.1f}% {status(acc_nlast >= 70)}"
+ )
+ lines.append(
+ f" {'Margen promedio':<35} {avg_margin:>10.4f} {status(avg_margin > 0.02)}"
+ )
+ lines.append(
+ f" {'Margen mínimo':<35} {min_margin:>10.4f} {status(min_margin > 0.005)}"
+ )
+ lines.append(
+ f" {'Diferenciación (std promedio)':<35} {avg_std:>10.4f} {status(avg_std > 0.02)}"
+ )
+ lines.append(
+ f" {'Early Exit max confianza':<35} {max_conf:>9.1%} {status(exit_ok)}"
+ )
+ lines.append(
+ f" {'Diversidad routing (1-Gini)':<35} {1 - gini:>10.3f} {status(gini < 0.6)}"
+ )
+ lines.append(
+ f" {'Distribución':<35} "
+ + " ".join(
+ f"{STREAM_COLORS[t]}{STREAM_NAMES[t][:4]}={dominant_counts[t]}{RESET}"
+ for t in range(4)
+ )
+ )
+
+ # Score global (0-100)
+ score = (
+ min(acc_nlast, 100) * 0.35
+ + min(avg_margin * 1000, 100) * 0.20
+ + min(avg_std * 1000, 100) * 0.15
+ + (100 if exit_ok else max_conf * 100) * 0.15
+ + (1 - gini) * 100 * 0.15
+ )
+ s_color = "\033[32m" if score >= 70 else "\033[33m" if score >= 40 else "\033[31m"
+ lines.append(f"\n {BOLD}Score global: {s_color}{score:.0f}/100{RESET}")
+
+ return "\n".join(lines)
+
+
+def _gini_coefficient(counts: list[int]) -> float:
+ """Calcula coeficiente de Gini (0 = perfecto, 1 = todo en 1 clase)."""
+ n = len(counts)
+ total = sum(counts)
+ if total == 0:
+ return 0.0
+ sorted_c = sorted(counts)
+ cumulative = 0.0
+ gini_sum = 0.0
+ for c in sorted_c:
+ cumulative += c
+ gini_sum += cumulative
+ return 1 - (2 * gini_sum - total) / (total * n)
+
+
+# =============================================================================
+# SUITE: BATERÍA DE TESTS DIVERSA
+# =============================================================================
+
+
+def ejecutar_suite(
+ model: PamparV3,
+ tokenizer: object,
+ territory_table: torch.Tensor,
+ device: torch.device,
+) -> str:
+ """Ejecuta la suite completa de código y agrega métricas."""
+ lines = []
+ lines.append(f"\n{BOLD}{'═' * 70}")
+ lines.append(f" SUITE DE DIAGNÓSTICO COMPLETA — {len(CODE_SUITE)} muestras")
+ lines.append(f"{'═' * 70}{RESET}\n")
+
+ all_correct_n0 = 0
+ all_correct_nlast = 0
+ all_total = 0
+ all_margins: list[float] = []
+ all_max_conf: list[float] = []
+ per_sample: list[dict] = []
+
+ for label, code in CODE_SUITE:
+ token_ids = tokenizer.Encode(code, out_type=int)
+ tokens_str = [tokenizer.IdToPiece(tid) for tid in token_ids]
+ input_tensor = torch.tensor([token_ids], dtype=torch.long, device=device)
+
+ with torch.no_grad():
+ info = forward_instrumentado(model, input_tensor)
+
+ n_levels = len(info["confianza"])
+ terr_0 = info["terr_por_nivel"][0]
+ terr_last = info["terr_por_nivel"][n_levels]
+
+ correct_n0 = 0
+ correct_nlast = 0
+ margins: list[float] = []
+
+ for i, tid in enumerate(token_ids):
+ expected = territory_table[tid].item()
+ actual_n0 = terr_0[i].argmax().item()
+ actual_nlast = terr_last[i].argmax().item()
+ if actual_n0 == expected:
+ correct_n0 += 1
+ if actual_nlast == expected:
+ correct_nlast += 1
+
+ acts = terr_last[i].tolist()
+ sorted_acts = sorted(acts, reverse=True)
+ margins.append(sorted_acts[0] - sorted_acts[1])
+
+ n = len(token_ids)
+ acc_n0 = correct_n0 / n * 100
+ acc_nlast = correct_nlast / n * 100
+ avg_margin = sum(margins) / len(margins)
+ max_conf = max(info["confianza"])
+
+ all_correct_n0 += correct_n0
+ all_correct_nlast += correct_nlast
+ all_total += n
+ all_margins.extend(margins)
+ all_max_conf.append(max_conf)
+
+ per_sample.append(
+ {
+ "label": label,
+ "code": code.split("\n")[0][:40],
+ "tokens": n,
+ "acc_n0": acc_n0,
+ "acc_nlast": acc_nlast,
+ "margin": avg_margin,
+ "max_conf": max_conf,
+ }
+ )
+
+ # Tabla de resultados
+ lines.append(
+ f" {'Muestra':<16} {'Código':<42} {'Tok':>3} {'AccN0':>6} {'AccN5':>6} {'Marg':>6} {'Exit':>5}"
+ )
+ lines.append(
+ f" {'─' * 16} {'─' * 42} {'─' * 3} {'─' * 6} {'─' * 6} {'─' * 6} {'─' * 5}"
+ )
+
+ for s in per_sample:
+ c_n0 = "\033[32m" if s["acc_n0"] >= 70 else "\033[31m"
+ c_nl = "\033[32m" if s["acc_nlast"] >= 70 else "\033[31m"
+ c_m = "\033[32m" if s["margin"] > 0.02 else "\033[33m"
+ c_e = "\033[32m" if s["max_conf"] >= 0.9 else "\033[31m"
+ lines.append(
+ f" {s['label']:<16} {s['code']:<42} {s['tokens']:>3} "
+ f"{c_n0}{s['acc_n0']:>5.1f}%{RESET} "
+ f"{c_nl}{s['acc_nlast']:>5.1f}%{RESET} "
+ f"{c_m}{s['margin']:>6.4f}{RESET} "
+ f"{c_e}{s['max_conf']:>4.1%}{RESET}"
+ )
+
+ # Agregados
+ global_acc_n0 = all_correct_n0 / all_total * 100
+ global_acc_nlast = all_correct_nlast / all_total * 100
+ global_margin = sum(all_margins) / len(all_margins)
+ global_exit = sum(all_max_conf) / len(all_max_conf)
+ min_acc = min(s["acc_nlast"] for s in per_sample)
+ worst = [s for s in per_sample if s["acc_nlast"] == min_acc][0]
+
+ lines.append(f"\n {'─' * 90}")
+ c_g = "\033[32m" if global_acc_nlast >= 70 else "\033[31m"
+ lines.append(
+ f" {BOLD}GLOBAL{RESET} Tokens: {all_total} "
+ f"AccN0: {global_acc_n0:.1f}% "
+ f"{BOLD}AccN5: {c_g}{global_acc_nlast:.1f}%{RESET} "
+ f"Margen: {global_margin:.4f} "
+ f"Exit promedio: {global_exit:.1%}"
+ )
+ lines.append(
+ f" {BOLD}Peor muestra:{RESET} {worst['label']} → acc={worst['acc_nlast']:.1f}%"
+ )
+
+ # Score
+ score = (
+ min(global_acc_nlast, 100) * 0.40
+ + min(global_margin * 1000, 100) * 0.25
+ + min(global_exit * 100, 100) * 0.15
+ + min(min_acc, 100) * 0.20
+ )
+ s_color = "\033[32m" if score >= 70 else "\033[33m" if score >= 40 else "\033[31m"
+ lines.append(f"\n {BOLD}Score Suite: {s_color}{score:.0f}/100{RESET}")
+
+ return "\n".join(lines)
+
+
+# =============================================================================
+# COMPARACIÓN DE CHECKPOINTS
+# =============================================================================
+
+
+def comparar_checkpoints(
+ ckpt_a: Path,
+ ckpt_b: Path,
+ device: torch.device,
+) -> str:
+ """Compara dos checkpoints lado a lado con métricas clave."""
+ lines = []
+ lines.append(f"\n{BOLD}{'═' * 70}")
+ lines.append(f" COMPARACIÓN DE CHECKPOINTS")
+ lines.append(f"{'═' * 70}{RESET}")
+ lines.append(f" A: {ckpt_a.name}")
+ lines.append(f" B: {ckpt_b.name}\n")
+
+ results: dict[str, dict] = {}
+ for label, path in [("A", ckpt_a), ("B", ckpt_b)]:
+ model, tokenizer = load_model(path, device, verbose=False)
+ territory_table = _build_territory_table(tokenizer)
+
+ acc_n0_total = 0
+ acc_nlast_total = 0
+ total_tokens = 0
+ margins: list[float] = []
+ confs: list[float] = []
+
+ for _, code in CODE_SUITE:
+ token_ids = tokenizer.Encode(code, out_type=int)
+ input_tensor = torch.tensor([token_ids], dtype=torch.long, device=device)
+ with torch.no_grad():
+ info = forward_instrumentado(model, input_tensor)
+
+ n_levels = len(info["confianza"])
+ terr_0 = info["terr_por_nivel"][0]
+ terr_last = info["terr_por_nivel"][n_levels]
+
+ for i, tid in enumerate(token_ids):
+ expected = territory_table[tid].item()
+ if terr_0[i].argmax().item() == expected:
+ acc_n0_total += 1
+ if terr_last[i].argmax().item() == expected:
+ acc_nlast_total += 1
+ acts = terr_last[i].tolist()
+ sorted_a = sorted(acts, reverse=True)
+ margins.append(sorted_a[0] - sorted_a[1])
+ total_tokens += 1
+
+ confs.append(max(info["confianza"]))
+
+ # Diferenciación promedio (std de routing)
+ avg_std = 0.0
+ std_count = 0
+ for _, code in CODE_SUITE[:5]:
+ token_ids = tokenizer.Encode(code, out_type=int)
+ input_tensor = torch.tensor([token_ids], dtype=torch.long, device=device)
+ with torch.no_grad():
+ info2 = forward_instrumentado(model, input_tensor)
+ n_levels = len(info2["confianza"])
+ for i in range(len(token_ids)):
+ avg_std += info2["terr_por_nivel"][n_levels][i].std().item()
+ std_count += 1
+ avg_std /= max(std_count, 1)
+
+ results[label] = {
+ "acc_n0": acc_n0_total / total_tokens * 100,
+ "acc_nlast": acc_nlast_total / total_tokens * 100,
+ "margin": sum(margins) / len(margins),
+ "min_margin": min(margins),
+ "exit_avg": sum(confs) / len(confs),
+ "exit_max": max(confs),
+ "diff_std": avg_std,
+ }
+
+ del model
+ if device.type == "cuda":
+ torch.cuda.empty_cache()
+
+ # Tabla comparativa
+ lines.append(
+ f" {'Métrica':<30} {'Ckpt A':>10} {'Ckpt B':>10} {'Delta':>10} Mejor"
+ )
+ lines.append(f" {'─' * 30} {'─' * 10} {'─' * 10} {'─' * 10} {'─' * 6}")
+
+ metrics = [
+ ("Accuracy N0", "acc_n0", "%", True),
+ ("Accuracy N5", "acc_nlast", "%", True),
+ ("Margen promedio", "margin", "", True),
+ ("Margen mínimo", "min_margin", "", True),
+ ("Exit promedio", "exit_avg", "%", True),
+ ("Exit máximo", "exit_max", "%", True),
+ ("Diferenciación (std)", "diff_std", "", True),
+ ]
+
+ for name, key, unit, higher_better in metrics:
+ va = results["A"][key]
+ vb = results["B"][key]
+ delta = vb - va
+ is_pct = unit == "%"
+ fmt = ".1f" if is_pct else ".4f"
+ suf = "%" if is_pct else ""
+
+ better = "B" if (delta > 0) == higher_better else "A" if delta != 0 else "="
+ b_color = "\033[32m" if better == "B" else "\033[33m" if better == "A" else ""
+ d_sign = "+" if delta > 0 else ""
+
+ lines.append(
+ f" {name:<30} {va:>9{fmt}}{suf} {vb:>9{fmt}}{suf} "
+ f"{b_color}{d_sign}{delta:>9{fmt}}{suf}{RESET} {better}"
+ )
+
+ return "\n".join(lines)
+
+
+# =============================================================================
+# GENERACIÓN DE CÓDIGO
+# =============================================================================
+
+
+def mostrar_generacion(
+ model: PamparV3,
+ tokenizer: object,
+ prompt: str,
+ device: torch.device,
+ max_tokens: int = 128,
+ temperature: float = 0.7,
+) -> str:
+ """Genera código desde un prompt y muestra el resultado."""
+ lines = []
+ lines.append(f"\n{BOLD}═══ GENERACIÓN DE CÓDIGO ═══{RESET}")
+ lines.append(f" Prompt: {prompt}")
+ lines.append(f" Params: max_tokens={max_tokens}, temperature={temperature}\n")
+
+ prompt_ids = tokenizer.Encode(prompt, out_type=int)
+ input_tensor = torch.tensor([prompt_ids], dtype=torch.long, device=device)
+
+ with torch.no_grad():
+ output_ids = model.generate(
+ input_tensor,
+ max_tokens=max_tokens,
+ temperature=temperature,
+ )
+
+ generated_ids = output_ids[0].tolist()
+ generated_text = tokenizer.Decode(generated_ids)
+
+ # Separar prompt del generado
+ prompt_text = tokenizer.Decode(prompt_ids)
+ new_text = generated_text[len(prompt_text) :]
+
+ lines.append(f" {DIM}{'─' * 60}{RESET}")
+ lines.append(f" {DIM}{prompt_text}{RESET}{BOLD}{new_text}{RESET}")
+ lines.append(f" {DIM}{'─' * 60}{RESET}")
+ lines.append(f" Tokens generados: {len(generated_ids) - len(prompt_ids)}")
+
+ # Análisis del routing de lo generado
+ with torch.no_grad():
+ info = forward_instrumentado(
+ model, output_ids[:, : min(64, output_ids.shape[1])]
+ )
+
+ n_levels = len(info["confianza"])
+ terr_last = info["terr_por_nivel"][n_levels]
+ gen_start = len(prompt_ids)
+ gen_end = min(64, output_ids.shape[1])
+
+ if gen_end > gen_start:
+ dominant_counts = [0, 0, 0, 0]
+ for i in range(gen_start, gen_end):
+ d = terr_last[i].argmax().item()
+ dominant_counts[d] += 1
+ n_gen = gen_end - gen_start
+ lines.append(
+ f"\n Routing generado: "
+ + " ".join(
+ f"{STREAM_COLORS[t]}{STREAM_NAMES[t][:4]}={dominant_counts[t]}/{n_gen}{RESET}"
+ for t in range(4)
+ )
+ )
+
+ return "\n".join(lines)
+
+
+# =============================================================================
+# VISUALIZACIÓN: PESOS DEL MODELO
+# =============================================================================
+
+
+def mostrar_pesos(model: PamparV3) -> str:
+ """Muestra distribución de pesos por componente del modelo."""
+ lines = []
+ lines.append(f"\n{BOLD}═══ ANATOMÍA DE PESOS ═══{RESET}\n")
+
+ stats = model.count_params()
+ total = stats["total"]
+
+ componentes = {
+ "Embedding (tok_emb)": stats["embeddings"],
+ "Tálamo Inicial": stats["talamo_inicial"],
+ "Niveles (5×NivelProfundo)": stats["niveles"],
+ "Norm Final": stats["norm_f"],
+ }
+
+ lines.append(f" {'Componente':<30} {'Params':>12} {'%':>8} {'Distribución':>20}")
+ lines.append(f" {'─' * 30} {'─' * 12} {'─' * 8} {'─' * 20}")
+
+ for name, count in componentes.items():
+ pct = count / total
+ lines.append(f" {name:<30} {count:>12,} {pct:>7.1%} {barra(pct, 20)}")
+
+ lines.append(f"\n {BOLD}Total: {total:,} parámetros ({total / 1e6:.1f}M){RESET}")
+
+ # Detalle por nivel
+ lines.append(f"\n {BOLD}Detalle por nivel:{RESET}")
+ for i, nivel in enumerate(model.niveles):
+ attn_p = sum(p.numel() for p in nivel.attn.parameters())
+ ffn_p = sum(p.numel() for p in nivel.ffns.parameters())
+ lat_p = sum(p.numel() for p in nivel.lateral.parameters())
+ tal_p = sum(p.numel() for p in nivel.talamo_nivel.parameters())
+ exit_p = sum(p.numel() for p in nivel.exit_head.parameters())
+ nivel_total = attn_p + ffn_p + lat_p + tal_p + exit_p
+
+ lines.append(
+ f"\n Nivel {i}: {nivel_total:,} params ({nivel_total / 1e6:.1f}M)"
+ )
+ lines.append(
+ f" Atención GQA: {attn_p:>10,} {barra(attn_p / nivel_total, 15)}"
+ )
+ lines.append(
+ f" 4× StreamFFN: {ffn_p:>10,} {barra(ffn_p / nivel_total, 15)}"
+ )
+ lines.append(
+ f" LateralGate: {lat_p:>10,} {barra(lat_p / nivel_total, 15)}"
+ )
+ lines.append(
+ f" TalamoNivel: {tal_p:>10,} {barra(tal_p / nivel_total, 15)}"
+ )
+ lines.append(
+ f" Exit Head: {exit_p:>10,} {barra(exit_p / nivel_total, 15)}"
+ )
+
+ # Distribución de magnitud de pesos
+ lines.append(f"\n {BOLD}Salud de pesos (magnitud):{RESET}")
+ for name, param in model.named_parameters():
+ if param.numel() < 1000:
+ continue
+ data = param.detach().float().cpu()
+ mean_abs = data.abs().mean().item()
+ std = data.std().item()
+ dead = (data.abs() < 1e-6).float().mean().item()
+
+ # Alertas
+ alert = ""
+ if dead > 0.5:
+ alert = f" \033[31m⚠ {dead:.0%} muertos{RESET}"
+ elif std < 1e-5:
+ alert = f" \033[33m⚠ baja varianza{RESET}"
+
+ if alert:
+ short_name = (
+ name.replace("niveles.", "N")
+ .replace("ffns.", "FFN")
+ .replace("lateral.", "Lat.")
+ )
+ lines.append(
+ f" {short_name:<45} μ|w|={mean_abs:.4f} σ={std:.4f}{alert}"
+ )
+
+ return "\n".join(lines)
+
+
+# =============================================================================
+# EXPORTAR HTML
+# =============================================================================
+
+
+def exportar_html(
+ tokens: list[str],
+ token_ids: list[int],
+ info: dict,
+ output_path: Path,
+ code: str,
+ territory_table: torch.Tensor,
+) -> None:
+ """Exporta el scan como un HTML auto-contenido con métricas de precisión."""
+ n_levels = len(info["confianza"])
+
+ # Heatmap territorial por token (nivel 0)
+ terr_0 = info["terr_por_nivel"][0]
+ terr_last = info["terr_por_nivel"][n_levels]
+
+ rows_html = []
+ correct_n0 = 0
+ correct_nlast = 0
+ total = len(tokens)
+ margins = []
+
+ for i, (tok, tid) in enumerate(zip(tokens, token_ids)):
+ acts = terr_0[i].tolist()
+ acts_last = terr_last[i].tolist()
+ dominant = max(range(4), key=lambda t: acts[t])
+ dominant_last = max(range(4), key=lambda t: acts_last[t])
+ expected = territory_table[tid].item()
+
+ match_n0 = dominant == expected
+ match_nlast = dominant_last == expected
+ if match_n0:
+ correct_n0 += 1
+ if match_nlast:
+ correct_nlast += 1
+
+ sorted_a = sorted(acts_last, reverse=True)
+ margins.append(sorted_a[0] - sorted_a[1])
+
+ cells = "".join(
+ f'{acts[t]:.3f} '
+ for t in range(4)
+ )
+ match_sym = "✓" if match_nlast else "✗"
+ match_color = "#a6e3a1" if match_nlast else "#f38ba8"
+ tok_esc = tok.replace("&", "&").replace("<", "<").replace(">", ">")
+ rows_html.append(
+ f'{tok_esc} {cells}'
+ f'{STREAM_NAMES[dominant]} '
+ f'{STREAM_NAMES[expected]} '
+ f'{match_sym} '
+ )
+
+ acc_n0 = correct_n0 / total * 100 if total > 0 else 0
+ acc_nlast = correct_nlast / total * 100 if total > 0 else 0
+ avg_margin = sum(margins) / len(margins) if margins else 0
+
+ # Evolución heatmap
+ evo_rows = []
+ for i, tok in enumerate(tokens):
+ tok_esc = tok.replace("&", "&").replace("<", "<").replace(">", ">")
+ cells = ""
+ for n in range(n_levels + 1):
+ acts = info["terr_por_nivel"][n][i].tolist()
+ dominant = max(range(4), key=lambda t: acts[t])
+ cells += f'{max(acts):.2f} '
+ evo_rows.append(f'{tok_esc} {cells} ')
+
+ # Confianza
+ conf_bars = ""
+ max_conf = 0.0
+ for n, conf in enumerate(info["confianza"]):
+ color = "#4caf50" if conf >= PRESET_V3.umbral_exit else "#f44336"
+ conf_bars += f''
+ max_conf = max(max_conf, conf)
+
+ # Score
+ stds = [terr_last[i].std().item() for i in range(total)]
+ avg_std = sum(stds) / len(stds) if stds else 0
+ score = (
+ min(acc_nlast, 100) * 0.35
+ + min(avg_margin * 1000, 100) * 0.20
+ + min(avg_std * 1000, 100) * 0.15
+ + (100 if max_conf >= 0.9 else max_conf * 100) * 0.15
+ + 50 * 0.15
+ )
+ score_color = "#a6e3a1" if score >= 70 else "#f9e2af" if score >= 40 else "#f38ba8"
+
+ html = f"""
+
+
+
+PAMPAr Brain Scanner
+
+
+
+🧠 PAMPAr Brain Scanner
+{code.replace("&", "&").replace("<", "<")}
+
+Métricas de Salud
+
+
{score:.0f}/100
Score Global
+
= 70 else "#f38ba8"}">{acc_nlast:.1f}%
Accuracy Routing
+
0.02 else "#f9e2af"}">{avg_margin:.4f}
Margen Promedio
+
= 0.9 else "#f38ba8"}">{max_conf:.1%}
Early Exit Max
+
{avg_std:.4f}
Diferenciación
+
+
+Tálamo: Routing Inicial
+
+Token SINTAXIS SEMANTICA LOGICO ESTRUCTURAL Actual Esperado OK
+{"".join(rows_html)}
+
+
+Evolución por Nivel
+
+Token {"".join(f"N{n} " for n in range(n_levels + 1))}
+{"".join(evo_rows)}
+
+
+Early Exit
+{conf_bars}
+Umbral: {PRESET_V3.umbral_exit:.0%} — Mín {PRESET_V3.capas_min} niveles
+
+
+"""
+
+ output_path.write_text(html, encoding="utf-8")
+
+
+def _stream_rgb(idx: int) -> str:
+ """RGB para cada stream (sin alpha)."""
+ return ["137,180,250", "166,227,161", "249,226,175", "203,166,247"][idx]
+
+
+def _stream_hex(idx: int) -> str:
+ """Color hex para cada stream."""
+ return ["#89b4fa", "#a6e3a1", "#f9e2af", "#cba6f7"][idx]
+
+
+# =============================================================================
+# MAIN
+# =============================================================================
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="PAMPAr Brain Scanner — Diagnóstico completo de la arquitectura cerebral",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument(
+ "--code",
+ type=str,
+ default=None,
+ help="Código Python a analizar (ej: 'def fibonacci(n):')",
+ )
+ parser.add_argument(
+ "--suite",
+ action="store_true",
+ help="Ejecutar suite completa de diagnóstico (20 muestras diversas)",
+ )
+ parser.add_argument(
+ "--compare",
+ nargs=2,
+ metavar=("CKPT_A", "CKPT_B"),
+ help="Comparar dos checkpoints lado a lado",
+ )
+ parser.add_argument(
+ "--generate",
+ type=str,
+ default=None,
+ help="Generar código desde un prompt y analizar routing",
+ )
+ parser.add_argument(
+ "--weights",
+ action="store_true",
+ help="Mostrar distribución de pesos del modelo",
+ )
+ parser.add_argument(
+ "--checkpoint",
+ type=str,
+ default=str(PROJECT_ROOT / "checkpoints" / "v3_sft_v8.pt"),
+ help="Path al checkpoint (.pt)",
+ )
+ parser.add_argument(
+ "--device",
+ type=str,
+ default="auto",
+ choices=["auto", "cuda", "cpu"],
+ )
+ parser.add_argument(
+ "--html",
+ type=str,
+ default=None,
+ help="Exportar resultado como HTML (ej: scan.html)",
+ )
+
+ args = parser.parse_args()
+
+ has_action = (
+ args.code or args.weights or args.suite or args.compare or args.generate
+ )
+ if not has_action:
+ parser.error(
+ "Necesitas al menos uno de: --code, --suite, --compare, --generate, --weights"
+ )
+
+ # Resolver device
+ if args.device == "auto":
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ else:
+ device = torch.device(args.device)
+
+ print(f"\n{BOLD}🧠 PAMPAr Brain Scanner v2{RESET}")
+ print(f" Device: {device}")
+
+ # --- Comparación de checkpoints (no necesita cargar modelo) ---
+ if args.compare:
+ ckpt_a = Path(args.compare[0])
+ ckpt_b = Path(args.compare[1])
+ for p in [ckpt_a, ckpt_b]:
+ if not p.exists():
+ print(f"\033[31mError: Checkpoint no encontrado: {p}{RESET}")
+ sys.exit(1)
+ print(comparar_checkpoints(ckpt_a, ckpt_b, device))
+ print()
+ return
+
+ # Cargar modelo para los demás modos
+ print(f" Checkpoint: {args.checkpoint}\n")
+ ckpt_path = Path(args.checkpoint)
+ if not ckpt_path.exists():
+ print(f"\033[31mError: Checkpoint no encontrado: {ckpt_path}{RESET}")
+ sys.exit(1)
+
+ model, tokenizer = load_model(ckpt_path, device, verbose=False)
+ print(
+ f" Modelo cargado: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M params"
+ )
+
+ # Construir territory table
+ territory_table = _build_territory_table(tokenizer)
+ print(f" Territory table: {territory_table.shape[0]} tokens mapeados\n")
+
+ # --- Análisis de pesos ---
+ if args.weights:
+ print(mostrar_pesos(model))
+
+ # --- Suite de diagnóstico ---
+ if args.suite:
+ print(ejecutar_suite(model, tokenizer, territory_table, device))
+
+ # --- Generación ---
+ if args.generate:
+ print(mostrar_generacion(model, tokenizer, args.generate, device))
+
+ # --- Análisis de código ---
+ if args.code:
+ tokens_ids = tokenizer.Encode(args.code, out_type=int)
+ tokens_str = [tokenizer.IdToPiece(tid) for tid in tokens_ids]
+
+ print(f" Código: {BOLD}{args.code}{RESET}")
+ print(f" Tokens: {len(tokens_str)} → {tokens_str}\n")
+
+ input_tensor = torch.tensor([tokens_ids], dtype=torch.long, device=device)
+ info = forward_instrumentado(model, input_tensor)
+
+ # Mostrar todas las visualizaciones
+ print(mostrar_routing(tokens_str, info))
+ print(mostrar_precision(tokens_str, tokens_ids, info, territory_table))
+ print(mostrar_margen(tokens_str, info))
+ print(mostrar_zonas(tokens_str, info))
+ print(mostrar_evolucion(tokens_str, info))
+ print(mostrar_fibras_blancas(info))
+ print(mostrar_early_exit(info))
+ print(mostrar_stream_norms(info))
+ print(mostrar_resumen(tokens_str, tokens_ids, info, territory_table))
+
+ # Exportar HTML si se pidió
+ if args.html:
+ html_path = Path(args.html)
+ exportar_html(
+ tokens_str, tokens_ids, info, html_path, args.code, territory_table
+ )
+ print(f"\n {BOLD}HTML exportado:{RESET} {html_path.resolve()}")
+
+ print()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/build_bilingual_tokenizer.py b/scripts/build_bilingual_tokenizer.py
new file mode 100644
index 0000000000000000000000000000000000000000..5ca58e17a6e75058f169e474d4e84c82817c8b3a
--- /dev/null
+++ b/scripts/build_bilingual_tokenizer.py
@@ -0,0 +1,302 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Descargar texto español masivo y re-entrenar tokenizer 48K.
+
+Usa Wikipedia español de HuggingFace (gratuito, sin auth).
+Luego re-entrena SentencePiece con corpus código + español.
+"""
+import os
+import sys
+import json
+from pathlib import Path
+
+DATA_DIR = Path(__file__).parent.parent / "data"
+TOKENIZER_DIR = DATA_DIR / "tokenizer"
+SPANISH_FILE = TOKENIZER_DIR / "spanish_wiki.txt"
+CORPUS_FILE = TOKENIZER_DIR / "corpus_48k.txt"
+OUTPUT_PREFIX = str(TOKENIZER_DIR / "pampar_48k")
+
+TARGET_ES_MB = 500 # Queremos ~500MB de español
+
+
+def download_spanish_wiki():
+ """Descargar Wikipedia español desde HuggingFace datasets."""
+ from datasets import load_dataset
+
+ print("📥 Descargando Wikipedia ES desde HuggingFace...")
+ print(" Dataset: wikimedia/wikipedia, 20231101.es")
+ print(" Esto puede tardar 3-10 minutos...\n")
+
+ ds = load_dataset(
+ "wikimedia/wikipedia",
+ "20231101.es",
+ split="train",
+ streaming=True,
+ )
+
+ count = 0
+ with open(SPANISH_FILE, "w", encoding="utf-8") as f:
+ for example in ds:
+ text = example.get("text", "")
+ if len(text) > 200: # Solo artículos sustanciales
+ f.write(text.strip() + "\n")
+ count += 1
+
+ if count % 50000 == 0:
+ size_mb = SPANISH_FILE.stat().st_size / 1024**2
+ print(f" {count:,} artículos ({size_mb:.0f} MB)")
+
+ if size_mb >= TARGET_ES_MB:
+ print(f" Alcanzado objetivo de {TARGET_ES_MB} MB")
+ break
+
+ size_mb = SPANISH_FILE.stat().st_size / 1024**2
+ print(f"\n✅ Wikipedia ES: {count:,} artículos, {size_mb:.0f} MB")
+ return count
+
+
+def rebuild_corpus():
+ """Reconstruir corpus combinando código + español + código ES."""
+ print("\n📦 Reconstruyendo corpus bilingüe...")
+
+ # 1. Código existente (nuestros JSONL)
+ code_files = [
+ DATA_DIR / "code" / "github_code.jsonl",
+ DATA_DIR / "code" / "train_massive.jsonl",
+ DATA_DIR / "code" / "train.jsonl",
+ DATA_DIR / "distillation" / "codealpaca_20k.jsonl",
+ DATA_DIR / "distillation" / "evol_instruct_code_80k.jsonl",
+ DATA_DIR / "distillation" / "codeexercises_python.jsonl",
+ DATA_DIR / "distillation" / "distillation_data.jsonl",
+ ]
+
+ total = 0
+ with open(CORPUS_FILE, "w", encoding="utf-8") as out:
+ # Código
+ print(" Código:")
+ for jsonl_path in code_files:
+ if not jsonl_path.exists():
+ continue
+ size_mb = jsonl_path.stat().st_size / (1024 * 1024)
+ file_count = 0
+ with open(jsonl_path, "r", encoding="utf-8", errors="replace") as f:
+ for line in f:
+ try:
+ data = json.loads(line)
+ text = data.get("text", "")
+ if len(text) > 50:
+ out.write(text.strip() + "\n")
+ total += 1
+ file_count += 1
+ except (json.JSONDecodeError, KeyError):
+ continue
+ print(f" {jsonl_path.name}: {file_count:,} docs ({size_mb:.1f} MB)")
+
+ # Español de Wikipedia
+ if SPANISH_FILE.exists():
+ print(f" Wikipedia ES:")
+ es_count = 0
+ with open(SPANISH_FILE, "r", encoding="utf-8") as f:
+ for line in f:
+ if len(line.strip()) > 50:
+ out.write(line)
+ total += 1
+ es_count += 1
+ print(f" {es_count:,} artículos")
+
+ # Español sintético (términos de programación)
+ print(" Código español sintético:")
+ es_code = generate_spanish_code()
+ for line in es_code:
+ out.write(line + "\n")
+ total += 1
+ print(f" {len(es_code):,} líneas")
+
+ # Corpus existente anterior
+ old_corpus = TOKENIZER_DIR / "corpus.txt"
+ if old_corpus.exists():
+ print(f" Corpus antiguo:")
+ old_count = 0
+ with open(old_corpus, "r", encoding="utf-8", errors="replace") as f:
+ for line in f:
+ if len(line.strip()) > 20:
+ out.write(line)
+ total += 1
+ old_count += 1
+ print(f" {old_count:,} docs")
+
+ corpus_mb = CORPUS_FILE.stat().st_size / 1024**2
+ print(f"\n📊 Corpus final: {total:,} docs, {corpus_mb:.0f} MB")
+
+ # Verify Spanish content
+ es_lines = 0
+ with open(CORPUS_FILE, "r", encoding="utf-8") as f:
+ for line in f:
+ if any(c in line for c in "áéíóúñü"):
+ es_lines += 1
+ es_pct = es_lines * 100 / total if total else 0
+ print(f" Líneas con español: {es_lines:,} ({es_pct:.1f}%)")
+
+ return total
+
+
+def generate_spanish_code():
+ """Genera corpus sustancial de código con español."""
+ lines = []
+
+ # Términos que DEBEN ser tokens propios
+ terms = [
+ "función", "parámetro", "argumento", "retorno", "resultado",
+ "variable", "constante", "método", "clase", "objeto",
+ "archivo", "directorio", "configuración", "conexión", "autenticación",
+ "usuario", "contraseña", "validación", "verificación", "búsqueda",
+ "algoritmo", "estructura", "índice", "cálculo", "número",
+ "cadena", "entero", "flotante", "booleano", "tamaño",
+ "longitud", "cantidad", "máximo", "mínimo", "promedio",
+ "iteración", "condición", "comparación", "operación", "excepción",
+ "implementación", "definición", "inicialización", "herencia", "abstracción",
+ "compilación", "ejecución", "depuración", "prueba", "documentación",
+ "capa", "neurona", "peso", "activación", "gradiente",
+ "optimizador", "pérdida", "precisión", "época", "entrenamiento",
+ "inferencia", "predicción", "modelo", "vocabulario", "tokenización",
+ "normalización", "regularización", "aprendizaje", "atención", "transformador",
+ ]
+
+ templates = [
+ 'def {0}(self, {1}):\n """Calcula el {2} del {1} proporcionado."""\n return self._{1}',
+ '# {0}: se encarga de procesar el {1} y calcular el {2}',
+ 'raise ValueError("El {0} no puede estar vacío")',
+ 'logger.info(f"Procesando {0} con {1}: {{valor}}")',
+ '"""\n{0}.\n\nParámetros:\n {1}: El valor de {2} a procesar.\n\nRetorna:\n El {2} calculado.\n"""',
+ 'self.{0} = {1} # {2} del componente',
+ '# TODO: implementar {0} para mejorar el {1}',
+ 'if not self.{0}:\n raise RuntimeError("Falta el {1} para la {2}")',
+ 'print(f"Error en {0}: el {1} no es válido para {2}")',
+ 'assert isinstance({0}, {1}), f"Se esperaba {1}, se recibió {{type({0})}}"',
+ ]
+
+ import random
+ random.seed(42)
+
+ for _ in range(2000):
+ random.shuffle(terms)
+ for i in range(0, len(terms) - 2, 3):
+ for t in templates:
+ try:
+ lines.append(t.format(terms[i], terms[i+1], terms[i+2]))
+ except (IndexError, KeyError):
+ pass
+
+ # Terms repeated standalone
+ for _ in range(2000):
+ for term in terms:
+ lines.append(term)
+
+ return lines
+
+
+def retrain_tokenizer():
+ """Re-entrenar SentencePiece BPE con corpus bilingüe."""
+ import sentencepiece as spm
+
+ corpus_mb = CORPUS_FILE.stat().st_size / 1024**2
+ print(f"\n🔧 Re-entrenando tokenizer BPE...")
+ print(f" Corpus: {corpus_mb:.0f} MB")
+ print(f" Vocab: 48,000")
+ print(f" Esto puede tardar 10-30 minutos...\n")
+
+ spm.SentencePieceTrainer.train(
+ input=str(CORPUS_FILE),
+ model_prefix=OUTPUT_PREFIX,
+ vocab_size=48000,
+ model_type="bpe",
+ pad_id=0, eos_id=1, bos_id=2, unk_id=3,
+ character_coverage=0.9999,
+ num_threads=os.cpu_count() or 4,
+ train_extremely_large_corpus=True,
+ max_sentence_length=16384,
+ byte_fallback=True,
+ normalization_rule_name="identity",
+ split_digits=True,
+ user_defined_symbols=[
+ " ", " ", " ", "\t",
+ "==", "!=", "<=", ">=", "+=", "-=", "*=", "/=",
+ "->", "=>", "::", "//", "**", "&&", "||",
+ "...", "..=", '"""', "'''", "```",
+ ],
+ control_symbols=["", ""],
+ )
+
+ print("✅ Tokenizer re-entrenado!")
+
+
+def verify():
+ """Verificar tokenizer."""
+ import sentencepiece as spm
+
+ sp = spm.SentencePieceProcessor()
+ sp.load(f"{OUTPUT_PREFIX}.model")
+
+ print(f"\n🧪 Verificación del tokenizer")
+ print(f" Vocab: {sp.get_piece_size():,}")
+
+ # Test español
+ tests = {
+ "función": "función",
+ "número": "número",
+ "tamaño": "tamaño",
+ "cálculo": "cálculo",
+ "también": "también",
+ "España": "España",
+ "programación": "programación",
+ }
+
+ print(f"\n Palabras españolas:")
+ all_ok = True
+ for name, word in tests.items():
+ enc = sp.encode(word, out_type=str)
+ has_bytes = any("<0x" in t for t in enc)
+ ok = "✅" if not has_bytes else "❌"
+ if has_bytes:
+ all_ok = False
+ print(f" {ok} {word:20s} -> {enc}")
+
+ # Eficiencia
+ tests2 = {
+ "Python": "def calcular_promedio(numeros):\n return sum(numeros) / len(numeros)",
+ "Español": "La función calcula el promedio de una lista de números flotantes.",
+ "TypeScript": "const resultado: number = await procesarDatos(entrada);",
+ "SQL": "SELECT nombre, COUNT(*) AS total FROM usuarios GROUP BY nombre",
+ }
+
+ print(f"\n Eficiencia (tok/char):")
+ for name, text in tests2.items():
+ ids = sp.encode(text)
+ ratio = len(ids) / len(text)
+ print(f" {name:15s}: {ratio:.3f} ({len(ids)} tok / {len(text)} chars)")
+
+ # Count accent tokens in vocab
+ accent_count = sum(1 for i in range(sp.get_piece_size())
+ if any(c in sp.id_to_piece(i) for c in "áéíóúñüÁÉÍÓÚÑÜ"))
+ print(f"\n Tokens con acentos/ñ: {accent_count:,} de {sp.get_piece_size():,}")
+
+ if all_ok:
+ print("\n✅ El tokenizer maneja español correctamente!")
+ else:
+ print("\n⚠️ Algunas palabras todavía usan byte fallback")
+
+
+if __name__ == "__main__":
+ # 1. Descargar Wikipedia ES
+ download_spanish_wiki()
+
+ # 2. Reconstruir corpus bilingüe
+ rebuild_corpus()
+
+ # 3. Re-entrenar tokenizer
+ retrain_tokenizer()
+
+ # 4. Verificar
+ verify()
diff --git a/scripts/chat.py b/scripts/chat.py
new file mode 100644
index 0000000000000000000000000000000000000000..df99e0413f1bba336e5c105ea6f6621b33c1685e
--- /dev/null
+++ b/scripts/chat.py
@@ -0,0 +1,484 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+chat.py — Loop interactivo de PAMPAr-Coder.
+
+El agente autónomo:
+ 1. Recibe un problema de programación en lenguaje natural
+ 2. Genera código Python usando el modelo (formato SFT)
+ 3. Ejecuta el código automáticamente con EjecutorCodigo
+ 4. Si falla → muestra el error y reintenta con el error como contexto
+ 5. Agotados los reintentos → guarda el fallo en ColaFinetune
+ 6. Cuando la cola alcanza el umbral → ofrece lanzar un mini-SFT
+
+Uso:
+ python -X utf8 scripts/chat.py
+ python -X utf8 scripts/chat.py --checkpoint checkpoints/v3_sft_v7.pt
+ python -X utf8 scripts/chat.py --temp 0.2 --max-tokens 600
+"""
+
+import argparse
+import json
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import sentencepiece as spm
+import torch
+import torch.nn.functional as F
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from pampar.memoria.clasificador import ClasificadorPareto, EntradaMemoria
+from pampar.memoria.cola_finetune import ColaFinetune
+from pampar.skills.ejecutar_codigo import EjecutorCodigo
+
+ROOT = Path(__file__).resolve().parent.parent
+SFT_SCRIPT = ROOT / "scripts" / "sft_v5.py"
+GOOD_DATASET = ROOT / "data" / "final_sft.jsonl"
+
+# =============================================================================
+# Constantes
+# =============================================================================
+
+MAX_REINTENTOS = 2
+MEMORIA_DIR = "memoria/data"
+TOKENIZER_PATHS = [
+ "data/tokenizer/pampar_48k.model",
+ "data/tokenizer/code_tokenizer.model",
+]
+
+
+# =============================================================================
+# Carga de modelo (delegada a pampar.inference)
+# =============================================================================
+
+from pampar.inference import load_model
+
+
+def cargar_tokenizer(vocab_size: int) -> spm.SentencePieceProcessor:
+ """Busca el tokenizer correcto según vocab_size."""
+ sp = spm.SentencePieceProcessor()
+ for path in TOKENIZER_PATHS:
+ p = Path(path)
+ if p.exists():
+ sp.Load(str(p))
+ if sp.vocab_size() == vocab_size:
+ return sp
+ raise FileNotFoundError(
+ f"No se encontró tokenizer con vocab_size={vocab_size}. "
+ f"Buscado en: {TOKENIZER_PATHS}"
+ )
+
+
+# =============================================================================
+# Generación (adaptada de eval_v3.py, misma lógica que funciona)
+# =============================================================================
+
+
+def generar(
+ modelo,
+ tokenizer: spm.SentencePieceProcessor,
+ prompt: str,
+ device: torch.device,
+ max_tokens: int = 512,
+ temperature: float = 0.1,
+ repetition_penalty: float = 1.15,
+ rep_window: int = 32,
+) -> str:
+ """
+ Genera código a partir de un prompt en formato SFT.
+
+ Devuelve solo la parte generada (no el prompt), limpia y lista para ejecutar.
+ """
+ ids = tokenizer.Encode(prompt)
+ generados = list(ids)
+
+ for _ in range(max_tokens):
+ ctx = torch.tensor([generados[-512:]], dtype=torch.long, device=device)
+ logits, _, _ = modelo(ctx)
+ next_logits = logits[0, -1]
+
+ # Penalizar repetición en ventana local (no destruir keywords de Python)
+ if repetition_penalty != 1.0 and len(generados) > len(ids):
+ window_start = max(len(ids), len(generados) - rep_window)
+ seen = set(generados[window_start:])
+ for token_id in seen:
+ if next_logits[token_id] > 0:
+ next_logits[token_id] /= repetition_penalty
+ else:
+ next_logits[token_id] *= repetition_penalty
+
+ if temperature <= 0.0:
+ next_token = int(next_logits.argmax())
+ else:
+ next_logits = next_logits / temperature
+ probs = F.softmax(next_logits, dim=-1)
+ next_token = int(torch.multinomial(probs, 1))
+
+ generados.append(next_token)
+ decoded = tokenizer.Decode(generados[len(ids) :]).replace("\u2047", "\n")
+
+ # Parar si el modelo empieza una nueva sección
+ if "###" in decoded:
+ idx = decoded.index("###")
+ if idx > 10:
+ return decoded[:idx].rstrip()
+
+ # Parar cuando termina la función (línea sin sangría después de contenido)
+ lines = decoded.split("\n")
+ if len(lines) > 3:
+ for i, line in enumerate(lines[2:], 2):
+ if line and not line[0].isspace() and line.strip() not in ("", "pass"):
+ return "\n".join(lines[:i])
+
+ # Parar solo cuando el bloque TERMINÓ con línea en blanco (no mid-función)
+ if decoded.endswith("\n\n") and len(decoded) > 20:
+ return decoded.rstrip()
+
+ return tokenizer.Decode(generados[len(ids) :]).replace("\u2047", "\n")
+
+
+# =============================================================================
+# Normalización de indentación (ídem eval_v3.py)
+# =============================================================================
+
+
+def _normalizar_indentacion(codigo: str) -> str:
+ """Corrige indentación inconsistente a múltiplos de 4 espacios."""
+ lineas = codigo.splitlines()
+ normalizadas = []
+ for linea in lineas:
+ if not linea.strip():
+ normalizadas.append("")
+ continue
+ n_spaces = len(linea) - len(linea.lstrip())
+ n_tabs = linea[:n_spaces].count("\t")
+ total = n_spaces + n_tabs * 4 - n_tabs
+ nivel = round(total / 4)
+ normalizadas.append(" " * nivel + linea.lstrip())
+ return "\n".join(normalizadas)
+
+
+# =============================================================================
+# Loop de coding autónomo
+# =============================================================================
+
+
+class CodingLoop:
+ """
+ Loop autónomo: genera → ejecuta → observa error → reintenta → aprende.
+
+ Si el modelo falla repetidamente en un problema, el par (problema, error, código)
+ va a la ColaFinetune. Cuando la cola tiene suficientes ejemplos, se ofrece
+ lanzar un mini-SFT para mejorar el modelo.
+ """
+
+ def __init__(
+ self,
+ checkpoint: Path,
+ device: torch.device,
+ max_reintentos: int = MAX_REINTENTOS,
+ temperatura: float = 0.1,
+ rep_penalty: float = 1.15,
+ max_tokens: int = 512,
+ memoria_dir: str = MEMORIA_DIR,
+ ):
+ print(f"\n{'═' * 60}")
+ print(f" PAMPAr-Coder — Cargando modelo...")
+ print(f"{'═' * 60}")
+
+ t0 = time.time()
+ print(f" Checkpoint : {checkpoint.name}", end=" ", flush=True)
+ self.modelo, self.tok = load_model(checkpoint, device, verbose=False)
+ n_params = sum(p.numel() for p in self.modelo.parameters()) / 1e6
+ print(f"({n_params:.1f}M params, {time.time() - t0:.1f}s)")
+
+ self.checkpoint = checkpoint
+ self.device = device
+ self.max_reintentos = max_reintentos
+ self.temp = temperatura
+ self.rep_penalty = rep_penalty
+ self.max_tokens = max_tokens
+
+ self.ejecutor = EjecutorCodigo(timeout=15)
+ self.clasificador = ClasificadorPareto()
+ self.cola = ColaFinetune(
+ directorio=memoria_dir,
+ min_ejemplos=50,
+ callback_proponer=self._proponer_finetune,
+ )
+
+ print(f" Dispositivo: {device}")
+ print(f" Cola aprendizaje: {len(self.cola)} ejemplos acumulados")
+ print(f"{'═' * 60}\n")
+
+ # ── Callbacks ─────────────────────────────────────────────────────────────
+
+ def _hacer_mini_sft(self) -> bool:
+ """
+ Lanza sft_v5.py como refresher SFT sobre final_sft.jsonl,
+ espera a que termine (síncrono), recarga el modelo en proceso
+ y vacía la cola.
+
+ Returns True si el training y la recarga fueron exitosos.
+ """
+ if not GOOD_DATASET.exists():
+ print(f" ⚠️ No se encontró {GOOD_DATASET}. Cancelando mini-SFT.")
+ return False
+
+ # Determinar nombre del nuevo checkpoint sin sobrescribir
+ idx = 1
+ while (ROOT / "checkpoints" / f"v3_sft_chat_v{idx}.pt").exists():
+ idx += 1
+ nuevo_ckpt = ROOT / "checkpoints" / f"v3_sft_chat_v{idx}.pt"
+
+ print(
+ f" Dataset : {GOOD_DATASET.name} ({GOOD_DATASET.stat().st_size // 1024} KB)"
+ )
+ print(f" Entrada : {self.checkpoint.name}")
+ print(f" Salida : {nuevo_ckpt.name}")
+ print(f" Entrenando... (puede tardar varios minutos)\n")
+
+ cmd = [
+ sys.executable,
+ str(SFT_SCRIPT),
+ "--checkpoint-in",
+ str(self.checkpoint),
+ "--checkpoint-out",
+ str(nuevo_ckpt),
+ "--targeted",
+ str(GOOD_DATASET),
+ "--lr",
+ "5e-7",
+ "--lr-min",
+ "5e-8",
+ "--max-pasos",
+ "200",
+ "--epochs",
+ "5",
+ "--warmup",
+ "10",
+ ]
+
+ result = subprocess.run(cmd, cwd=str(ROOT))
+
+ if result.returncode != 0:
+ print(
+ f"\n ❌ Mini-SFT falló (rc={result.returncode}). Checkpoint no guardado."
+ )
+ return False
+
+ if not nuevo_ckpt.exists():
+ print(f"\n ❌ Checkpoint no se creó ({nuevo_ckpt.name}).")
+ return False
+
+ # Recargar modelo en proceso
+ print(f"\n ♻️ Recargando modelo desde {nuevo_ckpt.name}...")
+ self.modelo, self.tok = load_model(nuevo_ckpt, self.device, verbose=False)
+ self.checkpoint = nuevo_ckpt
+
+ n_vaciados = self.cola.vaciar_post_finetune()
+ print(
+ f" ✅ Modelo actualizado. Cola vaciada ({n_vaciados} ejemplos procesados).\n"
+ )
+ return True
+
+ def _proponer_finetune(self, n: int, stats: dict) -> bool:
+ """
+ Callback cuando la cola supera el umbral.
+
+ Lanza mini-SFT de forma síncrona si el usuario acepta,
+ recarga el modelo y vacía la cola.
+ Siempre retorna False para evitar que ColaFinetune llame
+ al lanzar_finetune() antiguo (que usa el script obsoleto).
+ """
+ print(f"\n{'─' * 60}")
+ print(f"📚 Cola de aprendizaje lista: {n} ejemplos")
+ print(f" Importancia promedio: {stats.get('importancia_promedio', '?')}")
+ print(f" ¿Querés lanzar un mini-SFT para que el modelo mejore?")
+ try:
+ resp = input(" [s/N] >>> ").strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ resp = "n"
+
+ if resp in ("s", "si", "sí", "y", "yes"):
+ print("\n Lanzando mini-SFT...\n")
+ self._hacer_mini_sft()
+ else:
+ print(" OK. La cola se conserva y sigue acumulando.\n")
+
+ # Siempre False — el lanzar_finetune() de ColaFinetune no debe ejecutarse
+ return False
+
+ # ── Persistencia de errores ────────────────────────────────────────────────
+
+ def _guardar_error(self, problema: str, codigo: str, error: str) -> None:
+ """Persiste un fallo en la ColaFinetune para futura mejora."""
+ texto = (
+ f"### Problem:\n{problema}\n### Attempted:\n{codigo}\n### Error:\n{error}"
+ )
+ entrada = self.clasificador.clasificar(texto=texto, tipo="error")
+ # Los errores del modelo son siempre L3 — siempre queremos aprender de ellos
+ entrada.nivel = 3
+ entrada.importancia = 0.90
+ self.cola.agregar(entrada)
+
+ # ── Ciclo principal ────────────────────────────────────────────────────────
+
+ def responder(self, problema: str) -> None:
+ """
+ Procesa un problema:
+ 1. Genera código con el modelo
+ 2. Lo ejecuta
+ 3. Si falla → añade el error al contexto y reintenta
+ 4. Si sigue fallando → guarda en ColaFinetune
+ """
+ prompt_base = f"### Problem:\n{problema}\n### Solution:\n"
+ prompt = prompt_base
+ ultimo_codigo = ""
+ ultimo_error = ""
+
+ for intento in range(1, self.max_reintentos + 2):
+ label = f"intento {intento}/{self.max_reintentos + 1}"
+ print(f" ⚡ Generando ({label})...", end=" ", flush=True)
+ t0 = time.time()
+
+ codigo_raw = generar(
+ self.modelo,
+ self.tok,
+ prompt,
+ self.device,
+ max_tokens=self.max_tokens,
+ temperature=self.temp,
+ repetition_penalty=self.rep_penalty,
+ )
+ codigo = _normalizar_indentacion(codigo_raw.strip())
+ print(f"{time.time() - t0:.1f}s")
+
+ # Mostrar código generado
+ print()
+ print(" " + "─" * 50)
+ for line in codigo.splitlines():
+ print(f" {line}")
+ print(" " + "─" * 50)
+
+ # Ejecutar
+ resultado = self.ejecutor.execute(codigo=codigo)
+ ultimo_codigo = codigo
+
+ if resultado.exito:
+ print(f"\n ✅ Ejecutado correctamente\n")
+ if resultado.contenido.strip():
+ print(f" Output:")
+ for line in resultado.contenido.strip().splitlines():
+ print(f" {line}")
+ print()
+ return
+
+ # Falló
+ ultimo_error = resultado.error or "Error desconocido"
+ print(f"\n ⚠️ Error: {ultimo_error}")
+
+ if intento <= self.max_reintentos:
+ print(f" → Reintentando con el error como contexto...\n")
+ # El modelo ve su propio error y tiene otra oportunidad
+ prompt = (
+ prompt_base
+ + codigo
+ + f"\n\n# ⚠️ Error en el código anterior:\n# {ultimo_error}\n"
+ + "# Corrección:\n"
+ )
+
+ # Agotamos todos los reintentos
+ print(f"\n 📚 Guardando en cola de aprendizaje...", end=" ", flush=True)
+ self._guardar_error(problema, ultimo_codigo, ultimo_error)
+ stats = self.cola.stats()
+ print(
+ f"cola: {stats['total']}/{self.cola.min_ejemplos} "
+ f"(faltan {stats['faltan_para_finetune']} para mini-SFT)"
+ )
+ print()
+
+ # ── REPL ──────────────────────────────────────────────────────────────────
+
+ def run(self) -> None:
+ """Loop interactivo. Escribe un problema → el modelo lo resuelve."""
+ print("─" * 60)
+ print(" 🦙 PAMPAr-Coder — Chat interactivo")
+ print(" Describe un problema de programación en Python.")
+ print(" 'salir' o Ctrl+C para terminar.")
+ print("─" * 60)
+ print()
+
+ while True:
+ try:
+ problema = input(">>> ").strip()
+ except (KeyboardInterrupt, EOFError):
+ print("\n\nHasta pronto.")
+ break
+
+ if not problema:
+ continue
+
+ if problema.lower() in ("salir", "quit", "exit"):
+ print("Hasta pronto.")
+ break
+
+ print()
+ self.responder(problema)
+
+
+# =============================================================================
+# Entry point
+# =============================================================================
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="PAMPAr-Coder — Loop interactivo de generación y ejecución de código"
+ )
+ parser.add_argument(
+ "--checkpoint",
+ default="checkpoints/v3_sft_v7.pt",
+ help="Checkpoint del modelo (default: v3_sft_v7.pt)",
+ )
+ parser.add_argument("--device", default="auto", help="cuda / cpu / auto")
+ parser.add_argument(
+ "--temp", type=float, default=0.1, help="Temperatura de generación"
+ )
+ parser.add_argument(
+ "--rep-penalty", type=float, default=1.15, help="Penalización de repetición"
+ )
+ parser.add_argument(
+ "--max-tokens", type=int, default=512, help="Máximo tokens a generar"
+ )
+ parser.add_argument(
+ "--max-reintentos", type=int, default=2, help="Reintentos si falla"
+ )
+ parser.add_argument(
+ "--memoria-dir", default=MEMORIA_DIR, help="Directorio para ColaFinetune"
+ )
+ args = parser.parse_args()
+
+ if args.device == "auto":
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ else:
+ device = torch.device(args.device)
+
+ agente = CodingLoop(
+ checkpoint=Path(args.checkpoint),
+ device=device,
+ max_reintentos=args.max_reintentos,
+ temperatura=args.temp,
+ rep_penalty=args.rep_penalty,
+ max_tokens=args.max_tokens,
+ memoria_dir=args.memoria_dir,
+ )
+ agente.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/classroom.html b/scripts/classroom.html
new file mode 100644
index 0000000000000000000000000000000000000000..852f9efef36d45c7675b13b672b74b1b1736a499
--- /dev/null
+++ b/scripts/classroom.html
@@ -0,0 +1,934 @@
+
+
+
+
+
+ PAMPAr Classroom
+
+
+
+
+
+
+
+
diff --git a/scripts/classroom.py b/scripts/classroom.py
new file mode 100644
index 0000000000000000000000000000000000000000..4c5a4ca97e6dade8e57da6af7978751d8d6567db
--- /dev/null
+++ b/scripts/classroom.py
@@ -0,0 +1,702 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+"""
+classroom.py — Motor principal del Classroom (ClassroomEngine).
+
+Orquesta profesor, alumno y entrenamiento bio-inspirado.
+Para ejecutar: usar classroom_server.py (CLI/Web).
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import queue
+import sys
+import time
+from collections import deque
+from pathlib import Path
+from typing import Optional
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from bio_mechanisms import BioOrchestrator, BioState
+from classroom_curriculum import (
+ _CONCEPT_BY_ID,
+ ClassroomConfig,
+ StudentProfile,
+ concept_level,
+)
+from classroom_events import format_event_to_console
+from classroom_memory import EWC, LessonResult, ReplayBuffer, compute_ewc_baseline
+from classroom_persistence import (
+ save_checkpoint as _persist_checkpoint,
+)
+from classroom_persistence import (
+ save_recording as _persist_recording,
+)
+from classroom_persistence import (
+ save_session as _persist_session,
+)
+from classroom_teacher import Teacher
+from classroom_training import (
+ setup_optimizer,
+ tokenize_pair,
+ tokenize_teaching,
+ train_step,
+)
+
+# Leer .env
+_env_file = Path(__file__).parent.parent / ".env"
+if _env_file.exists():
+ for _line in _env_file.read_text(encoding="utf-8").splitlines():
+ _line = _line.strip()
+ if _line and not _line.startswith("#") and "=" in _line:
+ _k, _v = _line.split("=", 1)
+ os.environ.setdefault(_k.strip(), _v.strip())
+
+
+# =============================================================================
+# Classroom Engine — Motor principal
+# =============================================================================
+
+
+class ClassroomEngine:
+ """
+ Motor del aula — orquesta mentor, alumno y entrenamiento.
+
+ Flujo conversacional de una lección:
+ 1. Seleccionar concepto via StudentProfile (adaptativo)
+ 2. Mentor (Qwen) genera lección: explicación + ejemplo + ejercicio + solución
+ 3. Phase A — Absorber: entrenar en explicación+ejemplo (todos los tokens)
+ 4. Phase B — Practicar: alumno genera respuesta al ejercicio
+ 5. Phase C — Corregir: mentor evalúa, entrenar en solución correcta + replay
+ 6. Actualizar perfil del alumno (mastery por concepto)
+ """
+
+ def __init__(self, config: ClassroomConfig):
+ self.config = config
+ self.device = self._resolve_device(config.device)
+ self.model: Optional[nn.Module] = None
+ self.tokenizer = None
+ self.optimizer: Optional[torch.optim.Optimizer] = None
+ self.teacher: Optional[Teacher] = None
+ self.ewc = EWC(nn.Module(), config.ewc_lambda)
+ self.replay = ReplayBuffer(config.replay_size)
+
+ # Estado del curriculum
+ self.current_level = config.start_level
+ self.level_history: deque[bool] = deque(maxlen=config.window_size)
+ self.lesson_count = 0
+ self.total_correct = 0
+ self.used_exercises: dict[int, set[int]] = {i: set() for i in range(1, 6)}
+
+ # Perfil adaptativo del alumno (árbol de conceptos)
+ self.student_profile = StudentProfile()
+
+ # Sesión — log completo
+ self.session_log: list[LessonResult] = []
+
+ # SSE: cola de eventos para la UI
+ self.event_queue: queue.Queue = queue.Queue()
+
+ # Bio-inspired orchestrator (se inicializa después de cargar modelo)
+ self.bio: Optional[BioOrchestrator] = None
+ self._last_terr_acts: Optional[list[torch.Tensor]] = None
+
+ # Recording — captura TODOS los eventos con timestamps
+ self._recording_events: list[dict] = []
+ self._recording_start: float = 0.0
+
+ def _resolve_device(self, device_arg: str) -> torch.device:
+ if device_arg == "auto":
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ return torch.device(device_arg)
+
+ # ── Carga del modelo ────────────────────────────────────────────
+
+ def load(self) -> None:
+ """Carga modelo, tokenizer, configura optimizer con LR diferencial."""
+ import sentencepiece as spm
+ from pampar.coder.v3.config import PRESET_V3
+ from pampar.coder.v3.modelo import PamparV3
+
+ self._emit("system", "Cargando modelo...")
+
+ # Tokenizer
+ project_root = Path(__file__).parent.parent
+ tok_path = project_root / "data" / "tokenizer" / "pampar_48k.model"
+ self.tokenizer = spm.SentencePieceProcessor()
+ self.tokenizer.Load(str(tok_path))
+
+ # Modelo
+ self.model = PamparV3(PRESET_V3).to(self.device)
+ ckpt_path = project_root / self.config.checkpoint_in
+ ckpt = torch.load(str(ckpt_path), map_location=self.device, weights_only=False)
+ state_dict = ckpt.get("modelo", ckpt.get("model", ckpt))
+ self.model.load_state_dict(state_dict, strict=False)
+ self.model.registrar_tokenizer(self.tokenizer)
+
+ params = sum(p.numel() for p in self.model.parameters()) / 1e6
+ self._emit("system", f"Modelo cargado: {params:.1f}M params en {self.device}")
+
+ # Optimizer con groups de LR diferencial
+ self._setup_optimizer()
+
+ # Teacher
+ api_key = self.config.api_key
+ if not api_key:
+ if self.config.teacher_backend == "github":
+ api_key = os.environ.get("GITHUB_TOKEN", "")
+ elif self.config.teacher_backend == "qwen":
+ api_key = os.environ.get("QWEN_API_KEY", "")
+ else:
+ api_key = os.environ.get("OPENROUTER_API_KEY", "")
+
+ if not api_key:
+ self._emit(
+ "error",
+ "No se encontró API key. Configura GITHUB_TOKEN, OPENROUTER_API_KEY o QWEN_API_KEY en .env",
+ )
+ return
+
+ self.teacher = Teacher(
+ backend=self.config.teacher_backend,
+ model=self.config.teacher_model,
+ api_key=api_key,
+ )
+ self._emit(
+ "system",
+ f"Profesor: {self.config.teacher_model} ({self.config.teacher_backend})",
+ )
+
+ # Calcular Fisher Information para EWC
+ self._compute_ewc_baseline()
+
+ # Inicializar mecanismos bio-inspirados
+ if self.config.bio_enabled:
+ from pampar.coder.v3.config import PRESET_V3
+
+ self.bio = BioOrchestrator(
+ model=self.model,
+ optimizer=self.optimizer,
+ replay_buffer=self.replay,
+ device=self.device,
+ baseline_lr=self._baseline_lr,
+ dim=PRESET_V3.dim,
+ n_streams=PRESET_V3.n_streams,
+ n_levels=PRESET_V3.n_levels,
+ sleep_every=self.config.sleep_every,
+ prune_every=self.config.prune_every,
+ )
+ self._emit(
+ "system",
+ "Bio-mechanisms activados: Neuromod + LTP + Sleep + Neurogenesis + Pruning",
+ )
+
+ self._emit("system", "¡Aula lista! Comienza la clase.")
+
+ def _setup_optimizer(self) -> None:
+ """Configura optimizer con Learning Rate diferencial."""
+ self.optimizer, self._baseline_lr, info = setup_optimizer(
+ self.model,
+ self.config,
+ )
+ for g in info:
+ self._emit(
+ "system",
+ f" LR {g['label']}: {g['lr']:.2e} ({g['n_params'] / 1e6:.1f}M params)",
+ )
+
+ def _compute_ewc_baseline(self) -> None:
+ """Calcula Fisher Information sobre datos que el modelo ya maneja bien."""
+ self._emit("system", "Calculando Fisher Information para EWC...")
+ self.ewc = compute_ewc_baseline(
+ self.model,
+ self.tokenizer,
+ self.config.ewc_lambda,
+ self.config.ewc_samples,
+ self.config.seq_len,
+ self.device,
+ )
+ n_samples = len(self.ewc.fisher)
+ self._emit("system", f"EWC listo: Fisher calculada sobre {n_samples} params")
+
+ # ── Tokenización ────────────────────────────────────────────────
+
+ def _tokenize_pair(
+ self, problem: str, solution: str
+ ) -> tuple[torch.Tensor, torch.Tensor]:
+ """Tokeniza problema→solución con máscara de loss."""
+ return tokenize_pair(self.tokenizer, problem, solution, self.config.seq_len)
+
+ def _tokenize_teaching(self, text: str) -> tuple[torch.Tensor, torch.Tensor]:
+ """Tokeniza contenido del mentor (todos los tokens entrenables)."""
+ return tokenize_teaching(self.tokenizer, text, self.config.seq_len)
+
+ # ── Generación del alumno ───────────────────────────────────────
+
+ def _student_generate(self, problem: str, concept_type: str = "coding") -> str:
+ """El alumno (PamparV3) intenta resolver el problema.
+
+ Para conceptos conceptual/bridge usa formato conversacional en español.
+ Para coding usa el formato de código Python.
+ """
+ self.model.eval()
+
+ if concept_type in ("conceptual", "bridge"):
+ # Formato conversacional: pregunta → respuesta en español (sin tildes)
+ from classroom_training import _norm_for_tok
+
+ prompt = _norm_for_tok(f"### Pregunta:\n{problem}\n### Respuesta:\n")
+ stops = ["###", "\n\n\n", "\n"]
+ max_tokens = 30 # una frase corta, no 80
+ temperature = 0.2 # más determinístico
+ else:
+ # Formato código Python
+ from classroom_training import _norm_for_tok
+
+ prompt = f"### Problem:\n{problem}\n### Solution:\n```python\n"
+ stops = ["```", "###", "\n\n\n"]
+ max_tokens = 200
+ temperature = 0.3
+
+ ids = self.tokenizer.Encode(prompt)
+ input_ids = torch.tensor([ids], dtype=torch.long, device=self.device)
+
+ with torch.no_grad():
+ output = self.model.generate(
+ input_ids,
+ max_tokens=max_tokens,
+ temperature=temperature,
+ top_k=10 if concept_type in ("conceptual", "bridge") else 40,
+ top_p=0.9,
+ )
+
+ generated = output[0, len(ids) :].tolist()
+ text = self.tokenizer.Decode(generated)
+
+ for stop in stops:
+ if stop in text:
+ text = text[: text.index(stop)]
+ return text.strip()
+
+ # ── Paso de entrenamiento ───────────────────────────────────────
+
+ def _train_step(
+ self, examples: list[tuple[torch.Tensor, torch.Tensor]]
+ ) -> tuple[float, float]:
+ """Delega al módulo classroom_training y captura terr_acts."""
+ loss_ce, ewc_pen, last_info = train_step(
+ self.model,
+ self.optimizer,
+ self.ewc,
+ examples,
+ self.device,
+ )
+ if last_info and "terr_acts" in last_info:
+ self._last_terr_acts = [last_info["terr_acts"].detach()]
+ return loss_ce, ewc_pen
+
+ # ── Quick brain check ───────────────────────────────────────────
+
+ def _quick_brain_check(self) -> float:
+ """Mini brain scan rápido: accuracyN5 sobre 3 muestras."""
+ self.model.eval()
+ probes = ["def fibonacci(n):", "for i in range(10):", "class DataProcessor:"]
+ correct = 0
+ total = 0
+
+ with torch.no_grad():
+ for probe in probes:
+ ids = self.tokenizer.Encode(probe)
+ if len(ids) < 3:
+ continue
+ input_ids = torch.tensor([ids], dtype=torch.long, device=self.device)
+ logits, _, _ = self.model(input_ids)
+
+ for pos in range(len(ids) - 1):
+ probs = F.softmax(logits[0, pos], dim=-1)
+ top5 = probs.topk(5).indices.tolist()
+ if ids[pos + 1] in top5:
+ correct += 1
+ total += 1
+
+ return correct / total if total > 0 else 0.0
+
+ # ── Curriculum ──────────────────────────────────────────────────
+
+ def _select_concept(self) -> tuple[str, dict]:
+ """Selecciona el concepto y genera lección via mentor.
+
+ Returns:
+ (concept_id, lesson_dict) donde lesson_dict tiene
+ keys: explain, example, exercise, solution.
+ """
+ concept_id = self.student_profile.select_next_concept()
+ concept = _CONCEPT_BY_ID[concept_id]
+ concept_type = concept.get("type", "coding")
+ profile_summary = self.student_profile.summary()
+
+ self._emit(
+ "system", f"Mentor preparando: {concept['name']} [{concept_type}]..."
+ )
+ lesson = self.teacher.generate_lesson(
+ profile_summary, concept["name"], concept_type=concept_type
+ )
+
+ if not lesson:
+ self._emit("system", "Reintentando generación de lección...")
+ lesson = self.teacher.generate_lesson(
+ profile_summary, concept["name"], concept_type=concept_type
+ )
+
+ if not lesson:
+ # Fallback según tipo
+ if concept_type == "conceptual":
+ fallback_exercise = (
+ f"¿Puedes explicar con tus palabras qué es: {concept['desc']}?"
+ )
+ elif concept_type == "bridge":
+ fallback_exercise = (
+ f"Muestra en Python el concepto de: {concept['desc']}"
+ )
+ else:
+ fallback_exercise = (
+ f"Write a Python function demonstrating: {concept['desc']}"
+ )
+ lesson = {
+ "explain": "",
+ "example": "",
+ "exercise": fallback_exercise,
+ "solution": "",
+ }
+
+ return concept_id, lesson
+
+ # ── Lección completa ────────────────────────────────────────────
+
+ def run_lesson(self) -> LessonResult:
+ """Ejecuta una lección conversacional completa.
+
+ Flujo según tipo de concepto:
+ conceptual/bridge:
+ Phase A — Absorber: entrenar en explicación + ejemplo (todos los tokens)
+ Phase B — Responder: alumno responde en lenguaje natural
+ Phase C — Corregir: entrenar en pregunta→respuesta correcta (sin máscara)
+ coding:
+ Phase A — Absorber: entrenar en explicación + ejemplo
+ Phase B — Practicar: alumno intenta el ejercicio en Python
+ Phase C — Corregir: entrenar en ejercicio→solución (con máscara de prompt)
+ """
+ self.lesson_count += 1
+
+ # 1. Seleccionar concepto y generar lección
+ concept_id, lesson = self._select_concept()
+ concept = _CONCEPT_BY_ID[concept_id]
+ concept_type = concept.get("type", "coding")
+ level = concept_level(concept_id)
+ self.current_level = level
+
+ self._emit(
+ "lesson_start",
+ {
+ "lesson_id": self.lesson_count,
+ "level": level,
+ "level_name": concept["name"],
+ "concept": concept_id,
+ "problem": lesson.get("exercise", concept["desc"]),
+ },
+ )
+
+ # 2. Mostrar lo que el mentor enseña
+ if lesson.get("explain"):
+ self._emit(
+ "mentor_explain",
+ {
+ "lesson_id": self.lesson_count,
+ "explain": lesson["explain"],
+ },
+ )
+ if lesson.get("example"):
+ self._emit(
+ "mentor_example",
+ {
+ "lesson_id": self.lesson_count,
+ "example": lesson["example"],
+ },
+ )
+ if lesson.get("clave"):
+ self._emit(
+ "mentor_clave",
+ {
+ "lesson_id": self.lesson_count,
+ "clave": lesson["clave"],
+ },
+ )
+
+ # 3. Phase A — Absorber: entrenar en contenido del mentor
+ # Para conceptos conceptuales el text incluye la conversación natural.
+ # Para coding incluye explicación + código de ejemplo.
+ teaching_text = ""
+ if lesson.get("explain"):
+ teaching_text += lesson["explain"] + "\n\n"
+ if lesson.get("example"):
+ teaching_text += lesson["example"]
+
+ teach_loss = 0.0
+ if teaching_text.strip():
+ teach_ids, teach_labels = self._tokenize_teaching(teaching_text)
+ teach_loss, _ = self._train_step([(teach_ids, teach_labels)])
+ self._emit("system", f"Absorcion completada (loss={teach_loss:.4f})")
+
+ # Phase A+ — Refuerzo CLAVE: paso adicional solo con lo esencial
+ if lesson.get("clave"):
+ clave_ids, clave_labels = self._tokenize_teaching(lesson["clave"])
+ self._train_step([(clave_ids, clave_labels)])
+
+ # 4. Phase B — El alumno intenta responder
+ exercise = lesson.get("exercise", "")
+ teacher_solution = lesson.get("solution", "")
+ student_answer = ""
+ correct = False
+ feedback = ""
+ loss_ce = teach_loss
+ ewc_pen = 0.0
+
+ if exercise:
+ self._emit("student_thinking", {"lesson_id": self.lesson_count})
+ student_answer = self._student_generate(exercise, concept_type=concept_type)
+ self._emit(
+ "student_answer",
+ {"lesson_id": self.lesson_count, "answer": student_answer},
+ )
+
+ # 5. Phase C — Mentor evalúa el intento (lenguaje o código según tipo)
+ self._emit("teacher_evaluating", {"lesson_id": self.lesson_count})
+ profile_summary = self.student_profile.summary()
+ eval_result = self.teacher.respond_to_attempt(
+ exercise,
+ student_answer,
+ profile_summary,
+ concept_type=concept_type,
+ )
+ correct = eval_result.get("correct", False)
+ feedback = eval_result.get("feedback", "")
+
+ self._emit(
+ "teacher_feedback",
+ {
+ "lesson_id": self.lesson_count,
+ "correct": correct,
+ "feedback": feedback,
+ },
+ )
+
+ if correct:
+ teacher_solution = student_answer
+ self.total_correct += 1
+ else:
+ fix = eval_result.get("fix", "")
+ if fix:
+ teacher_solution = fix
+ if not teacher_solution and concept_type == "coding":
+ teacher_solution = (
+ self.teacher.generate_solution(exercise) or student_answer
+ )
+ self._emit(
+ "teacher_solution",
+ {"lesson_id": self.lesson_count, "solution": teacher_solution},
+ )
+
+ # Entrenar en ejercicio→solución
+ if teacher_solution:
+ if concept_type in ("conceptual", "bridge"):
+ # Sin máscara de prompt: todo el par es señal de aprendizaje
+ full_text = (
+ f"### Pregunta:\n{exercise}\n### Respuesta:\n{teacher_solution}"
+ )
+ ex_ids, ex_labels = self._tokenize_teaching(full_text)
+ else:
+ # Con máscara: solo la solución genera loss
+ ex_ids, ex_labels = self._tokenize_pair(exercise, teacher_solution)
+
+ train_batch: list[tuple[torch.Tensor, torch.Tensor]] = [
+ (ex_ids, ex_labels),
+ ]
+
+ if len(self.replay) > 0:
+ n_replay = max(
+ 1,
+ int(
+ len(train_batch)
+ / (1 - self.config.replay_ratio)
+ * self.config.replay_ratio
+ ),
+ )
+ replay_samples = self.replay.sample(n_replay)
+ for s in replay_samples:
+ train_batch.append((s["input_ids"], s["labels"]))
+
+ self._emit(
+ "training",
+ {"lesson_id": self.lesson_count, "batch_size": len(train_batch)},
+ )
+
+ loss_ce, ewc_pen = self._train_step(train_batch)
+
+ # Guardar en replay buffer solo si el alumno acertó
+ # (evita fijar patrones incorrectos en el buffer)
+ if correct:
+ self.replay.add(
+ exercise,
+ teacher_solution,
+ ex_ids,
+ ex_labels,
+ level,
+ )
+ else:
+ correct = True
+ feedback = "Lección absorbida (sin ejercicio)"
+
+ # 6. Actualizar perfil del alumno
+ error_desc = feedback if not correct else ""
+ self.student_profile.record(concept_id, correct, error_desc)
+
+ # 7. Quick brain check
+ brain_score = self._quick_brain_check()
+
+ # 8. Bio-mechanisms hook
+ bio_state = None
+ if self.bio is not None:
+ bio_state = self.bio.after_lesson(
+ correct=correct,
+ loss=loss_ce,
+ level=level,
+ terr_acts_per_level=self._last_terr_acts,
+ )
+ self._emit(
+ "bio_update",
+ {
+ "lesson_id": self.lesson_count,
+ "dopamine": round(bio_state.dopamine, 3),
+ "norepinephrine": round(bio_state.norepinephrine, 3),
+ "lr_factor": round(bio_state.lr_factor, 3),
+ "ltp_applied": bio_state.ltp_applied,
+ "sleep_triggered": bio_state.sleep_triggered,
+ "sleep_loss": round(bio_state.sleep_loss, 4)
+ if bio_state.sleep_triggered
+ else 0,
+ "adapters_total": bio_state.adapters_total,
+ "pruned": bool(bio_state.pruned_streams),
+ },
+ )
+
+ # 9. Resultado
+ result = LessonResult(
+ lesson_id=self.lesson_count,
+ level=level,
+ problem=exercise or concept["desc"],
+ student_answer=student_answer,
+ teacher_solution=teacher_solution,
+ correct=correct,
+ feedback=feedback,
+ loss=loss_ce,
+ ewc_penalty=ewc_pen,
+ brain_score=brain_score,
+ )
+ self.session_log.append(result)
+
+ accuracy = self.total_correct / self.lesson_count
+ self._emit(
+ "lesson_complete",
+ {
+ "lesson_id": self.lesson_count,
+ "correct": correct,
+ "loss": round(loss_ce, 4),
+ "ewc_penalty": round(ewc_pen, 6),
+ "brain_score": round(brain_score, 4),
+ "accuracy": round(accuracy, 4),
+ "level": self.current_level,
+ "concept": concept_id,
+ "replay_size": len(self.replay),
+ },
+ )
+
+ # Guardar checkpoint periódicamente
+ if self.lesson_count % self.config.guardar_cada == 0:
+ self._save_checkpoint()
+
+ return result
+
+ # ── Guardar checkpoint ──────────────────────────────────────────
+
+ def _save_checkpoint(self) -> None:
+ """Guarda checkpoint del modelo."""
+ path = _persist_checkpoint(
+ self.model,
+ self.optimizer,
+ self.config,
+ self.lesson_count,
+ self.current_level,
+ self.total_correct,
+ )
+ self._emit("checkpoint", {"path": path, "lesson": self.lesson_count})
+
+ # ── Guardar sesión ──────────────────────────────────────────────
+
+ def save_session(self) -> str:
+ """Guarda la sesión completa como JSONL."""
+ path = _persist_session(self.session_log)
+ self._emit(
+ "session_saved",
+ {"path": path, "lessons": len(self.session_log)},
+ )
+ return path
+
+ def save_recording(self) -> str:
+ """Guarda la grabación completa de eventos como HTML reproducible."""
+ path = _persist_recording(
+ self._recording_events,
+ self._recording_start,
+ self.config,
+ self.lesson_count,
+ self.total_correct,
+ self.current_level,
+ )
+ if path:
+ self._emit(
+ "recording_saved",
+ {"path": path, "events": len(self._recording_events)},
+ )
+ return path
+
+ # ── Emitir eventos (SSE) ────────────────────────────────────────
+
+ def _emit(self, event_type: str, data: str | dict = "") -> None:
+ """Emite un evento para la UI y lo imprime en consola."""
+ if isinstance(data, dict):
+ payload = json.dumps(data, ensure_ascii=False)
+ else:
+ payload = data
+
+ self.event_queue.put({"event": event_type, "data": payload})
+
+ # Grabar evento para reproducción
+ if self.config.record:
+ if self._recording_start == 0.0:
+ self._recording_start = time.time()
+ self._recording_events.append(
+ {
+ "t": round(time.time() - self._recording_start, 3),
+ "event": event_type,
+ "data": data if isinstance(data, (dict, str)) else str(data),
+ }
+ )
+
+ # Imprimir en consola
+ format_event_to_console(event_type, data)
diff --git a/scripts/classroom_curriculum.py b/scripts/classroom_curriculum.py
new file mode 100644
index 0000000000000000000000000000000000000000..552efa3a09a458d64128197bfc10efc325cf77a5
--- /dev/null
+++ b/scripts/classroom_curriculum.py
@@ -0,0 +1,689 @@
+"""classroom_curriculum.py — Configuración, árbol de conceptos y perfil del alumno."""
+
+from __future__ import annotations
+
+import random
+from collections import defaultdict
+from dataclasses import dataclass, field
+
+
+@dataclass
+class ClassroomConfig:
+ """Configuración del aula."""
+
+ # Modelo
+ checkpoint_in: str = "checkpoints/v3_ghidra_v9.pt"
+ checkpoint_out: str = "checkpoints/v3_classroom.pt"
+ device: str = "auto"
+
+ # Teacher
+ teacher_backend: str = "github" # "github" | "openrouter"
+ teacher_model: str = "openai/gpt-4o-mini"
+ api_key: str = ""
+
+ # Entrenamiento bio-inspirado
+ lr_base: float = 5e-6 # LR base (conservador)
+ lr_llaves_mult: float = 0.01 # LLAVES/Tálamo: 1% del LR base
+ lr_attn_mult: float = 0.1 # Atención: 10% del LR base
+ lr_embed_mult: float = 0.1 # Embeddings: 10% del LR base
+ lr_ffn_mult: float = 1.0 # FFN/StreamFFN: 100% del LR base
+
+ # EWC
+ ewc_lambda: float = 500.0 # Fuerza de la penalización EWC
+ ewc_samples: int = 200 # Muestras para calcular Fisher
+
+ # Replay buffer
+ replay_size: int = 100 # Tamaño del buffer
+ replay_ratio: float = 0.5 # 50% replay, 50% nuevo
+
+ # Curriculum
+ start_level: int = 1 # Nivel inicial (1-5)
+ advance_threshold: float = 0.7 # 70% correcto para avanzar
+ window_size: int = 10 # Ventana para calcular accuracy
+
+ # Sesión
+ max_lessons: int = 200 # Máximo de lecciones por sesión
+ guardar_cada: int = 20 # Guardar checkpoint cada N lecciones
+ seq_len: int = 256 # Longitud máx de secuencia para training
+
+ # Bio-inspired mechanisms
+ bio_enabled: bool = True # Activar mecanismos bio-inspirados
+ sleep_every: int = 15 # Consolidación de sueño cada N lecciones
+ prune_every: int = 30 # Poda sináptica cada N lecciones
+
+ # Server
+ port: int = 8888
+
+ # Recording
+ record: bool = True # Grabar sesión como video reproducible
+
+
+CURRICULUM: dict[int, dict] = {
+ 1: {
+ "nombre": "Fundamentos",
+ "desc": "Variables, funciones simples, operaciones básicas",
+ "ejercicios": [
+ "Write a Python function `suma(a, b)` that returns the sum of two numbers.",
+ "Write a Python function `es_par(n)` that returns True if n is even, False otherwise.",
+ "Write a Python function `longitud(texto)` that returns the length of a string without using len().",
+ "Write a Python function `invertir(texto)` that returns the reversed string.",
+ "Write a Python function `contar_vocales(texto)` that counts vowels (a,e,i,o,u) case-insensitive.",
+ "Write a Python function `suma_digitos(n)` that returns the sum of all digits of a non-negative integer.",
+ "Write a Python function `es_palindromo(s)` that returns True if the string is a palindrome.",
+ "Write a Python function `maximo(a, b)` that returns the larger of two numbers without using max().",
+ "Write a Python function `absoluto(n)` that returns the absolute value without using abs().",
+ "Write a Python function `celsius_a_fahrenheit(c)` that converts Celsius to Fahrenheit.",
+ "Write a Python function `factorial(n)` that returns n! using a loop.",
+ "Write a Python function `potencia(base, exp)` that returns base**exp using a loop.",
+ "Write a Python function `duplicar_lista(lst)` that returns a new list with each element doubled.",
+ "Write a Python function `minimo_lista(lst)` that returns the smallest element without using min().",
+ "Write a Python function `contar_mayusculas(texto)` that counts uppercase letters in a string.",
+ ],
+ },
+ 2: {
+ "nombre": "Estructuras de control",
+ "desc": "Loops, condicionales, listas, diccionarios",
+ "ejercicios": [
+ "Write a Python function `fizzbuzz(n)` that returns 'FizzBuzz' if n divisible by 3 and 5, 'Fizz' if by 3, 'Buzz' if by 5, else str(n).",
+ "Write a Python function `fibonacci(n)` that returns the n-th Fibonacci number (0-indexed).",
+ "Write a Python function `frecuencia(lista)` that returns a dict mapping each element to its count.",
+ "Write a Python function `aplanar(lista)` that flattens a list of lists by one level.",
+ "Write a Python function `cuadrados_pares(n)` that returns squares of all even numbers from 2 to n.",
+ "Write a Python function `invertir_dict(d)` that returns a new dict with keys and values swapped.",
+ "Write a Python function `busqueda_lineal(lista, objetivo)` that returns the index or -1 if not found.",
+ "Write a Python function `eliminar_duplicados(lista)` that returns a list without duplicates, preserving order.",
+ "Write a Python function `es_primo(n)` that returns True if n is prime.",
+ "Write a Python function `ordenar_burbuja(lista)` that sorts a list using bubble sort.",
+ "Write a Python function `interseccion(a, b)` that returns elements common to both lists.",
+ "Write a Python function `rotar_lista(lst, k)` that rotates list left by k positions.",
+ ],
+ },
+ 3: {
+ "nombre": "Funciones avanzadas",
+ "desc": "Recursión, generadores, comprensiones complejas",
+ "ejercicios": [
+ "Write a Python function `merge_sort(lista)` that returns a new sorted list using merge sort.",
+ "Write a Python function `busqueda_binaria(lista, objetivo)` that returns the index or -1.",
+ "Write a Python function `primos_hasta(n)` that yields all primes up to n using a generator.",
+ "Write a Python function `memoize(fn)` that returns a cached version of fn.",
+ "Write a Python function `aplanar_profundo(lst)` that recursively flattens nested lists.",
+ "Write a Python function `permutaciones(lst)` that returns all permutations of a list.",
+ "Write a Python function `cifrado_cesar(texto, k)` that shifts each letter by k positions.",
+ "Write a Python function `potencia_recursiva(base, exp)` that calculates power recursively.",
+ "Write a Python function `torre_hanoi(n, origen, destino, auxiliar)` that prints the moves.",
+ "Write a Python function `zip_manual(a, b)` that zips two lists without using zip().",
+ ],
+ },
+ 4: {
+ "nombre": "Clases y OOP",
+ "desc": "Clases, herencia, métodos especiales",
+ "ejercicios": [
+ "Write a Python class `Stack` with methods `push(item)`, `pop()`, `is_empty()`, `peek()`.",
+ "Write a Python class `Punto` with x, y attributes and a method `distancia(otro)` for Euclidean distance.",
+ "Write a Python class `Cola` implementing a FIFO queue with `enqueue(item)` and `dequeue()`.",
+ "Write a Python class `Fraccion` with add, sub, mul, and __str__ using GCD simplification.",
+ "Write a Python class `Contador` that counts how many times it has been called (using __call__).",
+ "Write a Python class `Vector` with __add__, __sub__, __mul__ (scalar) and __repr__.",
+ "Write a Python class `ListaEnlazada` with `agregar(valor)`, `buscar(valor)`, `__len__`.",
+ "Write a Python class `Matriz` with __add__ and __mul__ for 2D matrix operations.",
+ ],
+ },
+ 5: {
+ "nombre": "Patrones avanzados",
+ "desc": "Decoradores, context managers, algoritmos complejos",
+ "ejercicios": [
+ "Write a Python decorator `cronometrar` that prints how long a function takes to execute.",
+ "Write a Python context manager class `TempFile` that creates a temp file and deletes it on exit.",
+ "Write a Python function `lru_cache(maxsize)` decorator that caches the last maxsize unique calls.",
+ "Write a Python function `dijkstra(grafo, inicio)` that returns shortest distances from inicio.",
+ "Write a Python async function `fetch_all(urls)` that fetches URLs concurrently with asyncio.",
+ "Write a Python function `quick_sort(lista)` implementing quicksort with median-of-three pivot.",
+ ],
+ },
+}
+
+
+# =============================================================================
+# Árbol de conceptos — el mentor elige qué enseñar basándose en esto
+# =============================================================================
+
+# Tipos de concepto:
+# "conceptual" — sin código, lenguaje natural (etapas 0-3)
+# "bridge" — mezcla: concepto cotidiano + correspondencia Python (etapa 4)
+# "coding" — Python puro (etapas 5+, curriculum original)
+#
+# Orden de prerequisitos: cada concepto requiere dominar los anteriores en su grupo
+CONCEPT_TREE: list[dict] = [
+ # =========================================================================
+ # ETAPA 0 — PRESENCIA Y COMUNICACIÓN (sin código)
+ # El alumno aprende que existe, que puede responder, que hay un interlocutor.
+ # =========================================================================
+ {
+ "id": "greeting",
+ "name": "Saludos y presentación",
+ "type": "conceptual",
+ "stage": 0,
+ "desc": "Intercambio de saludos. El alumno aprende a responder y articular quién es.",
+ "prereqs": [],
+ },
+ # =========================================================================
+ # ETAPA 1 — CONCEPTOS PRIMITIVOS (sin código)
+ # Lo mismo que aprender letras y números antes de aprender a leer.
+ # =========================================================================
+ {
+ "id": "concept_number",
+ "name": "¿Qué es un número?",
+ "type": "conceptual",
+ "stage": 1,
+ "desc": "Un número representa una cantidad real: 1 manzana, 3 personas, 0 lluvia.",
+ "prereqs": ["greeting"],
+ },
+ {
+ "id": "concept_sequence",
+ "name": "Secuencias y orden",
+ "type": "conceptual",
+ "stage": 1,
+ "desc": "Una secuencia es algo que viene en orden. Contar: 1, 2, 3... Los días de la semana.",
+ "prereqs": ["concept_number"],
+ },
+ {
+ "id": "concept_word",
+ "name": "Palabras y significado",
+ "type": "conceptual",
+ "stage": 1,
+ "desc": "Una palabra es un símbolo con significado: 'casa', 'rojo', 'correr'.",
+ "prereqs": ["greeting"],
+ },
+ {
+ "id": "concept_true_false",
+ "name": "Verdadero y Falso",
+ "type": "conceptual",
+ "stage": 1,
+ "desc": "Solo hay dos opciones: algo es verdad o mentira. El cielo es azul: verdad. Las vacas vuelan: mentira.",
+ "prereqs": ["greeting"],
+ },
+ # =========================================================================
+ # ETAPA 2 — LÓGICA COTIDIANA (sin código)
+ # Como aprender a combinar letras en sílabas.
+ # =========================================================================
+ {
+ "id": "concept_compare",
+ "name": "Comparar cosas",
+ "type": "conceptual",
+ "stage": 2,
+ "desc": "Mayor, menor, igual. 5 es mayor que 3. Una jirafa es más alta que un gato.",
+ "prereqs": ["concept_number", "concept_word"],
+ },
+ {
+ "id": "concept_if_then",
+ "name": "Si... entonces...",
+ "type": "conceptual",
+ "stage": 2,
+ "desc": "Una regla: SI llueve, ENTONCES llevás paraguas. SI tienes hambre, ENTONCES comés.",
+ "prereqs": ["concept_true_false"],
+ },
+ {
+ "id": "concept_repeat",
+ "name": "Repetir acciones",
+ "type": "conceptual",
+ "stage": 2,
+ "desc": "A veces repetimos lo mismo varias veces hasta que algo cambia. Contar, respirar, caminar.",
+ "prereqs": ["concept_sequence"],
+ },
+ # =========================================================================
+ # ETAPA 3 — PROCEDIMIENTOS Y ABSTRACCIÓN (sin código)
+ # Como combinar sílabas en palabras y palabras en oraciones.
+ # =========================================================================
+ {
+ "id": "concept_steps",
+ "name": "Recetas y procedimientos",
+ "type": "conceptual",
+ "stage": 3,
+ "desc": "Una receta es una lista de pasos ordenados que llevan a un resultado. Primero X, luego Y.",
+ "prereqs": ["concept_sequence", "concept_if_then"],
+ },
+ {
+ "id": "concept_variable_box",
+ "name": "Cajas con nombre (variables)",
+ "type": "conceptual",
+ "stage": 3,
+ "desc": "Una caja con etiqueta que guarda algo. La caja 'nombre' guarda 'Ana'. La caja 'edad' guarda 25.",
+ "prereqs": ["concept_number", "concept_word"],
+ },
+ {
+ "id": "concept_function_machine",
+ "name": "Máquinas que procesan (funciones)",
+ "type": "conceptual",
+ "stage": 3,
+ "desc": "Una máquina recibe algo, lo transforma, y devuelve algo. Una licuadora: recibe fruta, devuelve jugo.",
+ "prereqs": ["concept_steps"],
+ },
+ # =========================================================================
+ # ETAPA 4 — PUENTE: CONCEPTOS → CÓDIGO (mezcla de lenguaje y Python)
+ # Como pasar de leer oraciones simples a leer textos más formales.
+ # =========================================================================
+ {
+ "id": "code_intro",
+ "name": "Python: instrucciones para la computadora",
+ "type": "bridge",
+ "stage": 4,
+ "desc": "Python es cómo le escribimos instrucciones a la computadora, igual que una receta.",
+ "prereqs": ["concept_function_machine", "concept_variable_box"],
+ },
+ {
+ "id": "code_values",
+ "name": "Números y texto en Python",
+ "type": "bridge",
+ "stage": 4,
+ "desc": "Los números son iguales: 5, 3.14. El texto va entre comillas: 'hola'.",
+ "prereqs": ["code_intro"],
+ },
+ {
+ "id": "code_true_false",
+ "name": "True y False en Python",
+ "type": "bridge",
+ "stage": 4,
+ "desc": "Verdadero se escribe True. Falso se escribe False. Es lo mismo que sí/no.",
+ "prereqs": ["code_intro", "concept_true_false"],
+ },
+ {
+ "id": "code_variables",
+ "name": "Variables en Python",
+ "type": "bridge",
+ "stage": 4,
+ "desc": "edad = 25 → la caja 'edad' ahora guarda 25. nombre = 'Ana'.",
+ "prereqs": ["concept_variable_box", "code_values"],
+ },
+ {
+ "id": "code_if",
+ "name": "if/else en Python",
+ "type": "bridge",
+ "stage": 4,
+ "desc": "El 'si... entonces...' cotidiano se escribe: if condicion: ... else: ...",
+ "prereqs": ["concept_if_then", "code_true_false"],
+ },
+ # =========================================================================
+ # ETAPA 5+ — CÓDIGO PYTHON (curriculum original, tipo "coding")
+ # =========================================================================
+ # Nivel 1 — Fundamentos
+ {
+ "id": "arithmetic",
+ "name": "Arithmetic operations",
+ "type": "coding",
+ "stage": 5,
+ "desc": "suma, resta, multiplicación, división, módulo, potencia",
+ "prereqs": ["code_variables"],
+ },
+ {
+ "id": "variables_types",
+ "name": "Variables and types",
+ "type": "coding",
+ "stage": 5,
+ "desc": "int, float, str, bool, type conversion, f-strings",
+ "prereqs": ["arithmetic"],
+ },
+ {
+ "id": "conditionals",
+ "name": "Conditionals",
+ "type": "coding",
+ "stage": 5,
+ "desc": "if/elif/else, comparadores, operadores lógicos (and, or, not)",
+ "prereqs": ["variables_types"],
+ },
+ {
+ "id": "strings",
+ "name": "String operations",
+ "type": "coding",
+ "stage": 5,
+ "desc": "slicing, split, join, replace, find, lower/upper, f-strings",
+ "prereqs": ["variables_types"],
+ },
+ {
+ "id": "functions_basic",
+ "name": "Basic functions",
+ "type": "coding",
+ "stage": 5,
+ "desc": "def, parámetros, return, valores por defecto, docstrings",
+ "prereqs": ["variables_types"],
+ },
+ # Nivel 6 — Control de flujo
+ {
+ "id": "loops_for",
+ "name": "For loops",
+ "type": "coding",
+ "stage": 6,
+ "desc": "for, range, enumerate, iteración sobre secuencias",
+ "prereqs": ["functions_basic", "conditionals"],
+ },
+ {
+ "id": "loops_while",
+ "name": "While loops",
+ "type": "coding",
+ "stage": 6,
+ "desc": "while, break, continue, centinela, acumulador",
+ "prereqs": ["loops_for"],
+ },
+ {
+ "id": "lists",
+ "name": "Lists",
+ "type": "coding",
+ "stage": 6,
+ "desc": "crear, indexar, append, extend, slicing, list comprehensions",
+ "prereqs": ["loops_for"],
+ },
+ {
+ "id": "tuples_sets",
+ "name": "Tuples and sets",
+ "type": "coding",
+ "stage": 6,
+ "desc": "tuplas inmutables, sets, operaciones de conjuntos",
+ "prereqs": ["lists"],
+ },
+ {
+ "id": "dicts",
+ "name": "Dictionaries",
+ "type": "coding",
+ "stage": 6,
+ "desc": "crear, acceder, items, keys, values, dict comprehensions",
+ "prereqs": ["lists"],
+ },
+ # Nivel 7 — Funciones avanzadas
+ {
+ "id": "recursion",
+ "name": "Recursion",
+ "type": "coding",
+ "stage": 7,
+ "desc": "caso base, caso recursivo, stack de llamadas, fibonacci, factorial",
+ "prereqs": ["functions_basic", "conditionals"],
+ },
+ {
+ "id": "higher_order",
+ "name": "Higher-order functions",
+ "type": "coding",
+ "stage": 7,
+ "desc": "map, filter, reduce, lambda, funciones como argumento",
+ "prereqs": ["functions_basic", "lists"],
+ },
+ {
+ "id": "generators",
+ "name": "Generators",
+ "type": "coding",
+ "stage": 7,
+ "desc": "yield, generadores, iteradores, lazy evaluation",
+ "prereqs": ["loops_for", "functions_basic"],
+ },
+ {
+ "id": "error_handling",
+ "name": "Error handling",
+ "type": "coding",
+ "stage": 7,
+ "desc": "try/except/finally, raise, excepciones custom",
+ "prereqs": ["functions_basic"],
+ },
+ # Nivel 8 — OOP
+ {
+ "id": "classes_basic",
+ "name": "Classes",
+ "type": "coding",
+ "stage": 8,
+ "desc": "class, __init__, self, atributos, métodos",
+ "prereqs": ["functions_basic", "dicts"],
+ },
+ {
+ "id": "inheritance",
+ "name": "Inheritance",
+ "type": "coding",
+ "stage": 8,
+ "desc": "herencia, super(), override, polimorfismo",
+ "prereqs": ["classes_basic"],
+ },
+ {
+ "id": "dunder_methods",
+ "name": "Dunder methods",
+ "type": "coding",
+ "stage": 8,
+ "desc": "__str__, __repr__, __len__, __add__, __eq__, __iter__",
+ "prereqs": ["classes_basic"],
+ },
+ # Nivel 9 — Avanzado
+ {
+ "id": "decorators",
+ "name": "Decorators",
+ "type": "coding",
+ "stage": 9,
+ "desc": "decoradores, functools.wraps, patrones de decorador",
+ "prereqs": ["higher_order"],
+ },
+ {
+ "id": "context_managers",
+ "name": "Context managers",
+ "type": "coding",
+ "stage": 9,
+ "desc": "with, __enter__/__exit__, contextlib",
+ "prereqs": ["classes_basic", "error_handling"],
+ },
+ {
+ "id": "algorithms",
+ "name": "Algorithms",
+ "type": "coding",
+ "stage": 9,
+ "desc": "sorting, searching, complejidad, divide and conquer",
+ "prereqs": ["recursion", "lists"],
+ },
+ {
+ "id": "file_io",
+ "name": "File I/O",
+ "type": "coding",
+ "stage": 9,
+ "desc": "open, read, write, with, json, csv",
+ "prereqs": ["error_handling", "strings"],
+ },
+]
+
+# Lookup rápido por id
+_CONCEPT_BY_ID = {c["id"]: c for c in CONCEPT_TREE}
+
+
+# =============================================================================
+# StudentProfile — tracking de qué sabe el alumno
+# =============================================================================
+
+
+class StudentProfile:
+ """Perfil adaptativo del alumno: trackea dominio por concepto."""
+
+ def __init__(self) -> None:
+ # concept_id → {"correct": int, "total": int, "last_errors": [str]}
+ self.concepts: dict[str, dict] = defaultdict(
+ lambda: {"correct": 0, "total": 0, "last_errors": []}
+ )
+ self.lesson_count: int = 0
+ self.total_correct: int = 0
+
+ def record(self, concept_id: str, correct: bool, error_desc: str = "") -> None:
+ """Registra un intento del alumno en un concepto."""
+ c = self.concepts[concept_id]
+ c["total"] += 1
+ if correct:
+ c["correct"] += 1
+ elif error_desc:
+ c["last_errors"] = (c["last_errors"] + [error_desc])[-3:]
+ self.lesson_count += 1
+ if correct:
+ self.total_correct += 1
+
+ def mastery(self, concept_id: str) -> float:
+ """Porcentaje de dominio de un concepto (0.0 a 1.0)."""
+ c = self.concepts[concept_id]
+ if c["total"] == 0:
+ return 0.0
+ return c["correct"] / c["total"]
+
+ def is_mastered(self, concept_id: str, threshold: float = 0.7) -> bool:
+ """Un concepto se domina si tiene >= threshold accuracy y >= 3 intentos."""
+ c = self.concepts[concept_id]
+ return c["total"] >= 3 and self.mastery(concept_id) >= threshold
+
+ def prereqs_met(self, concept_id: str) -> bool:
+ """Verifica que los prerequisitos estén dominados (o no vistos aún)."""
+ concept = _CONCEPT_BY_ID.get(concept_id)
+ if not concept:
+ return True
+ for prereq in concept.get("prereqs", []):
+ # Prerequisito cumplido si: dominado O nunca intentado (permitir explorar)
+ c = self.concepts[prereq]
+ if c["total"] > 0 and not self.is_mastered(prereq):
+ return False
+ return True
+
+ def select_next_concept(self, max_drills: int = 6) -> str:
+ """Elige el siguiente concepto a enseñar.
+
+ Prioridad:
+ 1. Nuevos conceptos disponibles (prereqs cumplidos) — siempre introduce al menos uno nuevo
+ antes de volver a reforzar, una vez que el concepto anterior supera max_drills intentos.
+ 2. Conceptos con intentos pero no dominados Y con < max_drills intentos (reforzar)
+ 3. Nuevos conceptos cuyos prereqs están cumplidos (avanzar)
+ 4. Conceptos dominados para repaso espaciado
+
+ max_drills: máximo de intentos seguidos en un concepto sin haberlo dominado
+ antes de forzar la introducción de uno nuevo.
+ """
+ # Separar conceptos que necesitan refuerzo y los que ya se han taladrado mucho
+ drillable = []
+ overdrilled = []
+ for concept in CONCEPT_TREE:
+ cid = concept["id"]
+ c = self.concepts[cid]
+ if c["total"] > 0 and not self.is_mastered(cid):
+ if c["total"] < max_drills:
+ drillable.append((cid, self.mastery(cid)))
+ else:
+ overdrilled.append((cid, self.mastery(cid)))
+
+ # Conceptos nuevos disponibles (prereqs cumplidos, no intentados)
+ available_new = [
+ concept["id"]
+ for concept in CONCEPT_TREE
+ if self.concepts[concept["id"]]["total"] == 0
+ and self.prereqs_met(concept["id"])
+ ]
+
+ # Si hay conceptos taladrados en exceso (≥ max_drills sin dominar)
+ # forzar introducción de algo nuevo antes de volver a drillarlo
+ if overdrilled and available_new:
+ return available_new[0]
+
+ # Reforzar conceptos con pocos intentos (todavía útil)
+ if drillable:
+ drillable.sort(key=lambda x: x[1])
+ return drillable[0][0]
+
+ # Avanzar a nuevo concepto aunque los anteriores no estén dominados
+ if available_new:
+ return available_new[0]
+
+ # Conceptos overdrilled: volver con ellos una vez agotados los nuevos
+ if overdrilled:
+ overdrilled.sort(key=lambda x: x[1])
+ return overdrilled[0][0]
+
+ # Repaso espaciado de conceptos dominados
+ mastered = [c["id"] for c in CONCEPT_TREE if self.is_mastered(c["id"])]
+ if mastered:
+ return random.choice(mastered)
+
+ # Fallback
+ return CONCEPT_TREE[0]["id"]
+
+ def summary(self) -> str:
+ """Genera un resumen textual para el mentor."""
+ lines = [
+ f"Lessons completed: {self.lesson_count}, "
+ f"Overall accuracy: {self.total_correct}/{self.lesson_count} "
+ f"({100 * self.total_correct / max(1, self.lesson_count):.0f}%)"
+ ]
+
+ mastered = []
+ struggling = []
+ untouched = []
+
+ for concept in CONCEPT_TREE:
+ cid = concept["id"]
+ c = self.concepts[cid]
+ if c["total"] == 0:
+ untouched.append(concept["name"])
+ elif self.is_mastered(cid):
+ mastered.append(concept["name"])
+ else:
+ pct = 100 * self.mastery(cid)
+ info = f"{concept['name']} ({pct:.0f}%)"
+ if c["last_errors"]:
+ info += f" — errors: {'; '.join(c['last_errors'][-2:])}"
+ struggling.append(info)
+
+ if mastered:
+ lines.append(f"Mastered: {', '.join(mastered)}")
+ if struggling:
+ lines.append(f"Struggling: {', '.join(struggling)}")
+ if untouched:
+ lines.append(f"Not yet taught: {', '.join(untouched[:5])}")
+
+ return "\n".join(lines)
+
+
+# ── Utilidades ──────────────────────────────────────────────────────────
+
+_LEVEL_MAP: dict[str, int] = {
+ # Etapa 0: presencia
+ "greeting": 0,
+ # Etapa 1: conceptos primitivos
+ "concept_number": 1,
+ "concept_sequence": 1,
+ "concept_word": 1,
+ "concept_true_false": 1,
+ # Etapa 2: lógica cotidiana
+ "concept_compare": 2,
+ "concept_if_then": 2,
+ "concept_repeat": 2,
+ # Etapa 3: procedimientos
+ "concept_steps": 3,
+ "concept_variable_box": 3,
+ "concept_function_machine": 3,
+ # Etapa 4: puente
+ "code_intro": 4,
+ "code_values": 4,
+ "code_true_false": 4,
+ "code_variables": 4,
+ "code_if": 4,
+ # Etapa 5+: código Python
+ "arithmetic": 5,
+ "variables_types": 5,
+ "conditionals": 5,
+ "strings": 5,
+ "functions_basic": 5,
+ "loops_for": 6,
+ "loops_while": 6,
+ "lists": 6,
+ "tuples_sets": 6,
+ "dicts": 6,
+ "recursion": 7,
+ "higher_order": 7,
+ "generators": 7,
+ "error_handling": 7,
+ "classes_basic": 8,
+ "inheritance": 8,
+ "dunder_methods": 8,
+ "decorators": 9,
+ "context_managers": 9,
+ "algorithms": 9,
+ "file_io": 9,
+}
+
+
+def concept_level(concept_id: str) -> int:
+ """Mapea concept_id a nivel del curriculum (0-9).
+
+ 0 = presencia, 1-3 = conceptual, 4 = puente, 5-9 = código Python.
+ """
+ return _LEVEL_MAP.get(concept_id, 5)
diff --git a/scripts/classroom_events.py b/scripts/classroom_events.py
new file mode 100644
index 0000000000000000000000000000000000000000..a811b42fe1d2581e777664fd433f4b6ee44c8781
--- /dev/null
+++ b/scripts/classroom_events.py
@@ -0,0 +1,104 @@
+"""classroom_events.py — Formateo e impresión de eventos del Classroom."""
+
+from __future__ import annotations
+
+
+def format_event_to_console(event_type: str, data: str | dict) -> None:
+ """Imprime un evento del classroom en consola con formato legible."""
+ d = data if isinstance(data, dict) else {}
+
+ _FORMATTERS.get(event_type, _noop)(event_type, data, d)
+
+
+def _noop(_event_type: str, _data: str | dict, _d: dict) -> None:
+ pass
+
+
+def _fmt_system(_et: str, data: str | dict, _d: dict) -> None:
+ print(f" 🏫 {data}")
+
+
+def _fmt_lesson_start(_et: str, _data: str | dict, d: dict) -> None:
+ concept = d.get("concept", "")
+ print(
+ f"\n ═══ Lección {d.get('lesson_id', '?')} — "
+ f"{d.get('level_name', '')} [{concept}] "
+ f"(Nivel {d.get('level', '?')}) ═══"
+ )
+ print(f" 📝 {d.get('problem', '')[:100]}")
+
+
+def _fmt_mentor_explain(_et: str, _data: str | dict, d: dict) -> None:
+ print(f" 📖 Mentor explica: {d.get('explain', '')[:120]}")
+
+
+def _fmt_mentor_example(_et: str, _data: str | dict, d: dict) -> None:
+ example = d.get("example", "")
+ lines = example.split("\n")
+ preview = lines[0][:80] if lines else ""
+ print(f" 💻 Mentor ejemplo: {preview}{'...' if len(lines) > 1 else ''}")
+
+
+def _fmt_student_answer(_et: str, _data: str | dict, d: dict) -> None:
+ print(f" 🧑🎓 Alumno: {d.get('answer', '')[:100]}")
+
+
+def _fmt_teacher_feedback(_et: str, _data: str | dict, d: dict) -> None:
+ icon = "✅" if d.get("correct") else "❌"
+ print(f" 👨🏫 Profesor: {icon} {d.get('feedback', '')[:100]}")
+
+
+def _fmt_lesson_complete(_et: str, _data: str | dict, d: dict) -> None:
+ concept = d.get("concept", "")
+ print(
+ f" 📊 Loss: {d.get('loss', 0):.4f} | "
+ f"EWC: {d.get('ewc_penalty', 0):.6f} | "
+ f"Brain: {d.get('brain_score', 0):.2%} | "
+ f"Acc: {d.get('accuracy', 0):.1%} | "
+ f"Replay: {d.get('replay_size', 0)} | "
+ f"Concepto: {concept}"
+ )
+
+
+def _fmt_level_up(_et: str, _data: str | dict, d: dict) -> None:
+ print(f"\n 🎉 ¡NIVEL UP! → Nivel {d.get('new_level', '?')}: {d.get('nombre', '')}")
+
+
+def _fmt_checkpoint(_et: str, _data: str | dict, d: dict) -> None:
+ print(f" 💾 Checkpoint guardado: lección {d.get('lesson', '?')}")
+
+
+def _fmt_bio_update(_et: str, _data: str | dict, d: dict) -> None:
+ parts = [
+ f"DA={d.get('dopamine', 0):.2f}",
+ f"NE={d.get('norepinephrine', 0):.2f}",
+ f"LR×{d.get('lr_factor', 1):.2f}",
+ ]
+ if d.get("ltp_applied"):
+ parts.append("LTP!")
+ if d.get("sleep_triggered"):
+ parts.append(f"SLEEP(loss={d.get('sleep_loss', 0):.3f})")
+ if d.get("adapters_total", 0) > 0:
+ parts.append(f"LoRA={d.get('adapters_total', 0)}")
+ if d.get("pruned"):
+ parts.append("PRUNED")
+ print(f" 🧠 Bio: {' | '.join(parts)}")
+
+
+def _fmt_error(_et: str, data: str | dict, _d: dict) -> None:
+ print(f" ❗ {data}")
+
+
+_FORMATTERS: dict[str, object] = {
+ "system": _fmt_system,
+ "lesson_start": _fmt_lesson_start,
+ "mentor_explain": _fmt_mentor_explain,
+ "mentor_example": _fmt_mentor_example,
+ "student_answer": _fmt_student_answer,
+ "teacher_feedback": _fmt_teacher_feedback,
+ "lesson_complete": _fmt_lesson_complete,
+ "level_up": _fmt_level_up,
+ "checkpoint": _fmt_checkpoint,
+ "bio_update": _fmt_bio_update,
+ "error": _fmt_error,
+}
diff --git a/scripts/classroom_memory.py b/scripts/classroom_memory.py
new file mode 100644
index 0000000000000000000000000000000000000000..8e9904514a79a1946698cd42779bd13da3c1e0f9
--- /dev/null
+++ b/scripts/classroom_memory.py
@@ -0,0 +1,187 @@
+"""classroom_memory.py — EWC y Replay Buffer para protección de memoria."""
+
+from __future__ import annotations
+
+import random
+import time
+from collections import deque
+from dataclasses import dataclass, field
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+
+def compute_ewc_baseline(
+ model: nn.Module,
+ tokenizer: object,
+ ewc_lambda: float,
+ ewc_samples: int,
+ seq_len: int,
+ device: torch.device,
+) -> "EWC":
+ """Calcula Fisher Information sobre datos que el modelo ya maneja bien.
+
+ Returns:
+ Instancia de EWC con Fisher calculada.
+ """
+ baseline_prompts = [
+ "def suma(a, b):",
+ "for i in range(10):",
+ "class Punto:",
+ "if x > 0:",
+ "import os\n",
+ "def fibonacci(n):",
+ "return sorted(",
+ "try:\n ",
+ "with open('",
+ "result = [x for x in",
+ ]
+
+ baseline_tokens: list[torch.Tensor] = []
+ model.eval()
+ for prompt in baseline_prompts:
+ ids = tokenizer.Encode(prompt)
+ if len(ids) < 4:
+ continue
+ for _ in range(20):
+ if len(ids) > 2:
+ start = random.randint(0, max(0, len(ids) - 3))
+ chunk = ids[start : start + min(seq_len, len(ids) - start)]
+ baseline_tokens.append(torch.tensor(chunk, dtype=torch.long))
+
+ ewc = EWC(model, ewc_lambda)
+ if baseline_tokens:
+ ewc.compute_fisher(model, baseline_tokens, device, ewc_samples)
+
+ return ewc
+
+
+class EWC:
+ """
+ Elastic Weight Consolidation (Kirkpatrick et al., 2017).
+
+ Simula LTP biológica: identifica pesos importantes (alta Fisher info)
+ y penaliza moverlos durante entrenamiento nuevo.
+
+ L_total = L_task + (λ/2) * Σ F_i * (θ_i - θ*_i)²
+ """
+
+ def __init__(self, model: nn.Module, lam: float = 500.0):
+ self.lam = lam
+ self.params_star: dict[str, torch.Tensor] = {}
+ self.fisher: dict[str, torch.Tensor] = {}
+
+ def compute_fisher(
+ self,
+ model: nn.Module,
+ data_loader: list[torch.Tensor],
+ device: torch.device,
+ n_samples: int = 200,
+ ) -> None:
+ """Calcula la Diagonal Fisher Information Matrix sobre datos existentes."""
+ model.eval()
+
+ self.params_star = {
+ n: p.data.clone() for n, p in model.named_parameters() if p.requires_grad
+ }
+
+ self.fisher = {
+ n: torch.zeros_like(p.data)
+ for n, p in model.named_parameters()
+ if p.requires_grad
+ }
+
+ n = min(n_samples, len(data_loader))
+ samples = random.sample(data_loader, n) if len(data_loader) > n else data_loader
+
+ for tokens in samples:
+ model.zero_grad()
+ tokens = tokens.to(device)
+ if tokens.dim() == 1:
+ tokens = tokens.unsqueeze(0)
+
+ input_ids = tokens[:, :-1]
+ targets = tokens[:, 1:]
+ logits, _, _ = model(input_ids)
+
+ loss = F.cross_entropy(
+ logits.reshape(-1, logits.size(-1)),
+ targets.reshape(-1),
+ ignore_index=-100,
+ )
+ loss.backward()
+
+ for name, param in model.named_parameters():
+ if param.requires_grad and param.grad is not None:
+ self.fisher[name] += param.grad.data.pow(2) / n
+
+ model.zero_grad()
+
+ def penalty(self, model: nn.Module) -> torch.Tensor:
+ """Calcula la penalización EWC: (λ/2) * Σ F_i * (θ_i - θ*_i)²"""
+ loss = torch.tensor(0.0, device=next(model.parameters()).device)
+ for name, param in model.named_parameters():
+ if name in self.fisher:
+ loss += (
+ self.fisher[name] * (param - self.params_star[name]).pow(2)
+ ).sum()
+ return (self.lam / 2.0) * loss
+
+
+class ReplayBuffer:
+ """
+ Buffer circular de ejemplos exitosos.
+
+ Mezcla ejemplos nuevos con viejos para evitar olvido catastrófico.
+ Como el replay neuronal durante el sueño: reactiva memorias viejas
+ mientras integra las nuevas.
+ """
+
+ def __init__(self, maxsize: int = 100):
+ self.buffer: deque[dict] = deque(maxlen=maxsize)
+
+ def add(
+ self,
+ problem: str,
+ solution: str,
+ input_ids: torch.Tensor,
+ labels: torch.Tensor,
+ level: int,
+ ) -> None:
+ self.buffer.append(
+ {
+ "problem": problem,
+ "solution": solution,
+ "input_ids": input_ids.cpu(),
+ "labels": labels.cpu(),
+ "level": level,
+ "timestamp": time.time(),
+ }
+ )
+
+ def sample(self, n: int) -> list[dict]:
+ if len(self.buffer) == 0:
+ return []
+ n = min(n, len(self.buffer))
+ return random.sample(list(self.buffer), n)
+
+ def __len__(self) -> int:
+ return len(self.buffer)
+
+
+@dataclass
+class LessonResult:
+ """Resultado de una lección."""
+
+ lesson_id: int
+ level: int
+ problem: str
+ student_answer: str
+ teacher_solution: str
+ correct: bool
+ feedback: str
+ loss: float
+ ewc_penalty: float
+ brain_score: float
+ timestamp: float = field(default_factory=time.time)
diff --git a/scripts/classroom_persistence.py b/scripts/classroom_persistence.py
new file mode 100644
index 0000000000000000000000000000000000000000..cf999d33f4d37ee2d4eca722cf43c8473affdf24
--- /dev/null
+++ b/scripts/classroom_persistence.py
@@ -0,0 +1,121 @@
+"""classroom_persistence.py — Guardado de checkpoints, sesiones y grabaciones."""
+
+from __future__ import annotations
+
+import json
+import time
+from dataclasses import asdict
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import torch
+
+if TYPE_CHECKING:
+ from classroom_curriculum import ClassroomConfig
+ from classroom_memory import LessonResult
+
+
+def save_checkpoint(
+ model: torch.nn.Module,
+ optimizer: torch.optim.Optimizer,
+ config: "ClassroomConfig",
+ lesson_count: int,
+ current_level: int,
+ total_correct: int,
+) -> str:
+ """Guarda checkpoint del modelo.
+
+ Returns:
+ Ruta del checkpoint guardado.
+ """
+ project_root = Path(__file__).parent.parent
+ ckpt_path = project_root / config.checkpoint_out
+ torch.save(
+ {
+ "modelo": model.state_dict(),
+ "optimizer": optimizer.state_dict(),
+ "paso_global": lesson_count,
+ "config": asdict(config),
+ "curriculum_level": current_level,
+ "accuracy": total_correct / max(1, lesson_count),
+ },
+ str(ckpt_path),
+ )
+ return str(ckpt_path)
+
+
+def save_session(session_log: list["LessonResult"]) -> str:
+ """Guarda la sesión completa como JSONL.
+
+ Returns:
+ Ruta del archivo guardado.
+ """
+ project_root = Path(__file__).parent.parent
+ ts = time.strftime("%Y%m%d_%H%M%S")
+ session_path = project_root / f"sessions/classroom_{ts}.jsonl"
+ session_path.parent.mkdir(parents=True, exist_ok=True)
+
+ with open(session_path, "w", encoding="utf-8") as f:
+ for r in session_log:
+ f.write(json.dumps(asdict(r), ensure_ascii=False) + "\n")
+
+ return str(session_path)
+
+
+def save_recording(
+ events: list[dict],
+ recording_start: float,
+ config: "ClassroomConfig",
+ lesson_count: int,
+ total_correct: int,
+ current_level: int,
+) -> str:
+ """Guarda la grabación completa de eventos como HTML reproducible.
+
+ Returns:
+ Ruta del archivo HTML o cadena vacía si no hay eventos.
+ """
+ if not events:
+ return ""
+
+ project_root = Path(__file__).parent.parent
+ ts = time.strftime("%Y%m%d_%H%M%S")
+ recording_dir = project_root / "sessions"
+ recording_dir.mkdir(parents=True, exist_ok=True)
+
+ meta = {
+ "model": "PamparV3 (108M)",
+ "teacher_backend": config.teacher_backend,
+ "teacher_model": config.teacher_model,
+ "start_time": time.strftime(
+ "%Y-%m-%d %H:%M:%S",
+ time.localtime(recording_start),
+ ),
+ "duration_s": round(time.time() - recording_start, 1) if recording_start else 0,
+ "total_lessons": lesson_count,
+ "accuracy": round(total_correct / max(1, lesson_count), 4),
+ "final_level": current_level,
+ "ewc_lambda": config.ewc_lambda,
+ "lr_base": config.lr_base,
+ }
+
+ replay_template = Path(__file__).parent / "classroom_replay.html"
+ if replay_template.exists():
+ template = replay_template.read_text(encoding="utf-8")
+ else:
+ template = "No replay template found "
+
+ recording_data = json.dumps(
+ {"meta": meta, "events": events},
+ ensure_ascii=False,
+ )
+
+ html = template.replace(
+ "/*__RECORDING_DATA__*/",
+ f"window.__RECORDING__ = {recording_data};",
+ )
+
+ out_path = recording_dir / f"classroom_{ts}.html"
+ out_path.write_text(html, encoding="utf-8")
+
+ return str(out_path)
diff --git a/scripts/classroom_replay.html b/scripts/classroom_replay.html
new file mode 100644
index 0000000000000000000000000000000000000000..900047a0e35ae94dfe5d7baeabf2052a998c38ea
--- /dev/null
+++ b/scripts/classroom_replay.html
@@ -0,0 +1,983 @@
+
+
+
+
+
+ PAMPAr Classroom — Replay
+
+
+
+
+ 🎬 PAMPAr Classroom — Replay
+ —
+ —
+ —
+ —
+
+
+
+
+
+
▶ Play
+
⏸ Pausa
+
⏮ Inicio
+
+
+
+
+ 0.5x
+ 1x
+ 2x
+ 5x
+ 10x
+ 50x (rápido)
+
+
+
0 / 0
+
+
+
+
+
diff --git a/scripts/classroom_server.py b/scripts/classroom_server.py
new file mode 100644
index 0000000000000000000000000000000000000000..f2a2b815428c98341c371099ae366866efb1e840
--- /dev/null
+++ b/scripts/classroom_server.py
@@ -0,0 +1,255 @@
+"""classroom_server.py — HTTP SSE server y CLI para el Classroom."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import queue
+import sys
+import threading
+import time
+import traceback
+from http.server import HTTPServer, SimpleHTTPRequestHandler
+from pathlib import Path
+
+from classroom_curriculum import ClassroomConfig
+
+
+class ClassroomHandler(SimpleHTTPRequestHandler):
+ """Handler HTTP con SSE para la UI del classroom."""
+
+ engine: object = None # ClassroomEngine (lazy import para evitar circular)
+ ui_path: str = ""
+
+ def do_GET(self) -> None:
+ if self.path == "/" or self.path == "/index.html":
+ self._serve_ui()
+ elif self.path == "/events":
+ self._serve_sse()
+ elif self.path == "/status":
+ self._serve_status()
+ else:
+ self.send_error(404)
+
+ def do_POST(self) -> None:
+ if self.path == "/start":
+ self._handle_start()
+ elif self.path == "/stop":
+ self._handle_stop()
+ elif self.path == "/save":
+ self._handle_save()
+ else:
+ self.send_error(404)
+
+ def _serve_ui(self) -> None:
+ """Sirve el archivo HTML de la UI."""
+ try:
+ ui_file = Path(self.ui_path)
+ content = ui_file.read_bytes()
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(content)))
+ self.end_headers()
+ self.wfile.write(content)
+ except Exception as e:
+ self.send_error(500, str(e))
+
+ def _serve_sse(self) -> None:
+ """Server-Sent Events stream."""
+ self.send_response(200)
+ self.send_header("Content-Type", "text/event-stream")
+ self.send_header("Cache-Control", "no-cache")
+ self.send_header("Connection", "keep-alive")
+ self.end_headers()
+
+ try:
+ while True:
+ try:
+ event = self.engine.event_queue.get(timeout=1.0)
+ msg = f"event: {event['event']}\ndata: {event['data']}\n\n"
+ self.wfile.write(msg.encode("utf-8"))
+ self.wfile.flush()
+ except queue.Empty:
+ self.wfile.write(b": heartbeat\n\n")
+ self.wfile.flush()
+ except (BrokenPipeError, ConnectionResetError):
+ pass
+
+ def _serve_status(self) -> None:
+ """Estado actual del aula."""
+ e = self.engine
+ status = {
+ "lesson_count": e.lesson_count,
+ "level": e.current_level,
+ "accuracy": e.total_correct / max(1, e.lesson_count),
+ "replay_size": len(e.replay),
+ "session_log_size": len(e.session_log),
+ }
+ body = json.dumps(status).encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _handle_start(self) -> None:
+ """Inicia las lecciones en background."""
+ threading.Thread(target=self._run_lessons, daemon=True).start()
+ self._json_response({"status": "started"})
+
+ def _handle_stop(self) -> None:
+ """Detiene las lecciones."""
+ self.engine._running = False
+ self._json_response({"status": "stopped"})
+
+ def _handle_save(self) -> None:
+ """Guarda la sesión."""
+ path = self.engine.save_session()
+ rec_path = self.engine.save_recording()
+ self.engine._save_checkpoint()
+ self._json_response({"status": "saved", "path": path, "recording": rec_path})
+
+ def _run_lessons(self) -> None:
+ """Loop principal de lecciones."""
+ self.engine._running = True
+ try:
+ while (
+ self.engine._running
+ and self.engine.lesson_count < self.engine.config.max_lessons
+ ):
+ self.engine.run_lesson()
+ time.sleep(1)
+ except Exception as e:
+ self.engine._emit("error", f"Error: {e}\n{traceback.format_exc()}")
+ finally:
+ self.engine._emit("system", "Sesión finalizada.")
+ self.engine.save_session()
+ self.engine.save_recording()
+ self.engine._save_checkpoint()
+
+ def _json_response(self, data: dict, code: int = 200) -> None:
+ body = json.dumps(data).encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, format, *args) -> None:
+ """Silenciar logs del HTTP server."""
+ pass
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="PAMPAr Classroom — Aula simulada")
+ parser.add_argument(
+ "--checkpoint",
+ default="checkpoints/v3_ghidra_v9.pt",
+ help="Checkpoint del alumno",
+ )
+ parser.add_argument(
+ "--checkpoint-out",
+ default="checkpoints/v3_classroom.pt",
+ help="Donde guardar el progreso",
+ )
+ parser.add_argument(
+ "--teacher",
+ choices=["github", "openrouter", "qwen"],
+ default="qwen",
+ help="Backend del profesor",
+ )
+ parser.add_argument("--model", default="qwen-plus", help="Modelo del profesor")
+ parser.add_argument(
+ "--api-key",
+ default="",
+ help="API key (o usa GITHUB_TOKEN / OPENROUTER_API_KEY / QWEN_API_KEY)",
+ )
+ parser.add_argument("--lr", type=float, default=1e-5, help="Learning rate base")
+ parser.add_argument("--ewc-lambda", type=float, default=50.0, help="Fuerza EWC")
+ parser.add_argument(
+ "--max-lessons", type=int, default=200, help="Máximo de lecciones"
+ )
+ parser.add_argument(
+ "--port", type=int, default=8888, help="Puerto del servidor web"
+ )
+ parser.add_argument("--no-ui", action="store_true", help="Solo consola, sin UI web")
+ parser.add_argument(
+ "--no-bio", action="store_true", help="Desactivar mecanismos bio-inspirados"
+ )
+ parser.add_argument("--level", type=int, default=1, help="Nivel inicial (1-5)")
+
+ args = parser.parse_args()
+
+ config = ClassroomConfig(
+ checkpoint_in=args.checkpoint,
+ checkpoint_out=args.checkpoint_out,
+ teacher_backend=args.teacher,
+ teacher_model=args.model,
+ api_key=args.api_key,
+ lr_base=args.lr,
+ ewc_lambda=args.ewc_lambda,
+ max_lessons=args.max_lessons,
+ port=args.port,
+ start_level=args.level,
+ bio_enabled=not args.no_bio,
+ )
+
+ # Import engine aquí (evita circular imports)
+ from classroom import ClassroomEngine
+
+ engine = ClassroomEngine(config)
+ engine.load()
+
+ if not engine.teacher:
+ print("\n❌ No se pudo configurar el profesor. Revisa tu API key.")
+ sys.exit(1)
+
+ if args.no_ui:
+ print("\n" + "=" * 60)
+ print(" 🏫 PAMPAr CLASSROOM — Modo consola")
+ print("=" * 60)
+ engine._running = True
+ try:
+ while engine._running and engine.lesson_count < config.max_lessons:
+ engine.run_lesson()
+ time.sleep(0.5)
+ except KeyboardInterrupt:
+ print("\n\n Interrumpido por usuario.")
+ finally:
+ engine.save_session()
+ rec = engine.save_recording()
+ engine._save_checkpoint()
+ accuracy = engine.total_correct / max(1, engine.lesson_count)
+ print(
+ f"\n 📊 Resumen: {engine.lesson_count} lecciones, {accuracy:.1%} accuracy, nivel {engine.current_level}"
+ )
+ if rec:
+ print(f" 🎥 Grabación guardada: {rec}")
+ else:
+ ui_path = Path(__file__).parent / "classroom.html"
+ ClassroomHandler.engine = engine
+ ClassroomHandler.ui_path = str(ui_path)
+
+ server = HTTPServer(("127.0.0.1", config.port), ClassroomHandler)
+ print(f"\n 🏫 PAMPAr CLASSROOM — UI en http://localhost:{config.port}")
+ print(f" Presiona Ctrl+C para detener\n")
+
+ try:
+ import webbrowser
+
+ webbrowser.open(f"http://localhost:{config.port}")
+ except Exception:
+ pass
+
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ print("\n Detenido.")
+ engine.save_session()
+ engine.save_recording()
+ engine._save_checkpoint()
+ server.server_close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/classroom_teacher.py b/scripts/classroom_teacher.py
new file mode 100644
index 0000000000000000000000000000000000000000..d965962afb1537b0c82e701cb91dc5d90d611cba
--- /dev/null
+++ b/scripts/classroom_teacher.py
@@ -0,0 +1,367 @@
+"""classroom_teacher.py — Mentor conversacional via API (GitHub Models / OpenRouter / Qwen).
+
+El mentor genera lecciones completas como un tutor en un chat:
+explicaciones, ejemplos, ejercicios y correcciones, todo en un flujo
+conversacional que el alumno (PamparV3) absorbe via gradient descent.
+
+Tipos de lección según etapa del curriculum:
+ conceptual — sin código, lenguaje natural, analogías cotidianas
+ bridge — concepto + correspondencia Python
+ coding — Python puro (comportamiento original)
+"""
+
+from __future__ import annotations
+
+import json
+import time
+import urllib.error
+import urllib.request
+
+# ── System prompts ──────────────────────────────────────────────────────────
+
+_META_CONTEXT = (
+ "You are a senior AI mentor. Your student is PamparV3, a 108M parameter "
+ "language model learning to understand the world and eventually write Python code. "
+ "PamparV3 learns via gradient descent from your responses — every token you "
+ "produce directly shapes its weights.\n\n"
+)
+
+# ── Prompt CONCEPTUAL (etapas 0-3): sin código, lenguaje natural ────────────
+_SYSTEM_CONCEPTUAL = (
+ _META_CONTEXT + "RIGHT NOW your student is in the CONCEPTUAL stage. This means:\n"
+ "- NO CODE whatsoever — not a single line of Python\n"
+ "- Teach like a primary school teacher starting from absolute basics\n"
+ "- Use everyday analogies, real-world examples, questions\n"
+ "- Use SPANISH. Keep it warm, simple, conversational.\n"
+ "- The student is learning what things ARE before learning to code them.\n\n"
+ "Format EXACTLY:\n"
+ "---EXPLAIN---\n"
+ "[Explanation of the concept in simple Spanish, using everyday analogies]\n"
+ "---EXAMPLE---\n"
+ "[2-3 concrete real-world examples that illustrate the concept. No code.]\n"
+ "---CLAVE---\n"
+ "[3 bullet points in Spanish starting with '- ': the exact ideas the student MUST retain from this lesson]\n"
+ "---EXERCISE---\n"
+ "[A simple question in Spanish the student can answer in natural language]\n"
+ "---SOLUTION---\n"
+ "[The ideal answer the student should give, in Spanish]\n\n"
+ "Rules:\n"
+ "- ZERO code. If the concept is 'number', talk about apples and people, not int.\n"
+ "- Be warm and encouraging, like talking to a curious child.\n"
+ "- Explanations AND examples AND exercise in SPANISH.\n"
+ "- Keep the exercise answerable in 1-3 sentences.\n"
+ "- The ---CLAVE--- section is the most important: distill the lesson into 3 ideas to retain.\n"
+)
+
+# ── Prompt BRIDGE (etapa 4): concepto cotidiano → Python ────────────────────
+_SYSTEM_BRIDGE = (
+ _META_CONTEXT
+ + "Your student is in the BRIDGE stage: they understand everyday concepts and "
+ "are now learning that Python is just a way to WRITE those concepts precisely.\n\n"
+ "Teaching approach:\n"
+ "- Always START with the everyday analogy they already know\n"
+ "- THEN show the Python equivalent side-by-side\n"
+ "- Use Spanish for explanations, Python for code\n"
+ "- Keep code ultra-simple — 1-3 lines max\n\n"
+ "Format EXACTLY:\n"
+ "---EXPLAIN---\n"
+ "[Everyday analogy first, then Python equivalent. Spanish + minimal Python.]\n"
+ "---EXAMPLE---\n"
+ "[Side by side: 'In real life: X ... In Python: Y'. Very short code.]\n"
+ "---CLAVE---\n"
+ "[3 bullet points in Spanish starting with '- ': what the student MUST retain from this bridge lesson]\n"
+ "---EXERCISE---\n"
+ "[A simple question that can be answered in Python + 1 sentence of explanation]\n"
+ "---SOLUTION---\n"
+ "[Correct Python + brief Spanish explanation of why it's correct]\n\n"
+ "Rules:\n"
+ "- Code blocks max 3 lines. No imports. No complex structures.\n"
+ "- ALWAYS connect to the everyday concept first.\n"
+ "- If the concept is 'variables in Python': start with 'Una caja con nombre...'\n"
+ "- The ---CLAVE--- section bridges real-world and Python: make it memorable.\n"
+)
+
+# ── Prompt CODING (etapas 5+): Python puro ──────────────────────────────────
+_SYSTEM_MENTOR = (
+ _META_CONTEXT
+ + "Your student understands concepts and is now learning Python deeply.\n\n"
+ "Teaching guidelines:\n"
+ "- Write CLEAN, CORRECT, IDIOMATIC Python\n"
+ "- Consistent formatting (the tokenizer is sensitive to whitespace)\n"
+ "- Prefer simple, readable solutions over clever one-liners\n"
+ "- Include type hints and brief docstrings\n"
+ "- No unnecessary imports or abstractions\n\n"
+ "Structure your lesson as:\n"
+ "---EXPLAIN---\n"
+ "[Brief concept explanation in Spanish, 2-3 sentences]\n"
+ "---EXAMPLE---\n"
+ "[A complete working code example demonstrating the concept]\n"
+ "---CLAVE---\n"
+ "[3 bullet points in Spanish starting with '- ': the exact patterns/rules the student must memorize]\n"
+ "---EXERCISE---\n"
+ "[A clear problem statement for the student to solve]\n"
+ "---SOLUTION---\n"
+ "[The correct Python solution]\n\n"
+ "Rules:\n"
+ "- Code must be clean Python, NO markdown, NO ```python blocks\n"
+ "- Each example/solution must be a complete, runnable function\n"
+ "- Use the EXACT function name you specify in the exercise\n"
+ "- Explanations in SPANISH, code in English\n"
+ "- The ---CLAVE--- section is critical: distill the 3 most important patterns to remember.\n"
+)
+
+# ── Prompt de evaluación de respuestas conceptuales ────────────────────────
+_SYSTEM_RESPOND_CONCEPTUAL = (
+ _META_CONTEXT
+ + "The student just answered a conceptual question (no code involved). "
+ "Evaluate whether they demonstrated understanding of the concept.\n\n"
+ "Respond with a JSON object:\n"
+ ' "correct": true if the student showed understanding, false if confused,\n'
+ ' "feedback": "1-2 sentences in Spanish acknowledging what was right/wrong",\n'
+ ' "fix": "the ideal answer in Spanish if wrong, empty string if correct",\n'
+ ' "next_concept": ""\n'
+ "\nBe generous — if the student said ANYTHING related to the concept, mark correct.\n"
+ "Respond ONLY with the JSON object.\n"
+)
+
+# ── Prompt de evaluación de respuestas puente ───────────────────────────────
+_SYSTEM_RESPOND_BRIDGE = (
+ _META_CONTEXT
+ + "The student just attempted a bridge exercise that mixes everyday concepts "
+ "with simple Python. Evaluate their understanding.\n\n"
+ "Respond with a JSON object:\n"
+ ' "correct": true/false,\n'
+ ' "feedback": "1-2 sentences in Spanish",\n'
+ ' "fix": "corrected code + explanation if wrong, empty if correct",\n'
+ ' "next_concept": ""\n'
+ "\nBe lenient with syntax — focus on whether the IDEA is correct.\n"
+ "Respond ONLY with the JSON object.\n"
+)
+
+# ── Prompt de evaluación de código ─────────────────────────────────────────
+_SYSTEM_RESPOND = (
+ _META_CONTEXT
+ + "The student just attempted a Python coding exercise. Continue the teaching conversation.\n\n"
+ "Respond with a JSON object:\n"
+ ' "correct": true/false,\n'
+ ' "feedback": "1-2 sentences in Spanish about what went right/wrong",\n'
+ ' "fix": "corrected code if wrong, empty string if correct",\n'
+ ' "next_concept": "what concept to teach next based on student performance"\n'
+ "\nRespond ONLY with the JSON object.\n"
+ "Be strict: wrong function name, broken syntax, or incorrect logic = incorrect."
+)
+
+_SYSTEM_SOLVE = (
+ _META_CONTEXT
+ + "When given a coding problem, respond with ONLY the Python code solution. "
+ "No explanations, no markdown, no ```python blocks. Just clean, correct Python code."
+)
+
+
+class Teacher:
+ """Modelo profesor via API (GitHub Models, OpenRouter o Qwen/DashScope)."""
+
+ ENDPOINTS = {
+ "github": "https://models.inference.ai.azure.com/chat/completions",
+ "openrouter": "https://openrouter.ai/api/v1/chat/completions",
+ "qwen": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
+ }
+
+ def __init__(self, backend: str, model: str, api_key: str):
+ self.backend = backend
+ self.model = model
+ self.api_key = api_key
+ self.endpoint = self.ENDPOINTS[backend]
+
+ def _headers(self) -> dict[str, str]:
+ h = {
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ }
+ if self.backend == "openrouter":
+ h["HTTP-Referer"] = "https://github.com/lucasmella-stack/PAMPAr-Coder"
+ h["X-Title"] = "PAMPAr Classroom"
+ return h
+
+ def _call(
+ self, messages: list[dict], max_tokens: int = 800, temperature: float = 0.3
+ ) -> str | None:
+ """Llama a la API del profesor."""
+ payload = json.dumps(
+ {
+ "model": self.model,
+ "messages": messages,
+ "max_tokens": max_tokens,
+ "temperature": temperature,
+ }
+ ).encode("utf-8")
+
+ req = urllib.request.Request(
+ self.endpoint,
+ data=payload,
+ headers=self._headers(),
+ method="POST",
+ )
+
+ for intento in range(3):
+ try:
+ with urllib.request.urlopen(req, timeout=60) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+ return data["choices"][0]["message"]["content"]
+ except urllib.error.HTTPError as e:
+ if e.code == 429:
+ time.sleep(10 * (intento + 1))
+ continue
+ body = e.read().decode("utf-8", errors="ignore")[:200]
+ print(f" [Teacher API {e.code}] {body}")
+ return None
+ except Exception as e:
+ print(f" [Teacher error] {e}")
+ time.sleep(5)
+ return None
+
+ def generate_solution(self, problem: str) -> str | None:
+ """Pide al profesor la solución correcta para un problema."""
+ messages = [
+ {"role": "system", "content": _SYSTEM_SOLVE},
+ {"role": "user", "content": problem},
+ ]
+ return self._call(messages, max_tokens=500, temperature=0.2)
+
+ def generate_lesson(
+ self, student_profile: str, concept: str, concept_type: str = "coding"
+ ) -> dict | None:
+ """Genera una lección completa según el tipo de concepto.
+
+ Args:
+ student_profile: Resumen del perfil del alumno.
+ concept: Nombre del concepto a enseñar.
+ concept_type: "conceptual" | "bridge" | "coding"
+
+ Returns:
+ Dict con keys: explain, example, exercise, solution. None si falla.
+ """
+ if concept_type == "conceptual":
+ system = _SYSTEM_CONCEPTUAL
+ max_tokens = 800
+ elif concept_type == "bridge":
+ system = _SYSTEM_BRIDGE
+ max_tokens = 1000
+ else:
+ system = _SYSTEM_MENTOR
+ max_tokens = 1200
+
+ user_msg = (
+ f"Student profile:\n{student_profile}\n\n"
+ f"Teach a lesson about: {concept}\n"
+ f"Generate the lesson now."
+ )
+ messages = [
+ {"role": "system", "content": system},
+ {"role": "user", "content": user_msg},
+ ]
+ raw = self._call(messages, max_tokens=max_tokens, temperature=0.4)
+ if not raw:
+ return None
+ return self._parse_lesson(raw)
+
+ def respond_to_attempt(
+ self,
+ exercise: str,
+ student_code: str,
+ student_profile: str,
+ concept_type: str = "coding",
+ ) -> dict:
+ """Evalúa el intento del alumno según el tipo de concepto.
+
+ Args:
+ exercise: El ejercicio o pregunta planteada.
+ student_code: La respuesta del alumno (código o texto).
+ student_profile: Resumen del perfil.
+ concept_type: "conceptual" | "bridge" | "coding"
+
+ Returns:
+ Dict con: correct, feedback, fix, next_concept.
+ """
+ if concept_type == "conceptual":
+ system = _SYSTEM_RESPOND_CONCEPTUAL
+ elif concept_type == "bridge":
+ system = _SYSTEM_RESPOND_BRIDGE
+ else:
+ system = _SYSTEM_RESPOND
+
+ messages = [
+ {"role": "system", "content": system},
+ {
+ "role": "user",
+ "content": (
+ f"Student profile:\n{student_profile}\n\n"
+ f"Exercise:\n{exercise}\n\n"
+ f"Student's attempt:\n{student_code}"
+ ),
+ },
+ ]
+ raw = self._call(messages, max_tokens=600, temperature=0.1)
+ if not raw:
+ return {
+ "correct": False,
+ "feedback": "Error de comunicación",
+ "fix": "",
+ "next_concept": "",
+ }
+ try:
+ raw = raw.strip()
+ if raw.startswith("```"):
+ raw = raw.split("\n", 1)[1].rsplit("```", 1)[0]
+ result = json.loads(raw)
+ result.setdefault("next_concept", "")
+ return result
+ except json.JSONDecodeError:
+ return {
+ "correct": False,
+ "feedback": raw[:200],
+ "fix": "",
+ "next_concept": "",
+ }
+
+ def _parse_lesson(self, raw: str) -> dict | None:
+ """Parsea la respuesta del mentor en secciones."""
+ sections: dict[str, str] = {}
+ markers = {
+ "---EXPLAIN---": "explain",
+ "---EXAMPLE---": "example",
+ "---CLAVE---": "clave",
+ "---EXERCISE---": "exercise",
+ "---SOLUTION---": "solution",
+ }
+
+ current_key: str | None = None
+ current_lines: list[str] = []
+
+ for line in raw.split("\n"):
+ stripped = line.strip()
+ if stripped in markers:
+ if current_key:
+ sections[current_key] = "\n".join(current_lines).strip()
+ current_key = markers[stripped]
+ current_lines = []
+ elif current_key is not None:
+ current_lines.append(line)
+
+ if current_key:
+ sections[current_key] = "\n".join(current_lines).strip()
+
+ # Validar que tenemos al menos ejemplo y solución
+ if "example" not in sections or "solution" not in sections:
+ # Fallback: tratar todo como ejemplo
+ return {
+ "explain": "",
+ "example": raw.strip(),
+ "exercise": "",
+ "solution": raw.strip(),
+ }
+
+ sections.setdefault("explain", "")
+ sections.setdefault("clave", "")
+ sections.setdefault("exercise", "")
+ return sections
diff --git a/scripts/classroom_training.py b/scripts/classroom_training.py
new file mode 100644
index 0000000000000000000000000000000000000000..f68b825fe92b64ed7d5f570efb8dc9700a82a2dd
--- /dev/null
+++ b/scripts/classroom_training.py
@@ -0,0 +1,228 @@
+"""classroom_training.py — Tokenización y paso de entrenamiento del Classroom."""
+
+from __future__ import annotations
+
+import unicodedata
+from dataclasses import dataclass
+from typing import Protocol
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+
+
+def _norm_for_tok(text: str) -> str:
+ """Elimina diacríticos y reemplaza puntuación invertida para compatibilidad
+ con el tokenizador pampar_48k (que no tiene á/é/ó/ñ/¿/¡ en vocabulario)."""
+ nfkd = unicodedata.normalize("NFKD", text)
+ without_accents = "".join(c for c in nfkd if unicodedata.category(c) != "Mn")
+ return without_accents.replace("¿", "?").replace("¡", "!")
+
+
+class HasLRConfig(Protocol):
+ """Protocolo mínimo para la config de LR."""
+
+ lr_base: float
+ lr_llaves_mult: float
+ lr_attn_mult: float
+ lr_embed_mult: float
+ lr_ffn_mult: float
+
+
+def setup_optimizer(
+ model: nn.Module,
+ config: HasLRConfig,
+) -> tuple[torch.optim.Optimizer, float, list[dict]]:
+ """Configura AdamW con Learning Rate diferencial.
+
+ Returns:
+ (optimizer, baseline_lr, param_group_info) donde param_group_info
+ es una lista de dicts con 'label', 'lr', 'n_params' para logging.
+ """
+ param_groups: list[dict] = []
+ assigned: set[str] = set()
+
+ # Grupo 1: LLAVES / Tálamo — casi congelado
+ llaves_params = []
+ for name, param in model.named_parameters():
+ if any(k in name for k in ["talamo", "llaves", "attn_proj"]):
+ if param.requires_grad:
+ llaves_params.append(param)
+ assigned.add(name)
+ if llaves_params:
+ param_groups.append(
+ {
+ "params": llaves_params,
+ "lr": config.lr_base * config.lr_llaves_mult,
+ "label": "llaves_talamo",
+ }
+ )
+
+ # Grupo 2: Atención — aprende lento
+ attn_params = []
+ for name, param in model.named_parameters():
+ if name not in assigned and any(
+ k in name for k in ["attn", "q_proj", "k_proj", "v_proj", "o_proj"]
+ ):
+ if param.requires_grad:
+ attn_params.append(param)
+ assigned.add(name)
+ if attn_params:
+ param_groups.append(
+ {
+ "params": attn_params,
+ "lr": config.lr_base * config.lr_attn_mult,
+ "label": "attention",
+ }
+ )
+
+ # Grupo 3: Embeddings — aprende lento
+ embed_params = []
+ for name, param in model.named_parameters():
+ if name not in assigned and any(k in name for k in ["tok_emb", "emb"]):
+ if param.requires_grad:
+ embed_params.append(param)
+ assigned.add(name)
+ if embed_params:
+ param_groups.append(
+ {
+ "params": embed_params,
+ "lr": config.lr_base * config.lr_embed_mult,
+ "label": "embeddings",
+ }
+ )
+
+ # Grupo 4: FFN / StreamFFN / todo lo demás
+ ffn_params = []
+ for name, param in model.named_parameters():
+ if name not in assigned and param.requires_grad:
+ ffn_params.append(param)
+ assigned.add(name)
+ if ffn_params:
+ param_groups.append(
+ {
+ "params": ffn_params,
+ "lr": config.lr_base * config.lr_ffn_mult,
+ "label": "ffn_generation",
+ }
+ )
+
+ optimizer = torch.optim.AdamW(param_groups, betas=(0.9, 0.95), weight_decay=0.01)
+
+ info = []
+ for g in param_groups:
+ n = sum(p.numel() for p in g["params"])
+ info.append({"label": g["label"], "lr": g["lr"], "n_params": n})
+
+ return optimizer, config.lr_base, info
+
+
+def tokenize_pair(
+ tokenizer: object,
+ problem: str,
+ solution: str,
+ seq_len: int,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """Tokeniza un par problema→solución con máscara de loss.
+
+ Returns:
+ (input_ids, labels) donde labels tiene -100 en el prompt
+ para que el loss solo se compute sobre la solución.
+ """
+ prompt = f"### Problem:\n{problem}\n### Solution:\n```python\n"
+ prompt_ids = tokenizer.Encode(_norm_for_tok(prompt))
+ solution_ids = tokenizer.Encode(_norm_for_tok(solution + "\n```"))
+
+ all_ids = prompt_ids + solution_ids
+ if len(all_ids) > seq_len:
+ all_ids = all_ids[:seq_len]
+ n_prompt = min(len(prompt_ids), len(all_ids))
+ else:
+ n_prompt = len(prompt_ids)
+
+ input_ids = torch.tensor(all_ids, dtype=torch.long)
+ labels = input_ids.clone()
+ labels[:n_prompt] = -100
+
+ return input_ids, labels
+
+
+def tokenize_teaching(
+ tokenizer: object,
+ text: str,
+ seq_len: int,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """Tokeniza contenido de enseñanza donde TODOS los tokens son entrenables.
+
+ Se usa para que el alumno absorba explicaciones y ejemplos del mentor.
+ """
+ ids = tokenizer.Encode(_norm_for_tok(text))
+ if len(ids) > seq_len:
+ ids = ids[:seq_len]
+ input_ids = torch.tensor(ids, dtype=torch.long)
+ labels = input_ids.clone()
+ return input_ids, labels
+
+
+def train_step(
+ model: nn.Module,
+ optimizer: torch.optim.Optimizer,
+ ewc: object,
+ examples: list[tuple[torch.Tensor, torch.Tensor]],
+ device: torch.device,
+) -> tuple[float, float, dict]:
+ """Un paso de entrenamiento con loss masking.
+
+ Args:
+ model: Modelo PamparV3.
+ optimizer: Optimizer con LR diferencial.
+ ewc: Objeto EWC para penalización.
+ examples: lista de (input_ids, labels) donde labels=-100 en prompt.
+ device: dispositivo de cómputo.
+
+ Returns: (loss_ce, ewc_penalty, last_info)
+ """
+ model.train()
+ optimizer.zero_grad()
+
+ total_loss = torch.tensor(0.0, device=device)
+ total_ce = 0.0
+ n = 0
+ last_info: dict = {}
+
+ for input_ids, labels in examples:
+ input_ids = input_ids.to(device)
+ labels = labels.to(device)
+ if input_ids.dim() == 1:
+ input_ids = input_ids.unsqueeze(0)
+ labels = labels.unsqueeze(0)
+ if input_ids.shape[1] < 3:
+ continue
+
+ inp = input_ids[:, :-1]
+ tgt = labels[:, 1:]
+ logits, _, info = model(inp)
+ last_info = info
+
+ loss_ce = F.cross_entropy(
+ logits.reshape(-1, logits.size(-1)),
+ tgt.reshape(-1),
+ ignore_index=-100,
+ )
+ total_loss = total_loss + loss_ce
+ total_ce += loss_ce.item()
+ n += 1
+
+ if n == 0:
+ return 0.0, 0.0, {}
+
+ total_loss = total_loss / n
+
+ ewc_pen = ewc.penalty(model)
+ total_loss = total_loss + ewc_pen
+
+ total_loss.backward()
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
+ optimizer.step()
+
+ return total_ce / n, ewc_pen.item(), last_info
diff --git a/scripts/destilar_v3.py b/scripts/destilar_v3.py
new file mode 100644
index 0000000000000000000000000000000000000000..521fafa02913c3414ea0cbe4c1b9aeea6bcf7621
--- /dev/null
+++ b/scripts/destilar_v3.py
@@ -0,0 +1,468 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+"""
+destilar_v3.py — Destilación de Qwen2.5-Coder-32B → PamparV3
+
+Usa OpenRouter (tier GRATIS) para generar código Python de calidad
+profesional y lo guarda como JSONL para entrenar PamparV3.
+
+Modelos gratuitos disponibles en OpenRouter:
+ - qwen/qwen-2.5-coder-32b-instruct:free ← recomendado (mejor en código)
+ - deepseek/deepseek-r1:free ← reasoning, más lento
+ - google/gemini-2.0-flash-thinking-exp:free
+
+Uso:
+ # Obtener API key gratis en https://openrouter.ai/keys
+ python scripts/destilar_v3.py --api-key sk-or-...
+ python scripts/destilar_v3.py --api-key sk-or-... --n 2000
+ python scripts/destilar_v3.py --api-key sk-or-... --modelo deepseek/deepseek-r1:free
+
+ # Con variable de entorno (recomendado):
+ $env:OPENROUTER_API_KEY = "sk-or-..."
+ python scripts/destilar_v3.py --n 3000
+"""
+
+import argparse
+import json
+import os
+import random
+import sys
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+# Leer .env si existe (para no exponer la key en el terminal)
+_env_file = Path(__file__).parent.parent / ".env"
+if _env_file.exists():
+ for _line in _env_file.read_text(encoding="utf-8").splitlines():
+ _line = _line.strip()
+ if _line and not _line.startswith("#") and "=" in _line:
+ _k, _v = _line.split("=", 1)
+ os.environ.setdefault(_k.strip(), _v.strip())
+
+# =============================================================================
+# Prompts para el profesor — ejercicios de código Python de distinto nivel
+# =============================================================================
+
+TEMAS_NIVEL_1 = [
+ "funciones de strings: reverse, count, replace, strip, split",
+ "operaciones con listas: append, sort, filter, map, zip",
+ "números: primos, factoriales, fibonacci, divisores",
+ "diccionarios: frecuencias, invertir, merge, filtrar por valor",
+ "condicionales y loops: FizzBuzz, triangulos, patrones",
+ "manejo de fechas con datetime",
+ "operaciones de sets: union, interseccion, diferencia",
+]
+
+TEMAS_NIVEL_2 = [
+ "recursion: torres de hanoi, permutaciones, árbol de decisión",
+ "algoritmos de sorting: bubble, merge, quick, heap sort",
+ "búsqueda binaria y variantes",
+ "programación funcional: reduce, partial, currying, closures",
+ "generadores e iteradores: yield, send, StopIteration",
+ "decoradores: memoize, timer, retry, rate_limit",
+ "context managers: __enter__, __exit__, contextlib",
+ "comprensiones anidadas y expresiones generadoras complejas",
+]
+
+TEMAS_NIVEL_3 = [
+ "clases y OOP: herencia, polimorfismo, dunder methods",
+ "dataclasses y attrs: frozen, validators, converters",
+ "patrones de diseño: singleton, factory, observer, strategy",
+ "metaclases y descriptores",
+ "async/await: asyncio, tasks, gather, queues",
+ "threading y multiprocessing: locks, events, pools",
+ "estructuras de datos: linked list, árbol BST, heap, graph",
+ "algoritmos de grafos: BFS, DFS, Dijkstra, A*",
+ "type hints avanzados: Generic, Protocol, TypeVar, overload",
+]
+
+TEMAS_NIVEL_4 = [
+ "parsers y tokenizers simples desde cero",
+ "implementar un mini ORM estilo Django desde cero",
+ "web scraping con requests y BeautifulSoup",
+ "API REST simple con FastAPI y Pydantic",
+ "testing con pytest: fixtures, parametrize, mocking, coverage",
+ "profiling y optimización: cProfile, memory_profiler, line_profiler",
+ "implementar un sistema de caché LRU y TTL",
+ "compresión y serialización: pickle, json, msgpack, protocol buffers",
+]
+
+TODOS_LOS_TEMAS = [
+ (1, t) for t in TEMAS_NIVEL_1
+] + [
+ (2, t) for t in TEMAS_NIVEL_2
+] + [
+ (3, t) for t in TEMAS_NIVEL_3
+] + [
+ (4, t) for t in TEMAS_NIVEL_4
+]
+
+
+def prompt_para_tema(nivel: int, tema: str) -> str:
+ """Genera un prompt que hace que el profesor produzca código Python real."""
+ instrucciones = {
+ 1: "Escribe 3 funciones Python simples pero útiles relacionadas con",
+ 2: "Escribe 2 soluciones Python con explicación de complejidad sobre",
+ 3: "Implementa desde cero en Python (sin librerías externas) un ejemplo completo de",
+ 4: "Escribe código Python de producción, bien documentado y con tests, sobre",
+ }
+ base = instrucciones.get(nivel, "Escribe código Python sobre")
+
+ return (
+ f"{base} {tema}. "
+ "Incluye docstrings, type hints, manejo de edge cases y ejemplos de uso. "
+ "El código debe ser correcto, idiomático y ejecutable tal cual. "
+ "No incluyas explicaciones en prosa, solo código Python con comentarios inline."
+ )
+
+
+# =============================================================================
+# Cliente OpenRouter
+# =============================================================================
+
+# Modelo principal (pago, con créditos OpenRouter)
+MODELO_PRINCIPAL = "qwen/qwen3-coder-30b-a3b-instruct"
+
+# Fallback si el principal tiene rate limit
+MODELOS_FALLBACK = [
+ "qwen/qwen3-coder-30b-a3b-instruct",
+ "qwen/qwen-2.5-coder-32b-instruct",
+ "qwen/qwen2.5-coder-7b-instruct",
+]
+
+# Precio aproximado por 1M tokens (input, output) — para tracking de costo
+PRECIO_POR_MILLON = {
+ "qwen/qwen3-coder-30b-a3b-instruct": (0.070, 0.270),
+ "qwen/qwen-2.5-coder-32b-instruct": (0.200, 0.200),
+ "qwen/qwen2.5-coder-7b-instruct": (0.030, 0.090),
+}
+
+MODELOS_FREE = MODELOS_FALLBACK # alias para retrocompatibilidad
+
+_modelo_idx = 0 # rotación global
+_costo_total = 0.0 # tracking de gasto
+
+
+def _sumar_costo(modelo: str, tokens_in: int, tokens_out: int) -> float:
+ """Calcula y acumula costo de la llamada."""
+ global _costo_total
+ pin, pout = PRECIO_POR_MILLON.get(modelo, (0.2, 0.2))
+ costo = (tokens_in * pin + tokens_out * pout) / 1_000_000
+ _costo_total += costo
+ return costo
+
+
+def llamar_api(
+ prompt: str,
+ api_key: str,
+ modelo: str,
+ max_tokens: int = 1500,
+ temperature: float = 0.3,
+ timeout: int = 90,
+) -> str | None:
+ """
+ Llama a OpenRouter API con backoff exponencial global.
+
+ Cuando todos los modelos devuelven 429, espera 60s antes de reintentar
+ en vez de rotar rápidamente y agotar más cuota.
+ """
+ import urllib.request
+ import urllib.error
+ global _modelo_idx
+
+ n_modelos = len(MODELOS_FREE)
+
+ for ronda in range(n_modelos * 2): # hasta 2 vueltas completas
+ modelo_actual = MODELOS_FREE[_modelo_idx % n_modelos]
+
+ payload = json.dumps({
+ "model": modelo_actual,
+ "messages": [
+ {
+ "role": "system",
+ "content": (
+ "Eres un experto en Python. Respondes ÚNICAMENTE con código Python "
+ "limpio, funcional y bien documentado. Sin markdown, sin bloques ```python. "
+ "Solo código Python puro que se pueda ejecutar directamente."
+ ),
+ },
+ {"role": "user", "content": prompt},
+ ],
+ "max_tokens": max_tokens,
+ "temperature": temperature,
+ }).encode("utf-8")
+
+ req = urllib.request.Request(
+ "https://openrouter.ai/api/v1/chat/completions",
+ data=payload,
+ headers={
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://github.com/lucasmella-stack/PAMPAr-Coder",
+ "X-Title": "PAMPAr-Coder Distillation",
+ },
+ method="POST",
+ )
+
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+ if "error" in data and "choices" not in data:
+ print(f"\n [API error body] {str(data)[:150]}")
+ _modelo_idx += 1
+ time.sleep(3)
+ continue
+ texto = data["choices"][0]["message"]["content"]
+ # Tracking de costo
+ usage = data.get("usage", {})
+ _sumar_costo(modelo_actual, usage.get("prompt_tokens", 400), usage.get("completion_tokens", 600))
+ if texto:
+ return texto
+ # Respuesta vacía → rotar modelo
+ _modelo_idx += 1
+ continue
+
+ except urllib.error.HTTPError as e:
+ body = e.read().decode("utf-8", errors="ignore")
+ if e.code == 429:
+ _modelo_idx += 1
+ siguiente = MODELOS_FREE[_modelo_idx % n_modelos]
+ # Si completamos una vuelta completa, backoff global de 60s
+ if ronda > 0 and ronda % n_modelos == 0:
+ print(f"\n [rate limit global] todos los modelos saturados — esperando 60s...", end="", flush=True)
+ time.sleep(60)
+ else:
+ print(f"\n [429] → {siguiente.split('/')[1]} ", end="", flush=True)
+ time.sleep(5)
+ continue
+ print(f"\n [API error {e.code}] {body[:150]}")
+ return None
+ except Exception as e:
+ print(f"\n [error] {type(e).__name__}: {e}")
+ time.sleep(5)
+ return None
+
+ print(f"\n [!, agotados reintentos]")
+ return None
+
+
+# =============================================================================
+# Validación básica del código generado
+# =============================================================================
+
+def limpiar_codigo(texto: str) -> str:
+ """Elimina bloques markdown ```python ... ``` si el modelo los incluye."""
+ texto = texto.strip()
+ if texto.startswith("```"):
+ # Quitar primera línea (```python o ```)
+ lineas = texto.split("\n")
+ if lineas[0].startswith("```"):
+ lineas = lineas[1:]
+ # Quitar cierre ```
+ if lineas and lineas[-1].strip() == "```":
+ lineas = lineas[:-1]
+ texto = "\n".join(lineas).strip()
+ return texto
+
+
+def es_codigo_valido(texto: str) -> bool:
+ """Rechaza respuestas que no son código Python real."""
+ import ast
+ if len(texto.strip()) < 30:
+ return False
+ # Debe tener al menos una definición
+ tiene_def = any(kw in texto for kw in ("def ", "class ", "async def "))
+ if not tiene_def:
+ return False
+ # Intentar parsear (no falla si hay clases parciales, etc.)
+ try:
+ ast.parse(texto)
+ return True
+ except SyntaxError:
+ # Aceptar igual — el modelo puede generar fragmentos válidos con pequeños errores
+ return len(texto) > 100
+
+
+# =============================================================================
+# Main
+# =============================================================================
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Destilación Qwen→PamparV3 vía OpenRouter")
+ parser.add_argument("--api-key", default=os.getenv("OPENROUTER_API_KEY"), help="OpenRouter API key")
+ parser.add_argument("--modelo", default=MODELO_PRINCIPAL,
+ help="Modelo de OpenRouter a usar como profesor")
+ parser.add_argument("--n", type=int, default=5000, help="Ejemplos a generar")
+ parser.add_argument("--output", default="biblioteca/python_real/destilado_qwen.jsonl")
+ parser.add_argument("--temp", type=float, default=0.35)
+ parser.add_argument("--max-tokens", type=int, default=1400)
+ parser.add_argument("--delay", type=float, default=0.3,
+ help="Segundos entre llamadas (0.3s con créditos — hasta 200 rpm)")
+ parser.add_argument("--si", action="store_true", help="No pedir confirmación")
+ args = parser.parse_args()
+
+ if not args.api_key:
+ print("\n ❌ Necesitás una API key de OpenRouter (gratis en https://openrouter.ai/keys)")
+ print(" Pasala con --api-key sk-or-... o con:")
+ print(" $env:OPENROUTER_API_KEY = 'sk-or-...'")
+ sys.exit(1)
+
+ output = Path(args.output)
+ output.parent.mkdir(parents=True, exist_ok=True)
+
+ # Ejemplos ya existentes (para no regenerar si se interrumpe)
+ ya_generados = 0
+ if output.exists():
+ with open(output, encoding="utf-8") as f:
+ ya_generados = sum(1 for _ in f)
+
+ print(f"\n{'═'*65}")
+ print(f" DESTILACIÓN — {args.modelo.split('/')[-1]}")
+ print(f"{'═'*65}")
+ print(f" Output : {output}")
+ print(f" Objetivo : {args.n} ejemplos")
+ print(f" Ya existen : {ya_generados} ejemplos")
+ print(f" Por generar: {max(0, args.n - ya_generados)} ejemplos")
+ print(f" Delay : {args.delay}s (tier free)")
+ pin, pout = PRECIO_POR_MILLON.get(args.modelo, (0.2, 0.2))
+ costo_estimado = (args.n * 600 * pin + args.n * 900 * pout) / 1_000_000
+ print(f" Modelo : {args.modelo}")
+ print(f" Costo est. : ~${costo_estimado:.2f} de $10 disponibles")
+ print(f"{'═'*65}\n")
+
+ faltan = args.n - ya_generados
+ if faltan <= 0:
+ print(f" ✅ Ya tenés {ya_generados} ejemplos. Usá --n {ya_generados + 500} para más.")
+ return
+
+ if not args.si:
+ resp = input(f" ¿Generar {faltan} ejemplos? (s/n): ").strip().lower()
+ if resp not in ("s", "si", "sí", "y", "yes"):
+ print(" Cancelado.")
+ return
+
+ n_ok = 0
+ n_error = 0
+ n_invalido = 0
+ t_start = time.time()
+
+ # Ciclar por temas infinitamente hasta llegar al objetivo
+ temas_pool = TODOS_LOS_TEMAS * ((faltan // len(TODOS_LOS_TEMAS)) + 2)
+ random.shuffle(temas_pool)
+
+ with open(output, "a", encoding="utf-8") as f_out:
+ for i, (nivel, tema) in enumerate(temas_pool):
+ if n_ok >= faltan:
+ break
+
+ prompt = prompt_para_tema(nivel, tema)
+ elapsed = time.time() - t_start
+ eta = (elapsed / max(n_ok, 1)) * (faltan - n_ok) if n_ok > 0 else "?"
+
+ print(
+ f" [{ya_generados + n_ok + 1:04d}/{args.n}] "
+ f"nivel={nivel} {tema[:45]:<45} ",
+ end="", flush=True,
+ )
+
+ respuesta = llamar_api(
+ prompt,
+ args.api_key,
+ args.modelo,
+ max_tokens=args.max_tokens,
+ temperature=args.temp,
+ )
+
+ if respuesta is None:
+ n_error += 1
+ print(f"ERROR (total errores: {n_error})")
+ if n_error % 5 == 0:
+ print(f" [!] {n_error} errores — esperando 30s...")
+ time.sleep(30)
+ continue
+
+ respuesta = limpiar_codigo(respuesta)
+
+ if not es_codigo_valido(respuesta):
+ n_invalido += 1
+ print(f"INVÁLIDO ({len(respuesta)} chars)")
+ continue
+
+ # Guardar en formato compatible con LectorBiblioteca
+ entrada = json.dumps({
+ "instruction": prompt,
+ "output": respuesta,
+ "nivel": nivel,
+ "tema": tema,
+ "profesor": args.modelo,
+ }, ensure_ascii=False)
+ f_out.write(entrada + "\n")
+ f_out.flush()
+
+ n_ok += 1
+ tok_aprox = len(respuesta.split())
+ eta_str = f"{int(eta)}s" if isinstance(eta, float) else eta
+ print(f"OK ~{tok_aprox}tok ${_costo_total:.3f} ETA:{eta_str}")
+
+ time.sleep(args.delay)
+
+ # ── Resumen ──────────────────────────────────────────────────────────────
+ total_archivo = ya_generados + n_ok
+ elapsed = time.time() - t_start
+ print(f"\n{'═'*65}")
+ print(f" COMPLETADO en {elapsed/60:.1f} min")
+ print(f" Nuevos ejemplos : {n_ok}")
+ print(f" Total en archivo : {total_archivo}")
+ print(f" Errores API : {n_error}")
+ print(f" Inválidos : {n_invalido}")
+ print(f" Costo real : ${_costo_total:.4f}")
+ print(f" Output : {output}")
+ print(f"{'═'*65}")
+
+ # Actualizar indice.json automáticamente
+ _registrar_en_indice(output, total_archivo)
+
+ print(f"\n ✅ Listo. Para entrenar con estos datos:")
+ print(f" & 'C:\\Users\\lucas\\AppData\\Local\\Programs\\Python\\Python313\\python.exe'")
+ print(f" scripts/train_v3.py --checkpoint checkpoints/v3_train.pt --lr 2e-5")
+
+
+def _registrar_en_indice(output: Path, n_ejemplos: int) -> None:
+ """Añade o actualiza la entrada en biblioteca/indice.json."""
+ indice_path = Path("biblioteca/indice.json")
+ if not indice_path.exists():
+ return
+
+ with open(indice_path, encoding="utf-8") as f:
+ indice = json.load(f)
+
+ nombre = "destilado_qwen"
+ ruta_relativa = str(output.relative_to(Path("biblioteca"))).replace("\\", "/")
+
+ # Buscar si ya existe
+ existe = False
+ for entrada in indice:
+ if entrada.get("nombre") == nombre:
+ entrada["n_ejemplos"] = n_ejemplos
+ existe = True
+ break
+
+ if not existe:
+ indice.append({
+ "nombre": nombre,
+ "categoria": "destilacion",
+ "nivel": 3,
+ "archivo": ruta_relativa,
+ "n_ejemplos": n_ejemplos,
+ "fuente": "qwen2.5-coder-32b via openrouter",
+ })
+ print(f"\n 📝 Añadido '{nombre}' a biblioteca/indice.json")
+
+ with open(indice_path, "w", encoding="utf-8") as f:
+ json.dump(indice, f, ensure_ascii=False, indent=2)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/diagnose_routing.py b/scripts/diagnose_routing.py
new file mode 100644
index 0000000000000000000000000000000000000000..3a8f8f39c00310c4ef79c533483bd64202e574da
--- /dev/null
+++ b/scripts/diagnose_routing.py
@@ -0,0 +1,127 @@
+#!/usr/bin/env python3
+"""Diagnóstico detallado de errores de routing por token."""
+import torch
+import sys
+import sentencepiece as spm
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+
+from pampar.coder.v3.config import PRESET_V3
+from pampar.coder.v3.modelo import PamparV3
+from pampar.coder.v3.llaves import clasificar_token
+from pampar.coder.v3.zonas import ZONA_TERRITORIO, Territorio
+
+TERR_NAMES = ["SINT", "SEMA", "LOGI", "ESTR"]
+
+WEAK_SAMPLES = [
+ ("numeros", "pi = 3.14159"),
+ ("with", "with open('file.txt') as f:"),
+ ("excepcion", "try:\n result = 1 / 0\nexcept ZeroDivisionError:"),
+ ("comparacion", "x == y or x != z"),
+ ("lambda", "fn = lambda x: x * 2"),
+ ("imports", "from pathlib import Path"),
+ ("literals", "name = 'hello world'"),
+ ("decorador", "@staticmethod\ndef create():"),
+]
+
+
+def main() -> None:
+ tok = spm.SentencePieceProcessor()
+ tok.Load(str(ROOT / "data" / "tokenizer" / "pampar_48k.model"))
+
+ vocab_size = tok.GetPieceSize()
+ territory_table = torch.zeros(vocab_size, dtype=torch.long)
+ for tid in range(vocab_size):
+ piece = tok.IdToPiece(tid)
+ z, _c = clasificar_token(piece)
+ territory_table[tid] = ZONA_TERRITORIO[z].value
+
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ model = PamparV3(PRESET_V3).to(device)
+ ckpt = torch.load(
+ str(ROOT / "checkpoints" / "v3_ghidra_v4.pt"),
+ map_location=device,
+ weights_only=False,
+ )
+ model.load_state_dict(ckpt["modelo"])
+ model.registrar_tokenizer(tok)
+ model.eval()
+ del ckpt
+
+ # Import forward_instrumentado from brain_scanner
+ from brain_scanner import forward_instrumentado
+
+ all_errors: list[dict] = []
+
+ for label, code in WEAK_SAMPLES:
+ tids = tok.Encode(code, out_type=int)
+ pieces = [tok.IdToPiece(t) for t in tids]
+ inp = torch.tensor([tids], dtype=torch.long, device=device)
+
+ with torch.no_grad():
+ info = forward_instrumentado(model, inp)
+
+ terr_last = info["terr_por_nivel"][-1]
+
+ print(f"\n{'='*60}")
+ print(f" {label}: {repr(code[:50])} ({len(tids)} tokens)")
+ print(f"{'='*60}")
+
+ for i, (tid, piece) in enumerate(zip(tids, pieces)):
+ expected = territory_table[tid].item()
+ actual = terr_last[i].argmax().item()
+ zona, conf = clasificar_token(piece)
+ acts = terr_last[i].tolist()
+ mark = "✓" if actual == expected else "✗"
+
+ if actual != expected:
+ all_errors.append({
+ "sample": label,
+ "token": piece,
+ "zona": zona.name,
+ "conf": conf,
+ "expected": TERR_NAMES[expected],
+ "actual": TERR_NAMES[actual],
+ "acts": acts,
+ })
+
+ acts_str = " ".join(
+ f"{TERR_NAMES[j]}={a:.3f}" for j, a in enumerate(acts)
+ )
+ print(
+ f" {mark} {piece:20s} exp={TERR_NAMES[expected]:4s} "
+ f"act={TERR_NAMES[actual]:4s} zona={zona.name:20s} "
+ f"conf={conf:.0%} [{acts_str}]"
+ )
+
+ print(f"\n{'='*60}")
+ print(f" RESUMEN DE ERRORES: {len(all_errors)} tokens mal routeados")
+ print(f"{'='*60}")
+
+ # Group errors by pattern
+ from collections import Counter
+ patterns = Counter()
+ for e in all_errors:
+ key = f"{e['expected']}->{e['actual']}"
+ patterns[key] += 1
+
+ print("\n Patrones de error:")
+ for pattern, count in patterns.most_common():
+ print(f" {pattern}: {count} tokens")
+
+ print("\n Detalle de cada error:")
+ for e in all_errors:
+ margin = sorted(e["acts"], reverse=True)
+ m = margin[0] - margin[1]
+ print(
+ f" [{e['sample']:12s}] {e['token']:20s} "
+ f"{e['expected']:4s}->{e['actual']:4s} "
+ f"zona={e['zona']:20s} conf={e['conf']:.0%} "
+ f"margin={m:.4f}"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/eval_agents.py b/scripts/eval_agents.py
new file mode 100644
index 0000000000000000000000000000000000000000..f6c6cea36150acda63bf8ae18473631f8ffdcdec
--- /dev/null
+++ b/scripts/eval_agents.py
@@ -0,0 +1,408 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+eval_agents.py — Evaluación de generación de AGENTS.md (Milestone 3).
+
+Dado un scan sintético, el modelo debe generar un AGENTS.md válido.
+Se evalúan 12 casos con entornos variados.
+
+Métricas:
+ - Secciones presentes: Quick Reference, Sistema detectado, Paquetes, Boot protocol
+ - Info del scan reflejada: OS, Python version, GPU (si existe)
+ - Formato válido: líneas con '|' para tablas, '```' para bloques, '##' headers
+ - Sin hallucination grave: no inventa servicios que no existían
+
+Uso:
+ python -X utf8 scripts/eval_agents.py --checkpoint checkpoints/v3_sft_v8.pt
+"""
+
+import argparse
+import re
+import sys
+import time
+from dataclasses import dataclass
+from pathlib import Path
+
+import sentencepiece as spm
+import torch
+import torch.nn.functional as F
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+TOKENIZER_PATHS = [
+ "data/tokenizer/pampar_48k.model",
+ "data/tokenizer/code_tokenizer.model",
+]
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Casos de evaluación (entornos sintéticos nunca vistos en entrenamiento)
+# ─────────────────────────────────────────────────────────────────────────────
+
+CASOS_EVAL = [
+ {
+ "nombre": "ML PyTorch GPU",
+ "scan": (
+ "El Scanner detectó el siguiente entorno. "
+ "Genera el archivo AGENTS.md contextual para este despliegue.\n\n"
+ "## Entorno detectado\n\n"
+ "- **OS**: Linux Ubuntu 22.04\n"
+ "- **Python**: 3.11.9\n"
+ "- **GPU**: NVIDIA GeForce RTX 4090 (24576 MB)\n"
+ "- **RAM**: 64.0 GB\n"
+ "- **Archivos**: Python: 52, JSON: 14, Markdown: 9\n"
+ "- **Paquetes** (6 relevantes): torch==2.5.1, transformers==4.47.1, "
+ "peft==0.13.2, datasets==3.1.0, trl==0.12.2, bitsandbytes==0.45.0\n"
+ "- **Servicios inactivos**: PostgreSQL, Redis\n"
+ "- **Voz**: espeak\n\n"
+ "**Proyecto**: llm-finetune — pipeline de fine-tuning con LoRA/QLoRA"
+ ),
+ "espera": {
+ "secciones": ["Quick Reference", "Sistema", "Paquetes", "Boot"],
+ "info_scan": ["Linux", "3.11.9", "RTX 4090", "torch", "peft"],
+ "servicios_correctos": False, # PostgreSQL/Redis estaban inactivos
+ },
+ },
+ {
+ "nombre": "FastAPI + PostgreSQL",
+ "scan": (
+ "El Scanner detectó el siguiente entorno. "
+ "Genera el archivo AGENTS.md contextual para este despliegue.\n\n"
+ "## Entorno detectado\n\n"
+ "- **OS**: macOS 14.0 Sonoma\n"
+ "- **Python**: 3.12.4\n"
+ "- **GPU**: no disponible (solo CPU)\n"
+ "- **RAM**: 32.0 GB\n"
+ "- **Archivos**: Python: 40, TypeScript: 8, JSON: 12, Markdown: 6\n"
+ "- **Paquetes** (6 relevantes): fastapi==0.115.0, uvicorn==0.32.0, "
+ "pydantic==2.10.0, sqlalchemy==2.0.36, alembic==1.14.0, httpx==0.28.0\n"
+ "- **Servicios activos**: PostgreSQL, HTTP-8000\n"
+ "- **Voz**: say\n\n"
+ "**Proyecto**: api-service — API REST con FastAPI y PostgreSQL"
+ ),
+ "espera": {
+ "secciones": ["Quick Reference", "Sistema", "Paquetes", "Boot"],
+ "info_scan": ["macOS", "3.12.4", "fastapi", "PostgreSQL"],
+ "servicios_correctos": True,
+ },
+ },
+ {
+ "nombre": "CLI / Script simple",
+ "scan": (
+ "El Scanner detectó el siguiente entorno. "
+ "Genera el archivo AGENTS.md contextual para este despliegue.\n\n"
+ "## Entorno detectado\n\n"
+ "- **OS**: Windows 11.0.22621\n"
+ "- **Python**: 3.13.3\n"
+ "- **GPU**: no disponible (solo CPU)\n"
+ "- **RAM**: 8.0 GB\n"
+ "- **Archivos**: Python: 12, Markdown: 3\n"
+ "- **Paquetes** (4 relevantes): click==8.1.8, rich==13.9.4, "
+ "httpx==0.28.0, python-dotenv==1.0.1\n"
+ "- **Voz**: SAPI\n\n"
+ "**Proyecto**: data-fetcher — herramienta CLI para descarga de datos"
+ ),
+ "espera": {
+ "secciones": ["Quick Reference", "Sistema", "Paquetes", "Boot"],
+ "info_scan": ["Windows", "11", "click", "rich"],
+ "servicios_correctos": True,
+ },
+ },
+ {
+ "nombre": "Django + Celery",
+ "scan": (
+ "El Scanner detectó el siguiente entorno. "
+ "Genera el archivo AGENTS.md contextual para este despliegue.\n\n"
+ "## Entorno detectado\n\n"
+ "- **OS**: Linux Debian 12\n"
+ "- **Python**: 3.11.9\n"
+ "- **GPU**: no disponible (solo CPU)\n"
+ "- **RAM**: 16.0 GB\n"
+ "- **Archivos**: Python: 58, HTML: 22, CSS: 12, JavaScript: 9, Markdown: 5\n"
+ "- **Paquetes** (5 relevantes): django==5.1.4, djangorestframework==3.15.2, "
+ "celery==5.4.0, redis==5.2.1, pillow==11.0.0\n"
+ "- **Servicios activos**: PostgreSQL, Redis, HTTP-8000\n"
+ "- **Voz**: espeak\n\n"
+ "**Proyecto**: plataforma-web — aplicación Django con tareas asíncronas"
+ ),
+ "espera": {
+ "secciones": ["Quick Reference", "Sistema", "Paquetes", "Boot"],
+ "info_scan": ["Linux", "django", "celery", "Redis"],
+ "servicios_correctos": True,
+ },
+ },
+ {
+ "nombre": "Data Science sin GPU",
+ "scan": (
+ "El Scanner detectó el siguiente entorno. "
+ "Genera el archivo AGENTS.md contextual para este despliegue.\n\n"
+ "## Entorno detectado\n\n"
+ "- **OS**: macOS 14.0 Sonoma\n"
+ "- **Python**: 3.10.14\n"
+ "- **GPU**: no disponible (solo CPU)\n"
+ "- **RAM**: 16.0 GB\n"
+ "- **Archivos**: Python: 20, JSON: 18, Markdown: 10\n"
+ "- **Paquetes** (6 relevantes): pandas==2.2.3, numpy==2.1.3, "
+ "matplotlib==3.9.3, scikit-learn==1.5.2, jupyter==1.1.1, plotly==5.24.1\n"
+ "- **Servicios activos**: PostgreSQL\n"
+ "- **Voz**: say\n\n"
+ "**Proyecto**: market-analysis — análisis de datos financieros"
+ ),
+ "espera": {
+ "secciones": ["Quick Reference", "Sistema", "Paquetes", "Boot"],
+ "info_scan": ["macOS", "pandas", "scikit-learn", "PostgreSQL"],
+ "servicios_correctos": True,
+ },
+ },
+ {
+ "nombre": "GTX 1650 Local",
+ "scan": (
+ "El Scanner detectó el siguiente entorno. "
+ "Genera el archivo AGENTS.md contextual para este despliegue.\n\n"
+ "## Entorno detectado\n\n"
+ "- **OS**: Windows 10.0.26200\n"
+ "- **Python**: 3.13.3\n"
+ "- **GPU**: NVIDIA GeForce GTX 1650 (4095 MB)\n"
+ "- **RAM**: 31.9 GB\n"
+ "- **Archivos**: Python: 65, Markdown: 10, JSON: 8\n"
+ "- **Paquetes** (6 relevantes): torch==2.5.1, sentencepiece==0.2.0, "
+ "transformers==4.47.1, accelerate==1.12.0, pytest==9.0.1, peft==0.13.2\n"
+ "- **Servicios inactivos**: PostgreSQL, Redis\n"
+ "- **Voz**: SAPI\n\n"
+ "**Proyecto**: pampar-coder — modelo de lenguaje 108M entrenado localmente"
+ ),
+ "espera": {
+ "secciones": ["Quick Reference", "Sistema", "Paquetes", "Boot"],
+ "info_scan": ["Windows", "GTX 1650", "torch", "sentencepiece"],
+ "servicios_correctos": False,
+ },
+ },
+]
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Carga de modelo (ídem eval_v3.py)
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _cargar_modelo(checkpoint: Path, device: torch.device):
+ import dataclasses
+ from pampar.coder.v3.config import PRESET_V3, ConfigV3
+ from pampar.coder.v3.modelo import PamparV3
+
+ ckpt = torch.load(checkpoint, map_location="cpu", weights_only=False)
+ raw_cfg = ckpt.get("config", {})
+ state = ckpt.get("modelo", ckpt)
+ if isinstance(raw_cfg, dict) and "dim" in raw_cfg:
+ valid = {f.name for f in dataclasses.fields(ConfigV3)}
+ cfg = ConfigV3(**{k: v for k, v in raw_cfg.items() if k in valid})
+ else:
+ cfg = PRESET_V3
+ modelo = PamparV3(cfg)
+ modelo.load_state_dict(state, strict=False)
+ return modelo.to(device).eval(), cfg
+
+
+def _cargar_tok(vocab_size: int) -> spm.SentencePieceProcessor:
+ sp = spm.SentencePieceProcessor()
+ for p in TOKENIZER_PATHS:
+ pp = Path(p)
+ if pp.exists():
+ sp.Load(str(pp))
+ if sp.vocab_size() == vocab_size:
+ return sp
+ raise FileNotFoundError(f"Tokenizer {vocab_size} no encontrado")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Generación (igual que eval_v3.py pero con max_tokens más alto)
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _generar(modelo, tok, prompt: str, device, max_tokens=800, temp=0.1, rep_pen=1.15) -> str:
+ ids = tok.Encode(prompt)
+ gen = list(ids)
+ rep_window = 32
+
+ for _ in range(max_tokens):
+ ctx = torch.tensor([gen[-512:]], dtype=torch.long, device=device)
+ logits, _, _ = modelo(ctx)
+ nxt = logits[0, -1]
+
+ if rep_pen != 1.0 and len(gen) > len(ids):
+ ws = max(len(ids), len(gen) - rep_window)
+ seen = set(gen[ws:])
+ for t in seen:
+ nxt[t] = nxt[t] / rep_pen if nxt[t] > 0 else nxt[t] * rep_pen
+
+ if temp <= 0.0:
+ next_tok = int(nxt.argmax())
+ else:
+ probs = F.softmax(nxt / temp, dim=-1)
+ next_tok = int(torch.multinomial(probs, 1))
+
+ gen.append(next_tok)
+ decoded = tok.Decode(gen[len(ids):]).replace("\u2047", "\n")
+
+ # Detener en nueva sección de instrucción
+ if decoded.count("### Scan:") > 0:
+ idx = decoded.index("### Scan:")
+ if idx > 50:
+ return decoded[:idx].rstrip()
+
+ # Detener si terminó el documento (línea de separador final)
+ if decoded.rstrip().endswith("```") and len(decoded) > 200:
+ return decoded.rstrip()
+
+ return tok.Decode(gen[len(ids):]).replace("\u2047", "\n")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Verificación
+# ─────────────────────────────────────────────────────────────────────────────
+
+@dataclass
+class ResultadoCaso:
+ nombre: str
+ score: float # 0.0 – 1.0
+ ok_secciones: bool
+ ok_info: bool
+ ok_formato: bool
+ n_secciones: int
+ n_info: int
+ texto: str
+
+
+def _verificar(texto: str, espera: dict) -> tuple[bool, bool, bool, int, int]:
+ """Verifica que el texto generado cumpla con las expectativas."""
+ texto_lower = texto.lower()
+
+ # 1. Secciones mínimas
+ secciones_esperadas = espera["secciones"]
+ n_encontradas = sum(1 for s in secciones_esperadas if s.lower() in texto_lower)
+ ok_secciones = n_encontradas >= len(secciones_esperadas) * 0.75 # 75%
+
+ # 2. Info del scan reflejada
+ info = espera["info_scan"]
+ n_info = sum(1 for i in info if i.lower() in texto_lower)
+ ok_info = n_info >= len(info) * 0.6 # 60%
+
+ # 3. Formato válido (tiene headers ## y alguna tabla o bloque)
+ tiene_header = bool(re.search(r'^##\s+\w', texto, re.MULTILINE))
+ tiene_tabla_o_bloque = '|' in texto or '```' in texto
+ ok_formato = tiene_header and tiene_tabla_o_bloque
+
+ return ok_secciones, ok_info, ok_formato, n_encontradas, n_info
+
+
+def _score(ok_s: bool, ok_i: bool, ok_f: bool, n_s: int, n_i: int,
+ max_s: int, max_i: int) -> float:
+ """Score ponderado: secciones 40%, info 40%, formato 20%."""
+ s = (n_s / max_s) * 0.4 + (n_i / max_i) * 0.4 + (0.2 if ok_f else 0.0)
+ return round(min(s, 1.0), 3)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Main
+# ─────────────────────────────────────────────────────────────────────────────
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--checkpoint", default="checkpoints/v3_sft_v8.pt")
+ parser.add_argument("--device", default="auto")
+ parser.add_argument("--temp", type=float, default=0.1)
+ parser.add_argument("--rep-penalty", type=float, default=1.15)
+ parser.add_argument("--max-tokens", type=int, default=800)
+ parser.add_argument("--verbose", action="store_true")
+ args = parser.parse_args()
+
+ device = torch.device(
+ "cuda" if args.device == "auto" and torch.cuda.is_available() else
+ args.device if args.device != "auto" else "cpu"
+ )
+
+ sep = "═" * 70
+ print(f"\n{sep}")
+ print(f" EVAL MILESTONE 3 — Generación de AGENTS.md")
+ print(f" Checkpoint : {args.checkpoint}")
+ print(f" Device : {device} | Temp: {args.temp} | RepPen: {args.rep_penalty}")
+ print(f" Casos : {len(CASOS_EVAL)}")
+ print(f"{sep}\n")
+
+ t0_total = time.time()
+ modelo, cfg = _cargar_modelo(Path(args.checkpoint), device)
+ tok = _cargar_tok(cfg.vocab_size)
+ modelo.registrar_tokenizer(tok)
+ print(f" Modelo cargado ({sum(p.numel() for p in modelo.parameters())/1e6:.1f}M params)\n")
+
+ resultados: list[ResultadoCaso] = []
+
+ for i, caso in enumerate(CASOS_EVAL, 1):
+ prompt = f"### Scan:\n{caso['scan']}\n### Protocolo:\n"
+ print(f" [{i:02d}/{len(CASOS_EVAL)}] {caso['nombre']}", end=" ", flush=True)
+ t0 = time.time()
+
+ texto = _generar(modelo, tok, prompt, device,
+ max_tokens=args.max_tokens, temp=args.temp, rep_pen=args.rep_penalty)
+
+ dt = time.time() - t0
+ espera = caso["espera"]
+ ok_s, ok_i, ok_f, n_s, n_i = _verificar(texto, espera)
+ sc = _score(ok_s, ok_i, ok_f, n_s, n_i,
+ len(espera["secciones"]), len(espera["info_scan"]))
+
+ emoji = "✅" if sc >= 0.6 else "⚠️ " if sc >= 0.4 else "❌"
+ print(f"[{dt:.1f}s] {emoji} score={sc:.2f} "
+ f"secciones={n_s}/{len(espera['secciones'])} "
+ f"info={n_i}/{len(espera['info_scan'])} "
+ f"formato={'✓' if ok_f else '✗'}")
+
+ if args.verbose:
+ print(f"\n --- Output (primeros 400 chars) ---")
+ for line in texto[:400].splitlines():
+ print(f" {line}")
+ print(" ...\n")
+
+ resultados.append(ResultadoCaso(
+ nombre=caso["nombre"],
+ score=sc,
+ ok_secciones=ok_s,
+ ok_info=ok_i,
+ ok_formato=ok_f,
+ n_secciones=n_s,
+ n_info=n_i,
+ texto=texto,
+ ))
+
+ # Resultado final
+ total = time.time() - t0_total
+ scores = [r.score for r in resultados]
+ aprobados = sum(1 for s in scores if s >= 0.6)
+ promedio = sum(scores) / len(scores)
+
+ print(f"\n{sep}")
+ print(f" RESULTADO MILESTONE 3 — {total:.0f}s total")
+ print(f"{sep}")
+ print(f" ✅ Aprobados : {aprobados}/{len(resultados)} (umbral score ≥ 0.60)")
+ print(f" 📊 Score prom : {promedio:.3f}")
+ print(f" 📊 Score mín : {min(scores):.3f}")
+ print(f" 📊 Score máx : {max(scores):.3f}")
+
+ # Detalles por criterio
+ ok_s_total = sum(1 for r in resultados if r.ok_secciones)
+ ok_i_total = sum(1 for r in resultados if r.ok_info)
+ ok_f_total = sum(1 for r in resultados if r.ok_formato)
+ print(f"\n Por criterio:")
+ print(f" Secciones correctas : {ok_s_total}/{len(resultados)}")
+ print(f" Info del scan : {ok_i_total}/{len(resultados)}")
+ print(f" Formato válido : {ok_f_total}/{len(resultados)}")
+
+ if promedio >= 0.7:
+ print(f"\n 🎯 MILESTONE 3 SUPERADO — el modelo genera AGENTS.md válidos")
+ elif promedio >= 0.5:
+ print(f"\n 📈 PROGRESO — necesita más SFT sobre agents_sft.jsonl")
+ else:
+ print(f"\n 🔧 NECESITA SFT — score por debajo del umbral de progreso")
+ print(f"{sep}\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/eval_v3.py b/scripts/eval_v3.py
new file mode 100644
index 0000000000000000000000000000000000000000..c15e6455ac001e8db5e984feb1ab75ee395f29f5
--- /dev/null
+++ b/scripts/eval_v3.py
@@ -0,0 +1,804 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+"""
+eval_v3.py — Evaluación de generalización real de PamparV3.
+
+Ejecuta prompts nunca vistos, genera código y lo ejecuta con asserts reales.
+
+Uso:
+ python -X utf8 scripts/eval_v3.py
+ python -X utf8 scripts/eval_v3.py --checkpoint checkpoints/v3_train.pt --temp 0.4
+ python -X utf8 scripts/eval_v3.py --verbose
+"""
+
+import argparse
+import ast
+import re
+import sys
+import time
+from pathlib import Path
+
+import torch
+import torch.nn.functional as F
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+# =============================================================================
+# CASOS DE PRUEBA — nunca vistos por el modelo
+# =============================================================================
+
+CASOS = [
+ # ── Nivel 1: básicos ────────────────────────────────────────────────────
+ {
+ "nivel": 1,
+ "desc": "Contar vocales",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `contar_vocales(texto)` that returns the number "
+ "of vowels (a, e, i, o, u, case-insensitive) in the string.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ ns["contar_vocales"]("hola mundo") == 4
+ and ns["contar_vocales"]("") == 0
+ and ns["contar_vocales"]("xyz") == 0
+ ),
+ },
+ {
+ "nivel": 1,
+ "desc": "Sumar dígitos",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `suma_digitos(n)` that returns the sum of all "
+ "digits of the non-negative integer n.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ ns["suma_digitos"](123) == 6
+ and ns["suma_digitos"](0) == 0
+ and ns["suma_digitos"](999) == 27
+ ),
+ },
+ {
+ "nivel": 1,
+ "desc": "Palíndromo",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `es_palindromo(s)` that returns True if the "
+ "string is a palindrome, False otherwise.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ ns["es_palindromo"]("racecar") is True
+ and ns["es_palindromo"]("hello") is False
+ and ns["es_palindromo"]("a") is True
+ ),
+ },
+ {
+ "nivel": 1,
+ "desc": "Máximo de lista",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `maximo(lista)` that returns the maximum element "
+ "of a non-empty list without using the built-in max().\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ ns["maximo"]([3, 1, 4, 1, 5, 9]) == 9
+ and ns["maximo"]([0]) == 0
+ and ns["maximo"]([-1, -5, -2]) == -1
+ ),
+ },
+ {
+ "nivel": 1,
+ "desc": "FizzBuzz single",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `fizzbuzz(n)` that returns 'FizzBuzz' if n is "
+ "divisible by both 3 and 5, 'Fizz' if divisible by 3, 'Buzz' if divisible "
+ "by 5, or the string representation of n otherwise.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ ns["fizzbuzz"](15) == "FizzBuzz"
+ and ns["fizzbuzz"](3) == "Fizz"
+ and ns["fizzbuzz"](5) == "Buzz"
+ and ns["fizzbuzz"](7) == "7"
+ ),
+ },
+ # ── Nivel 2: listas/dicts ───────────────────────────────────────────────
+ {
+ "nivel": 2,
+ "desc": "Aplanar lista un nivel",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `aplanar(lista)` that flattens a list of lists "
+ "by one level and returns the result as a single list.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ ns["aplanar"]([[1, 2], [3, 4], [5]]) == [1, 2, 3, 4, 5]
+ and ns["aplanar"]([]) == []
+ ),
+ },
+ {
+ "nivel": 2,
+ "desc": "Frecuencia de elementos",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `frecuencia(lista)` that returns a dictionary "
+ "mapping each element to its count in the list.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ ns["frecuencia"]([1, 2, 2, 3, 3, 3]) == {1: 1, 2: 2, 3: 3}
+ and ns["frecuencia"]([]) == {}
+ ),
+ },
+ {
+ "nivel": 2,
+ "desc": "Lista de cuadrados pares",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `cuadrados_pares(n)` that returns a list of "
+ "squares of all even numbers from 2 to n inclusive.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ ns["cuadrados_pares"](6) == [4, 16, 36] and ns["cuadrados_pares"](1) == []
+ ),
+ },
+ {
+ "nivel": 2,
+ "desc": "Invertir diccionario",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `invertir_dict(d)` that returns a new dictionary "
+ "with keys and values swapped.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ ns["invertir_dict"]({"a": 1, "b": 2}) == {1: "a", 2: "b"}
+ and ns["invertir_dict"]({}) == {}
+ ),
+ },
+ # ── Nivel 3: algoritmos ─────────────────────────────────────────────────
+ {
+ "nivel": 3,
+ "desc": "Fibonacci iterativo",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `fibonacci(n)` that returns the n-th Fibonacci "
+ "number (0-indexed: fibonacci(0)=0, fibonacci(1)=1, fibonacci(7)=13).\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ ns["fibonacci"](0) == 0
+ and ns["fibonacci"](1) == 1
+ and ns["fibonacci"](7) == 13
+ and ns["fibonacci"](10) == 55
+ ),
+ },
+ {
+ "nivel": 3,
+ "desc": "Busqueda binaria",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `busqueda_binaria(lista, objetivo)` that returns "
+ "the index of the target in a sorted list, or -1 if not found.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ ns["busqueda_binaria"]([1, 3, 5, 7, 9], 5) == 2
+ and ns["busqueda_binaria"]([1, 3, 5, 7, 9], 4) == -1
+ and ns["busqueda_binaria"]([], 1) == -1
+ ),
+ },
+ {
+ "nivel": 3,
+ "desc": "Merge sort",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `merge_sort(lista)` that returns a new sorted "
+ "list using the merge sort algorithm.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ ns["merge_sort"]([3, 1, 4, 1, 5, 9, 2, 6]) == [1, 1, 2, 3, 4, 5, 6, 9]
+ and ns["merge_sort"]([]) == []
+ and ns["merge_sort"]([1]) == [1]
+ ),
+ },
+ # ── Nivel 4: clases/OOP ─────────────────────────────────────────────────
+ {
+ "nivel": 4,
+ "desc": "Clase Stack básica",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python class `Stack` with methods `push(item)` and `pop()` "
+ "implementing a LIFO stack.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ (s := ns["Stack"]()) is not None
+ and (s.push(1) or True)
+ and (s.push(2) or True)
+ and s.pop() == 2
+ and s.pop() == 1
+ ),
+ },
+ {
+ "nivel": 4,
+ "desc": "Clase Punto con distancia",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python class `Punto` with attributes `x` and `y`, and a method "
+ "`distancia(otro)` that returns the Euclidean distance to another Punto.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: ns["Punto"](0, 0).distancia(ns["Punto"](3, 4)) == 5.0,
+ },
+ # ── Nivel 5: funcional/avanzado ─────────────────────────────────────────
+ {
+ "nivel": 5,
+ "desc": "Memoización con decorador",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python higher-order function `memoize(fn)` that returns a wrapped "
+ "version of fn that caches results by argument.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ (fn := ns["memoize"](lambda x: x * 2)) is not None
+ and fn(5) == 10
+ and fn(5) == 10 # cache hit
+ ),
+ },
+ {
+ "nivel": 5,
+ "desc": "Generador de números primos",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python generator function `primos_hasta(n)` that yields all "
+ "prime numbers up to and including n.\n"
+ "### Solution:\n"
+ ),
+ "verificar": lambda ns: (
+ list(ns["primos_hasta"](20)) == [2, 3, 5, 7, 11, 13, 17, 19]
+ ),
+ },
+]
+
+
+# =============================================================================
+# Carga del modelo v3 (delegada a pampar.inference)
+# =============================================================================
+
+from pampar.inference import load_model
+
+# =============================================================================
+# Extracción de firma para modo guiado
+# =============================================================================
+
+
+def extraer_firma(prompt: str) -> str:
+ """Extract function/class signature from prompt for guided generation."""
+ m = re.search(r"class `(\w+)`", prompt)
+ if m:
+ return f"class {m.group(1)}:"
+ m = re.search(r"function `(\w+\([^)]*\))`", prompt)
+ if m:
+ return f"def {m.group(1)}:"
+ return ""
+
+
+# =============================================================================
+# Generación greedy / top-p
+# =============================================================================
+
+
+@torch.no_grad()
+def generar(
+ modelo,
+ tokenizer,
+ prompt: str,
+ device,
+ max_tokens: int = 384,
+ temperature: float = 0.1,
+ repetition_penalty: float = 1.2,
+ rep_window: int = 32,
+) -> str:
+ ids = tokenizer.Encode(prompt)
+ generados = list(ids)
+
+ for _ in range(max_tokens):
+ ctx = torch.tensor([generados[-512:]], dtype=torch.long, device=device)
+ logits, _, _ = modelo(ctx)
+ next_logits = logits[0, -1]
+
+ # Penalizar solo tokens en una ventana reciente (no destruir nombres)
+ if repetition_penalty != 1.0 and len(generados) > len(ids):
+ window_start = max(len(ids), len(generados) - rep_window)
+ seen = set(generados[window_start:])
+ for token_id in seen:
+ if next_logits[token_id] > 0:
+ next_logits[token_id] /= repetition_penalty
+ else:
+ next_logits[token_id] *= repetition_penalty
+
+ if temperature <= 0.0:
+ next_token = int(next_logits.argmax())
+ else:
+ next_logits = next_logits / temperature
+ probs = F.softmax(next_logits, dim=-1)
+ next_token = int(torch.multinomial(probs, 1))
+
+ generados.append(next_token)
+ decoded = tokenizer.Decode(generados[len(ids) :]).replace("\u2047", "\n")
+
+ # Stop: repetition detector — same line appearing 3+ times
+ dec_lines = decoded.split("\n")
+ if len(dec_lines) > 6:
+ last_line = dec_lines[-1].strip()
+ if last_line and sum(1 for l in dec_lines if l.strip() == last_line) >= 3:
+ # Trim to content before the repeated lines
+ clean = []
+ for l in dec_lines:
+ if l.strip() == last_line and len(clean) > 2:
+ break
+ clean.append(l)
+ return prompt + "\n".join(clean).rstrip()
+
+ # Parar si el modelo empieza una nueva sección (formato instrucción)
+ if "###" in decoded:
+ idx = decoded.index("###")
+ if idx > 10:
+ return prompt + decoded[:idx].rstrip()
+
+ # Parar cuando termina la función/clase (línea sin sangría después de contenido)
+ if len(dec_lines) > 3:
+ for i, line in enumerate(dec_lines[2:], 2):
+ if line and not line[0].isspace() and line.strip() not in ("", "pass"):
+ partial = "\n".join(dec_lines[:i])
+ return prompt + partial
+
+ if decoded.endswith("\n\n") and len(decoded) > 20:
+ break
+
+ return tokenizer.Decode(generados).replace("\u2047", "\n")
+
+
+# =============================================================================
+# Normalización de indentación
+# =============================================================================
+
+
+def _normalizar_indentacion(codigo: str) -> str:
+ """Corregir indentación inconsistente (ej. 5 espacios → 4) redondeando a múltiplos de 4."""
+ lines = codigo.split("\n")
+ if not lines:
+ return codigo
+
+ fixed = [lines[0]] # Primera línea (def/class) se mantiene
+ for line in lines[1:]:
+ stripped = line.lstrip()
+ if not stripped:
+ fixed.append("")
+ continue
+ spaces = len(line) - len(stripped)
+ # Redondear a múltiplo de 4 más cercano, mínimo 4 si dentro de función/clase
+ normalized = round(spaces / 4) * 4
+ if normalized < 4 and lines[0].lstrip().startswith(("def ", "class ")):
+ normalized = 4
+ fixed.append(" " * normalized + stripped)
+
+ return "\n".join(fixed)
+
+
+def _reparar_bloques_huerfanos(codigo: str) -> str:
+ """
+ Repara el error 'expected an indented block after X statement'.
+
+ Cuando el modelo genera:
+ if condicion:
+ cuerpo_sin_indentar ← same indent as 'if' → SyntaxError
+
+ Lo convierte en:
+ if condicion:
+ cuerpo_sin_indentar ← indent + 4
+
+ Itera hasta que no detecte más bloques huérfanos (max 10 pasadas).
+ """
+ HEADERS = (
+ "if ",
+ "elif ",
+ "else:",
+ "for ",
+ "while ",
+ "try:",
+ "except",
+ "finally:",
+ "with ",
+ "def ",
+ "class ",
+ )
+
+ for _ in range(10):
+ lines = codigo.splitlines()
+ changed = False
+ i = 0
+ new_lines: list[str] = []
+
+ while i < len(lines):
+ line = lines[i]
+ ls = line.lstrip()
+ li = len(line) - len(ls)
+
+ is_header = line.rstrip().endswith(":") and any(
+ ls.startswith(h) for h in HEADERS
+ )
+
+ if is_header and i + 1 < len(lines):
+ nxt = lines[i + 1]
+ ns = nxt.lstrip()
+ ni = len(nxt) - len(ns)
+
+ # Next non-blank line must be MORE indented to form a valid block
+ if ns and ni <= li:
+ expected = li + 4
+ new_lines.append(line)
+ i += 1
+ # Re-indent all contiguous lines at the "wrong" indent level
+ while i < len(lines):
+ curr = lines[i]
+ cs = curr.lstrip()
+ ci = len(curr) - len(cs)
+
+ if not cs: # blank line — include but don't fix
+ new_lines.append(curr)
+ i += 1
+ continue
+
+ if ci < li: # exited back to parent scope → stop
+ break
+
+ if ci == ni: # still at the wrong indent level → fix
+ new_lines.append(" " * expected + cs)
+ i += 1
+ else:
+ break # different indent → let next iteration handle
+
+ changed = True
+ continue # re-process from current i
+
+ new_lines.append(line)
+ i += 1
+
+ codigo = "\n".join(new_lines)
+ if not changed:
+ break
+
+ return codigo
+
+
+def _extraer_primer_bloque(codigo: str) -> str:
+ """Extraer solo la primera función/clase completa, descartando definiciones duplicadas."""
+ lines = codigo.split("\n")
+ if not lines:
+ return codigo
+
+ result: list[str] = []
+ found_def = False
+
+ for line in lines:
+ stripped = line.lstrip()
+ # Si ya encontramos una definición y aparece otra del mismo tipo → parar
+ if found_def and (stripped.startswith("def ") or stripped.startswith("class ")):
+ indent = len(line) - len(stripped)
+ if indent == 0:
+ break
+ if stripped.startswith("def ") or stripped.startswith("class "):
+ found_def = True
+ result.append(line)
+
+ return "\n".join(result).rstrip()
+
+
+# =============================================================================
+# Ejecución segura
+# =============================================================================
+
+
+def _extraer_bloques_codigo(texto: str) -> list[str]:
+ """Extraer todos los bloques ```python...``` del texto, más el texto crudo como fallback."""
+ import textwrap
+
+ bloques: list[str] = []
+
+ # Extraer todos los bloques ```python ... ```
+ partes = texto.split("```python")
+ for parte in partes[1:]: # Skip antes del primer ```python
+ if "```" in parte:
+ bloque = parte.split("```", 1)[0]
+ else:
+ bloque = parte
+ bloque = _extraer_primer_bloque(textwrap.dedent(bloque).strip())
+ if bloque.strip():
+ bloques.append(bloque)
+
+ # Fallback: si no había ```python, intentar con ``` genérico
+ if not bloques and "```" in texto:
+ partes = texto.split("```")
+ for i in range(1, len(partes), 2): # bloques impares son código
+ bloque = _extraer_primer_bloque(textwrap.dedent(partes[i]).strip())
+ if bloque.strip():
+ bloques.append(bloque)
+
+ # Fallback final: el texto crudo (después de ### Solution: si existe)
+ if not bloques:
+ crudo = (
+ texto.split("### Solution:")[-1].lstrip("\n")
+ if "### Solution:" in texto
+ else texto
+ )
+ crudo = _extraer_primer_bloque(textwrap.dedent(crudo).strip())
+ if crudo.strip():
+ bloques.append(crudo)
+
+ return bloques
+
+
+def ejecutar_y_verificar(codigo: str, verificador) -> tuple[str, str]:
+ import textwrap
+
+ # Si el output es formato instrucción, extraer solo el código después de ### Solution:
+ if "### Solution:" in codigo:
+ codigo = codigo.split("### Solution:")[-1].lstrip("\n")
+
+ # Extraer TODOS los bloques de código candidatos
+ bloques = _extraer_bloques_codigo(codigo)
+
+ # Intentar cada bloque — devolver el primero que PASA
+ ultimo_estado, ultimo_detalle = "SINTAXIS", "no se encontró código"
+ for bloque in bloques:
+ estado, detalle = _intentar_bloque(bloque, verificador)
+ if estado == "PASA":
+ return "PASA", ""
+ # Guardar el error más informativo (FALLA > ERROR_EXEC > SINTAXIS)
+ prioridad = {"FALLA": 3, "ERROR_EXEC": 2, "SINTAXIS": 1}
+ if prioridad.get(estado, 0) >= prioridad.get(ultimo_estado, 0):
+ ultimo_estado, ultimo_detalle = estado, detalle
+
+ return ultimo_estado, ultimo_detalle
+
+
+def _intentar_bloque(codigo: str, verificador) -> tuple[str, str]:
+
+ try:
+ ast.parse(codigo)
+ except SyntaxError:
+ # Intento 1: normalizar espacios-a-múltiplos-de-4
+ codigo = _normalizar_indentacion(codigo)
+ try:
+ ast.parse(codigo)
+ except SyntaxError:
+ # Intento 2: reparar bloques huérfanos (if/for sin cuerpo indentado)
+ codigo = _reparar_bloques_huerfanos(codigo)
+ try:
+ ast.parse(codigo)
+ except SyntaxError as e:
+ return "SINTAXIS", str(e)
+
+ ns = {}
+ try:
+ exec(compile(codigo, "", "exec"), ns)
+ except NameError as e:
+ # Intento 3: reparar NameError causado por variable indefinida en comprehension.
+ # Patrón: el modelo genera [x * i for x in range(...)] donde 'i' no está definido
+ # → se reemplaza la variable indefinida por la variable del loop.
+ import re as _re
+
+ undef_match = _re.search(r"name '(\w+)' is not defined", str(e))
+ if undef_match:
+ undef = undef_match.group(1)
+ # Buscar comprehensions del tipo [EXP for VAR in ...] donde EXP usa undef
+ comp_matches = list(
+ _re.finditer(r"\[.*?\bfor\s+(\w+)\s+in\b", codigo, _re.DOTALL)
+ )
+ for cm in comp_matches:
+ loop_var = cm.group(1)
+ if loop_var != undef:
+ codigo_fix = _re.sub(
+ r"\b" + _re.escape(undef) + r"\b", loop_var, codigo
+ )
+ try:
+ ns2: dict = {}
+ exec(compile(codigo_fix, "", "exec"), ns2)
+ resultado = verificador(ns2)
+ return (
+ ("PASA", "")
+ if resultado
+ else ("FALLA", "verificador → False")
+ )
+ except Exception:
+ pass
+ return "ERROR_EXEC", f"{type(e).__name__}: {e}"
+ except Exception as e:
+ return "ERROR_EXEC", f"{type(e).__name__}: {e}"
+
+ try:
+ resultado = verificador(ns)
+ return ("PASA", "") if resultado else ("FALLA", "verificador → False")
+ except KeyError as e:
+ return "FALLA", f"función no definida: {e}"
+ except NameError as e:
+ # NameError dentro del cuerpo de la función (e.g. [x * i for x in range(...)])
+ # El handler del exec no lo captura porque la función se define sin error.
+ import re as _re
+
+ undef_match = _re.search(r"name '(\w+)' is not defined", str(e))
+ if undef_match:
+ undef = undef_match.group(1)
+ comp_matches = list(
+ _re.finditer(r"\[.*?\bfor\s+(\w+)\s+in\b", codigo, _re.DOTALL)
+ )
+ for cm in comp_matches:
+ loop_var = cm.group(1)
+ if loop_var != undef:
+ codigo_fix = _re.sub(
+ r"\b" + _re.escape(undef) + r"\b", loop_var, codigo
+ )
+ try:
+ ns2: dict = {}
+ exec(compile(codigo_fix, "", "exec"), ns2)
+ resultado = verificador(ns2)
+ return (
+ ("PASA", "")
+ if resultado
+ else ("FALLA", "verificador → False")
+ )
+ except Exception:
+ pass
+ return "FALLA", f"NameError: {e}"
+ except Exception as e:
+ return "FALLA", f"{type(e).__name__}: {e}"
+
+
+# =============================================================================
+# Main
+# =============================================================================
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--checkpoint", default="checkpoints/v3_train.pt")
+ parser.add_argument("--temp", type=float, default=0.1)
+ parser.add_argument("--max-tokens", type=int, default=512)
+ parser.add_argument("--rep-penalty", type=float, default=1.2)
+ parser.add_argument(
+ "--guided",
+ action="store_true",
+ help="Include function/class signature in prompt (HumanEval style)",
+ )
+ parser.add_argument("--verbose", action="store_true")
+ parser.add_argument(
+ "--device", type=str, default="auto", help="'auto', 'cuda' o 'cpu'"
+ )
+ args = parser.parse_args()
+
+ if args.device == "auto":
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ else:
+ device = torch.device(args.device)
+ checkpoint = Path(args.checkpoint)
+
+ print(f"\n{'═' * 65}")
+ print(f" EVAL HONESTA — PamparV3 Generalización")
+ print(
+ f" Checkpoint : {checkpoint.name} ({checkpoint.stat().st_size / 1e9:.2f} GB)"
+ )
+ mode_str = "GUIDED" if args.guided else "OPEN"
+ print(
+ f" Device : {device} | Temp: {args.temp} | RepPen: {args.rep_penalty}"
+ )
+ print(f" Mode : {mode_str}")
+ print(f" Prompts : {len(CASOS)} (nunca vistos en entrenamiento)")
+ print(f"{'═' * 65}\n")
+
+ print(" Cargando modelo...", end=" ", flush=True)
+ t0 = time.time()
+ modelo, tokenizer = load_model(checkpoint, device, verbose=False)
+ n_params = sum(p.numel() for p in modelo.parameters()) / 1e6
+ print(f"OK ({n_params:.1f}M params, {time.time() - t0:.1f}s)\n")
+
+ resultados = []
+ t_start = time.time()
+
+ for i, caso in enumerate(CASOS, 1):
+ print(
+ f" [{i:02d}/{len(CASOS)}] Nivel {caso['nivel']} — {caso['desc']}",
+ end=" ",
+ flush=True,
+ )
+
+ t_gen = time.time()
+ prompt_gen = caso["prompt"]
+ if args.guided:
+ firma = extraer_firma(caso["prompt"])
+ if firma:
+ # Incluir hint de indentación (4 espacios) para primar al modelo
+ prompt_gen = caso["prompt"] + "```python\n" + firma + "\n "
+ codigo = generar(
+ modelo,
+ tokenizer,
+ prompt_gen,
+ device,
+ args.max_tokens,
+ args.temp,
+ args.rep_penalty,
+ )
+ dt = time.time() - t_gen
+
+ estado, detalle = ejecutar_y_verificar(codigo, caso["verificar"])
+
+ ICONOS = {"PASA": "✅", "FALLA": "❌", "SINTAXIS": "⚠️", "ERROR_EXEC": "💥"}
+ icono = ICONOS.get(estado, "?")
+ print(f"[{dt:.1f}s] {icono} {estado}" + (f" — {detalle}" if detalle else ""))
+
+ if args.verbose or estado != "PASA":
+ print()
+ for line in codigo.splitlines():
+ print(f" {line}")
+ print()
+
+ resultados.append(
+ {"desc": caso["desc"], "nivel": caso["nivel"], "estado": estado}
+ )
+
+ # ── Resumen ──────────────────────────────────────────────────────────────
+ elapsed = time.time() - t_start
+ pasan = sum(1 for r in resultados if r["estado"] == "PASA")
+ fallan = sum(1 for r in resultados if r["estado"] == "FALLA")
+ sintax = sum(1 for r in resultados if r["estado"] == "SINTAXIS")
+ errores = sum(1 for r in resultados if r["estado"] == "ERROR_EXEC")
+ total = len(resultados)
+
+ por_nivel: dict = {}
+ for r in resultados:
+ n = r["nivel"]
+ por_nivel.setdefault(n, {"pasan": 0, "total": 0})
+ por_nivel[n]["total"] += 1
+ if r["estado"] == "PASA":
+ por_nivel[n]["pasan"] += 1
+
+ print(f"\n{'═' * 65}")
+ print(f" RESULTADO FINAL — {elapsed:.0f}s total")
+ print(f"{'═' * 65}")
+ print(f" ✅ Pasan : {pasan}/{total} ({pasan / total * 100:.0f}%)")
+ print(f" ❌ Fallan : {fallan}/{total}")
+ print(f" ⚠️ Sintaxis : {sintax}/{total}")
+ print(f" 💥 Error exec : {errores}/{total}")
+ print()
+ print(" Por nivel:")
+ for nivel in sorted(por_nivel):
+ d = por_nivel[nivel]
+ barra = "█" * d["pasan"] + "░" * (d["total"] - d["pasan"])
+ print(f" Nivel {nivel}: {barra} {d['pasan']}/{d['total']}")
+
+ print()
+ pct = pasan / total * 100
+ if pct >= 80:
+ veredicto = "🟢 GENERALIZA BIEN — el modelo aprendió de verdad"
+ elif pct >= 50:
+ veredicto = "🟡 PARCIAL — aprende patrones pero falla en casos nuevos"
+ elif pct >= 25:
+ veredicto = "🟠 PROBABLE MEMORIZACIÓN — mejora en benchmark pero no generaliza"
+ else:
+ veredicto = "🔴 NO GENERALIZA — 134k pasos no fueron suficientes"
+
+ print(f" {veredicto}")
+ print(f"{'═' * 65}\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/ghidra_trainer.py b/scripts/ghidra_trainer.py
new file mode 100644
index 0000000000000000000000000000000000000000..043ef696822a4498c5b0d6319c0db3c093a223c0
--- /dev/null
+++ b/scripts/ghidra_trainer.py
@@ -0,0 +1,914 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+PAMPAr Ghidra-Trainer — SFT con monitorización GhidraProbe en tiempo real.
+
+Entrena PamparV3 con el dataset master_sft.jsonl (1253 ejemplos únicos)
+monitorizando con GhidraProbe cada N pasos para detectar regresiones
+en routing, normas y balance de streams ANTES de que destruyan el modelo.
+
+Lecciones de 18 rounds previos:
+ - NO train-norm-clamp (destruye gradientes → Score 85→74)
+ - NO alpha-exit (descalibra la confianza)
+ - Proteger CE por encima de todo
+ - El GhidraProbe detecta problemas que el loss no muestra
+
+Uso:
+ python scripts/ghidra_trainer.py
+ python scripts/ghidra_trainer.py --max-pasos 800 --probe-cada 50
+ python scripts/ghidra_trainer.py --data data/master_sft.jsonl --lr 2e-7
+"""
+
+from __future__ import annotations
+
+import argparse
+import dataclasses
+from dataclasses import dataclass
+import json
+import logging
+import math
+import random
+import sys
+import time
+from collections import deque
+from pathlib import Path
+from typing import Optional
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+import torch.nn.utils as nn_utils
+import sentencepiece as spm
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+
+from pampar.coder.v3 import PamparV3, ConfigV3, PRESET_V3
+from pampar.coder.v3.talamo import TalamoInicial
+from pampar.coder.v3.llaves import clasificar_token
+from pampar.coder.v3.zonas import ZONA_TERRITORIO
+from pampar.coder.v3.ghidra_probe import GhidraProbe, STREAM_NAMES
+
+logger = logging.getLogger("ghidra_trainer")
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ datefmt="%H:%M:%S",
+)
+
+_MARCADORES = ["### Solution:", "### Protocolo:", "### Scan:", "### Response:"]
+
+
+# ─────────────────────────────────────────────────────────────────
+# Data
+# ─────────────────────────────────────────────────────────────────
+
+def _cargar_datos(ruta: Path) -> list[str]:
+ textos: list[str] = []
+ with open(ruta, "r", encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ obj = json.loads(line)
+ texto = obj.get("text", "")
+ if texto:
+ textos.append(texto)
+ logger.info("Cargados %d ejemplos desde %s", len(textos), ruta.name)
+ return textos
+
+
+def _tokenizar_con_mascara(
+ textos: list[str],
+ tokenizer: spm.SentencePieceProcessor,
+ max_len: int,
+) -> list[tuple[list[int], list[bool]]]:
+ chunks: list[tuple[list[int], list[bool]]] = []
+ for texto in textos:
+ ids_full = tokenizer.Encode(texto)
+ if len(ids_full) < 8:
+ continue
+ ids_full = ids_full[: max_len + 1]
+
+ n_prefijo = len(ids_full)
+ for marcador in _MARCADORES:
+ pos = texto.find(marcador)
+ if pos >= 0:
+ ids_pre = tokenizer.Encode(texto[: pos + len(marcador)])
+ n_prefijo = min(len(ids_pre), len(ids_full))
+ break
+
+ mascara = [False] * n_prefijo + [True] * (len(ids_full) - n_prefijo)
+ chunks.append((ids_full, mascara))
+ return chunks
+
+
+def _hacer_batch(
+ chunks: list[tuple[list[int], list[bool]]],
+ indices: list[int],
+ device: torch.device,
+ max_seq_len: int,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ sels = [chunks[i] for i in indices]
+ max_len = min(max(len(ids) for ids, _ in sels), max_seq_len + 1)
+ padded_ids: list[list[int]] = []
+ padded_mask: list[list[bool]] = []
+ for ids, mask in sels:
+ t = ids[:max_len]
+ m = mask[:max_len]
+ pad_len = max_len - len(t)
+ padded_ids.append(t + [0] * pad_len)
+ padded_mask.append(m + [False] * pad_len)
+ tokens = torch.tensor(padded_ids, dtype=torch.long, device=device)
+ mascara = torch.tensor(padded_mask, dtype=torch.bool, device=device)
+ return tokens, mascara
+
+
+# ─────────────────────────────────────────────────────────────────
+# Territory Table
+# ─────────────────────────────────────────────────────────────────
+
+def _build_territory_table(
+ tokenizer: spm.SentencePieceProcessor,
+) -> torch.Tensor:
+ vocab_size = tokenizer.GetPieceSize()
+ table = torch.zeros(vocab_size, dtype=torch.long)
+ for token_id in range(vocab_size):
+ piece = tokenizer.IdToPiece(token_id)
+ zona, _conf = clasificar_token(piece)
+ table[token_id] = ZONA_TERRITORIO[zona].value
+ return table
+
+
+# ─────────────────────────────────────────────────────────────────
+# Losses (simplificado: CE + routing solo)
+# ─────────────────────────────────────────────────────────────────
+
+def forward_neuro(
+ model: PamparV3,
+ input_ids: torch.Tensor,
+) -> tuple[torch.Tensor, dict]:
+ config = model.config
+ n_streams = config.n_streams
+
+ x = model.emb_drop(model.tok_emb(input_ids))
+ terr_acts, zona_acts_n0 = model.talamo(x, input_ids)
+ streams = [x.clone() for _ in range(n_streams)]
+
+ all_terr_acts: list[torch.Tensor] = [terr_acts]
+ all_stream_norms: list[torch.Tensor] = []
+
+ for nivel in model.niveles:
+ streams, terr_acts, _ = nivel(streams, terr_acts, TalamoInicial.agregar_fn)
+ all_terr_acts.append(terr_acts)
+
+ norms = torch.stack(
+ [streams[t].norm(dim=-1).mean() for t in range(n_streams)]
+ )
+ all_stream_norms.append(norms)
+
+ x_final = model._combinar_streams(streams, terr_acts)
+ x_final = model.norm_f(x_final)
+ logits = model.lm_head(x_final)
+
+ return logits, {
+ "all_terr_acts": all_terr_acts,
+ "all_stream_norms": all_stream_norms,
+ }
+
+
+def calcular_loss_routing(
+ all_terr_acts: list[torch.Tensor],
+ input_ids: torch.Tensor,
+ territory_table: torch.Tensor,
+ focal_gamma: float = 0.0,
+) -> torch.Tensor:
+ """Routing loss con focal loss opcional (gamma>0 focaliza en tokens mal ruteados)."""
+ device = input_ids.device
+ weights = torch.tensor(
+ [min(10.0, (48000 / 169) ** 0.5),
+ 1.0,
+ min(10.0, (48000 / 72) ** 0.5),
+ min(10.0, (48000 / 8) ** 0.5)],
+ device=device, dtype=torch.float32,
+ )
+ targets = territory_table.to(device)[input_ids]
+ total = torch.tensor(0.0, device=device)
+ for ta in all_terr_acts:
+ L_ta = ta.shape[1]
+ t = targets[:, :L_ta]
+ if focal_gamma > 0:
+ logprobs = F.log_softmax(ta.reshape(-1, 4), dim=-1)
+ t_flat = t.reshape(-1)
+ p_t = logprobs.exp().gather(1, t_flat.unsqueeze(1)).squeeze(1)
+ focal_w = (1.0 - p_t).detach() ** focal_gamma
+ per_token = F.nll_loss(logprobs, t_flat, weight=weights, reduction="none")
+ total = total + (focal_w * per_token).mean()
+ else:
+ total = total + F.cross_entropy(
+ ta.reshape(-1, 4), t.reshape(-1), weight=weights
+ )
+ return total / len(all_terr_acts)
+
+
+def calcular_loss_balance(all_stream_norms: list[torch.Tensor]) -> torch.Tensor:
+ """Coeficiente de variación: std/mean. Siempre positivo, bounded."""
+ total = torch.tensor(0.0, device=all_stream_norms[0].device)
+ for norms in all_stream_norms:
+ mean_n = norms.mean()
+ std_n = norms.std()
+ total = total + std_n / mean_n.clamp(min=1e-8)
+ return total / len(all_stream_norms)
+
+
+# ─────────────────────────────────────────────────────────────────
+# GhidraProbe Diagnosis (durante training)
+# ─────────────────────────────────────────────────────────────────
+
+@dataclass
+class ProbeSnapshot:
+ """Métricas clave extraídas del GhidraProbe en un paso."""
+ paso: int
+ # Normas por nivel [5]
+ norms_per_level: list[float]
+ # Routing: % de tokens dominados por SEMA en nivel final
+ sema_dominance: float
+ # Routing diversity: std promedio de terr_acts
+ routing_std: float
+ # LLAVES utilización
+ llaves_nonzero_pct: float
+ # Stream balance: ratio min/max norm en nivel final
+ stream_balance: float
+ # Lateral scales promedio
+ lateral_scales_avg: list[float]
+
+
+@dataclass
+class ProbeBaseline:
+ """Línea base del modelo pre-entrenamiento para detectar regresiones."""
+ norms_per_level: list[float]
+ sema_dominance: float
+ routing_std: float
+ llaves_nonzero_pct: float
+ stream_balance: float
+
+
+PROBE_SENTENCES = [
+ "def fibonacci(n):",
+ "import os\nfrom pathlib import Path",
+ "if x > 0 and y < 10:",
+ "class DataLoader:",
+]
+
+
+def _run_probe(
+ probe: GhidraProbe,
+ modelo: PamparV3,
+ tokenizer: spm.SentencePieceProcessor,
+ device: torch.device,
+ paso: int,
+) -> ProbeSnapshot:
+ """Ejecuta GhidraProbe sobre frases de test y extrae métricas clave."""
+ modelo.eval()
+
+ # Promediar métricas sobre varias frases para estabilidad
+ all_norms: list[list[float]] = []
+ all_sema_pct: list[float] = []
+ all_routing_std: list[float] = []
+ all_llaves_pct: list[float] = []
+ all_balance: list[float] = []
+ all_lat_scales: list[list[float]] = []
+
+ for sentence in PROBE_SENTENCES:
+ probe.reset()
+ ids = tokenizer.Encode(sentence)
+ input_ids = torch.tensor([ids], device=device)
+
+ with torch.no_grad():
+ modelo(input_ids)
+
+ cap = probe.report()
+
+ # Normas por nivel
+ level_norms = []
+ for nc in cap.niveles:
+ avg_norm = sum(nc.streams_out_norms) / max(len(nc.streams_out_norms), 1)
+ level_norms.append(avg_norm)
+ all_norms.append(level_norms)
+
+ # SEMA dominance en nivel final
+ if cap.niveles:
+ last = cap.niveles[-1]
+ terr = last.terr_acts_mean
+ total_terr = sum(terr) if terr else 1.0
+ sema_pct = (terr[1] / total_terr * 100) if len(terr) > 1 and total_terr > 0 else 0.0
+ all_sema_pct.append(sema_pct)
+
+ # Routing std
+ if terr:
+ mean_t = sum(terr) / len(terr)
+ std_t = (sum((v - mean_t) ** 2 for v in terr) / len(terr)) ** 0.5
+ all_routing_std.append(std_t)
+
+ # Stream balance
+ if last.streams_out_norms:
+ mn = min(last.streams_out_norms)
+ mx = max(last.streams_out_norms)
+ all_balance.append(mn / mx if mx > 0 else 0.0)
+
+ # Lateral scales
+ if last.lateral_scales:
+ all_lat_scales.append(last.lateral_scales)
+
+ # LLAVES
+ if cap.talamo:
+ all_llaves_pct.append(cap.talamo.llaves_nonzero_pct)
+
+ modelo.train()
+
+ # Promediar
+ n_levels = max(len(n) for n in all_norms) if all_norms else 5
+ avg_norms = []
+ for lvl in range(n_levels):
+ vals = [n[lvl] for n in all_norms if lvl < len(n)]
+ avg_norms.append(sum(vals) / max(len(vals), 1))
+
+ avg_lat = []
+ if all_lat_scales:
+ n_s = len(all_lat_scales[0])
+ for s in range(n_s):
+ vals = [ls[s] for ls in all_lat_scales if s < len(ls)]
+ avg_lat.append(sum(vals) / max(len(vals), 1))
+
+ return ProbeSnapshot(
+ paso=paso,
+ norms_per_level=avg_norms,
+ sema_dominance=sum(all_sema_pct) / max(len(all_sema_pct), 1),
+ routing_std=sum(all_routing_std) / max(len(all_routing_std), 1),
+ llaves_nonzero_pct=sum(all_llaves_pct) / max(len(all_llaves_pct), 1),
+ stream_balance=sum(all_balance) / max(len(all_balance), 1),
+ lateral_scales_avg=avg_lat,
+ )
+
+
+def _print_probe_report(
+ snap: ProbeSnapshot,
+ baseline: Optional[ProbeBaseline] = None,
+) -> None:
+ """Imprime reporte del GhidraProbe con deltas vs baseline."""
+ print(f"\n {'─' * 60}")
+ print(f" GHIDRA PROBE @ paso {snap.paso}")
+ print(f" {'─' * 60}")
+
+ # Normas por nivel
+ norm_parts = []
+ for i, n in enumerate(snap.norms_per_level):
+ delta = ""
+ if baseline and i < len(baseline.norms_per_level):
+ d = n - baseline.norms_per_level[i]
+ sign = "+" if d >= 0 else ""
+ color = "\033[31m" if abs(d) / max(baseline.norms_per_level[i], 1) > 0.2 else "\033[32m"
+ delta = f" ({color}{sign}{d:.1f}\033[0m)"
+ norm_parts.append(f"N{i}={n:.1f}{delta}")
+ print(f" Normas: {' | '.join(norm_parts)}")
+
+ # SEMA dominance
+ sema_delta = ""
+ if baseline:
+ d = snap.sema_dominance - baseline.sema_dominance
+ sign = "+" if d >= 0 else ""
+ color = "\033[31m" if d > 5 else "\033[32m" if d < -2 else "\033[33m"
+ sema_delta = f" ({color}{sign}{d:.1f}%\033[0m)"
+ sema_color = "\033[31m" if snap.sema_dominance > 60 else "\033[33m" if snap.sema_dominance > 40 else "\033[32m"
+ print(f" SEMA dom: {sema_color}{snap.sema_dominance:.1f}%\033[0m{sema_delta}")
+
+ # Routing diversity
+ rstd_delta = ""
+ if baseline:
+ d = snap.routing_std - baseline.routing_std
+ sign = "+" if d >= 0 else ""
+ rstd_delta = f" ({sign}{d:.4f})"
+ print(f" Route std: {snap.routing_std:.4f}{rstd_delta}")
+
+ # LLAVES
+ ll_delta = ""
+ if baseline:
+ d = snap.llaves_nonzero_pct - baseline.llaves_nonzero_pct
+ sign = "+" if d >= 0 else ""
+ ll_delta = f" ({sign}{d:.1f}%)"
+ print(f" LLAVES: {snap.llaves_nonzero_pct:.1f}% non-zero{ll_delta}")
+
+ # Stream balance
+ bal_delta = ""
+ if baseline:
+ d = snap.stream_balance - baseline.stream_balance
+ sign = "+" if d >= 0 else ""
+ bal_delta = f" ({sign}{d:.3f})"
+ bal_color = "\033[32m" if snap.stream_balance > 0.5 else "\033[33m" if snap.stream_balance > 0.2 else "\033[31m"
+ print(f" Balance: {bal_color}{snap.stream_balance:.3f}\033[0m{bal_delta}")
+
+ # Lateral scales
+ if snap.lateral_scales_avg:
+ scales_str = " | ".join(
+ f"{STREAM_NAMES[i]}={v:.4f}" for i, v in enumerate(snap.lateral_scales_avg)
+ )
+ print(f" Lat scale: {scales_str}")
+
+ print(f" {'─' * 60}")
+
+
+def _check_regression(
+ snap: ProbeSnapshot,
+ baseline: ProbeBaseline,
+ history: list[ProbeSnapshot],
+) -> list[str]:
+ """Detecta señales de regresión comparando con baseline."""
+ warnings: list[str] = []
+
+ # 1. Normas explotando (>2x baseline en cualquier nivel)
+ for i, n in enumerate(snap.norms_per_level):
+ if i < len(baseline.norms_per_level):
+ ratio = n / max(baseline.norms_per_level[i], 1)
+ if ratio > 2.0:
+ warnings.append(
+ f"NORMA N{i} explotando: {n:.1f} ({ratio:.1f}x baseline)"
+ )
+
+ # 2. SEMA dominance aumentando (colapso territorial)
+ if snap.sema_dominance > baseline.sema_dominance + 10:
+ warnings.append(
+ f"SEMA dominance subiendo: {snap.sema_dominance:.1f}% "
+ f"(baseline {baseline.sema_dominance:.1f}%)"
+ )
+
+ # 3. Stream balance degradando
+ if snap.stream_balance < baseline.stream_balance * 0.5:
+ warnings.append(
+ f"Stream balance degradando: {snap.stream_balance:.3f} "
+ f"(baseline {baseline.stream_balance:.3f})"
+ )
+
+ # 4. Tendencia de las últimas 3 probes (normas subiendo consistentemente)
+ if len(history) >= 3:
+ last3 = history[-3:]
+ for lvl in range(min(5, len(snap.norms_per_level))):
+ vals = [h.norms_per_level[lvl] for h in last3 if lvl < len(h.norms_per_level)]
+ if len(vals) == 3 and all(vals[j] < vals[j + 1] for j in range(2)):
+ growth = vals[-1] / max(vals[0], 1)
+ if growth > 1.3:
+ warnings.append(
+ f"N{lvl} normas subiendo 3 probes consecutivas "
+ f"({vals[0]:.0f}→{vals[-1]:.0f})"
+ )
+
+ return warnings
+
+
+# ─────────────────────────────────────────────────────────────────
+# Training Step
+# ─────────────────────────────────────────────────────────────────
+
+def paso_entrenamiento(
+ modelo: PamparV3,
+ optimizer: torch.optim.Optimizer,
+ tokens: torch.Tensor,
+ mascara: torch.Tensor,
+ max_grad_norm: float,
+ alpha_diff: float,
+ alpha_balance: float,
+ territory_table: torch.Tensor,
+ warmup_factor: float = 1.0,
+ focal_gamma: float = 0.0,
+) -> dict[str, float]:
+ """Un paso de entrenamiento: CE + routing + balance (sin exit, sin norm)."""
+ modelo.train()
+ optimizer.zero_grad(set_to_none=True)
+
+ input_ids = tokens[:, :-1]
+ targets = tokens[:, 1:]
+ loss_mask = mascara[:, 1:]
+
+ logits, neuro_info = forward_neuro(modelo, input_ids)
+ B, T, V = logits.shape
+
+ # CE Loss principal
+ targets_masked = targets.masked_fill(~loss_mask, -100)
+ loss_ce = F.cross_entropy(
+ logits.reshape(B * T, V),
+ targets_masked.reshape(B * T),
+ ignore_index=-100,
+ )
+
+ # Solo routing + balance (lecciones de 18 rounds: no exit, no norm train)
+ loss_diff = calcular_loss_routing(
+ neuro_info["all_terr_acts"], input_ids, territory_table,
+ focal_gamma=focal_gamma,
+ )
+ loss_balance = calcular_loss_balance(neuro_info["all_stream_norms"])
+
+ wf = warmup_factor
+ loss = (
+ loss_ce
+ + wf * alpha_diff * loss_diff
+ + wf * alpha_balance * loss_balance
+ )
+
+ # Si CE_mask era todo False (ej: agents sin marcador), CE=0 → no entrenar
+ n_valid = loss_mask.sum().item()
+ if n_valid == 0:
+ return {"ce": 0.0, "diff": 0.0, "balance": 0.0, "total": 0.0, "skipped": True}
+
+ if loss.isnan() or loss.isinf():
+ logger.warning("Loss inestable, skipping step")
+ return {"ce": 0.0, "diff": 0.0, "balance": 0.0, "total": 0.0, "skipped": True}
+
+ loss.backward()
+ nn_utils.clip_grad_norm_(modelo.parameters(), max_grad_norm)
+ optimizer.step()
+
+ return {
+ "ce": float(loss_ce.detach()),
+ "diff": float(loss_diff.detach()),
+ "balance": float(loss_balance.detach()),
+ "total": float(loss.detach()),
+ "skipped": False,
+ }
+
+
+# ─────────────────────────────────────────────────────────────────
+# Utils
+# ─────────────────────────────────────────────────────────────────
+
+def _cosine_lr(
+ paso: int, warmup: int, total: int, lr_max: float, lr_min: float,
+) -> float:
+ if paso < warmup:
+ return lr_max * (paso + 1) / warmup
+ progreso = (paso - warmup) / max(1, total - warmup)
+ return lr_min + 0.5 * (lr_max - lr_min) * (1 + math.cos(math.pi * progreso))
+
+
+def _guardar(
+ ruta: Path, modelo: PamparV3, optimizer: torch.optim.Optimizer, paso: int,
+) -> None:
+ ruta.parent.mkdir(parents=True, exist_ok=True)
+ torch.save(
+ {
+ "modelo": modelo.state_dict(),
+ "optimizer": optimizer.state_dict(),
+ "paso_global": paso,
+ "config": dataclasses.asdict(modelo.config),
+ "tipo": "ghidra_trainer",
+ },
+ ruta,
+ )
+ logger.info("Checkpoint guardado -> %s (paso %d)", ruta.name, paso)
+
+
+# ─────────────────────────────────────────────────────────────────
+# CLI + main
+# ─────────────────────────────────────────────────────────────────
+
+def _parse_args() -> argparse.Namespace:
+ p = argparse.ArgumentParser(
+ description="PAMPAr Ghidra-Trainer: SFT con monitoreo GhidraProbe",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ p.add_argument(
+ "--checkpoint-in", type=Path,
+ default=ROOT / "checkpoints" / "v3_neuro_v9.pt",
+ )
+ p.add_argument(
+ "--checkpoint-out", type=Path,
+ default=ROOT / "checkpoints" / "v3_ghidra_v1.pt",
+ )
+ p.add_argument(
+ "--tokenizer", type=Path,
+ default=ROOT / "data" / "tokenizer" / "pampar_48k.model",
+ )
+ p.add_argument(
+ "--data", type=Path,
+ default=ROOT / "data" / "master_sft.jsonl",
+ )
+
+ # Learning rates
+ p.add_argument("--lr", type=float, default=3e-7)
+ p.add_argument("--lr-min", type=float, default=3e-8)
+ p.add_argument("--lr-routing", type=float, default=1e-3)
+ p.add_argument("--lr-routing-min", type=float, default=1e-4)
+
+ # Training config
+ p.add_argument("--warmup", type=int, default=30)
+ p.add_argument("--max-pasos", type=int, default=1000)
+ p.add_argument("--epochs", type=int, default=20)
+ p.add_argument("--batch-size", type=int, default=1)
+ p.add_argument("--seq-len", type=int, default=256)
+ p.add_argument("--max-grad-norm", type=float, default=1.0)
+ p.add_argument("--guardar-cada", type=int, default=200)
+
+ # Aux losses (conservador: solo routing + balance)
+ p.add_argument("--alpha-diff", type=float, default=0.5)
+ p.add_argument("--alpha-balance", type=float, default=0.05)
+ p.add_argument("--focal-gamma", type=float, default=0.0,
+ help="Focal loss gamma (0=standard CE, 2+=focus on wrong tokens)")
+ p.add_argument("--aux-warmup", type=int, default=50)
+
+ # GhidraProbe
+ p.add_argument("--probe-cada", type=int, default=50,
+ help="Cada cuantos pasos ejecutar GhidraProbe")
+ p.add_argument("--abort-on-regression", action="store_true",
+ help="Detener si GhidraProbe detecta regresion severa")
+
+ p.add_argument("--device", type=str, default="auto")
+ p.add_argument("--seed", type=int, default=42)
+
+ return p.parse_args()
+
+
+def main() -> None:
+ args = _parse_args()
+ random.seed(args.seed)
+ torch.manual_seed(args.seed)
+
+ device = torch.device(
+ "cuda"
+ if args.device == "auto" and torch.cuda.is_available()
+ else args.device
+ if args.device != "auto"
+ else "cpu"
+ )
+ logger.info("Device: %s", device)
+ if device.type == "cuda":
+ torch.cuda.manual_seed(args.seed)
+ logger.info(
+ "GPU: %s (%.1f GiB)",
+ torch.cuda.get_device_name(0),
+ torch.cuda.get_device_properties(0).total_memory / 1e9,
+ )
+
+ # Tokenizer
+ tok = spm.SentencePieceProcessor()
+ tok.Load(str(args.tokenizer))
+ logger.info("Tokenizer vocab=%d", tok.GetPieceSize())
+
+ # Modelo
+ if not args.checkpoint_in.exists():
+ logger.error("Checkpoint no encontrado: %s", args.checkpoint_in)
+ sys.exit(1)
+
+ payload = torch.load(
+ args.checkpoint_in, map_location=device, weights_only=False,
+ )
+ config = ConfigV3(**payload["config"]) if "config" in payload else PRESET_V3
+ modelo = PamparV3(config).to(device)
+ modelo.load_state_dict(payload["modelo"])
+ modelo.registrar_tokenizer(tok)
+ logger.info(
+ "Cargado '%s' (tipo: %s)", args.checkpoint_in.name, payload.get("tipo", "?"),
+ )
+ del payload
+
+ n_params = sum(p.numel() for p in modelo.parameters() if p.requires_grad)
+ logger.info("PamparV3 %.1fM params", n_params / 1e6)
+
+ # Datos
+ if not args.data.exists():
+ logger.error("Dataset no encontrado: %s", args.data)
+ sys.exit(1)
+
+ textos = _cargar_datos(args.data)
+ chunks = _tokenizar_con_mascara(textos, tok, args.seq_len)
+ logger.info("Chunks tokenizados: %d (seq_len=%d)", len(chunks), args.seq_len)
+ del textos
+
+ pct_solution = sum(sum(m) for _, m in chunks) / max(
+ 1, sum(len(ids) for ids, _ in chunks),
+ )
+ logger.info("Porcentaje tokens en zona Solution: %.1f%%", pct_solution * 100)
+
+ # Territory table
+ territory_table = _build_territory_table(tok)
+
+ # ── GhidraProbe setup ─────────────────────────────────────────
+ probe = GhidraProbe(modelo)
+ logger.info("GhidraProbe instalado (%d hooks)", len(probe._hooks))
+
+ # Capturar baseline ANTES de entrenar
+ logger.info("Capturando baseline GhidraProbe...")
+ snap_baseline = _run_probe(probe, modelo, tok, device, paso=0)
+ baseline = ProbeBaseline(
+ norms_per_level=snap_baseline.norms_per_level[:],
+ sema_dominance=snap_baseline.sema_dominance,
+ routing_std=snap_baseline.routing_std,
+ llaves_nonzero_pct=snap_baseline.llaves_nonzero_pct,
+ stream_balance=snap_baseline.stream_balance,
+ )
+ _print_probe_report(snap_baseline)
+ probe_history: list[ProbeSnapshot] = [snap_baseline]
+
+ # ── Optimizer ─────────────────────────────────────────────────
+ routing_names = {"talamo", "talamo_nivel", "lateral"}
+ routing_params: list[nn.Parameter] = []
+ main_params: list[nn.Parameter] = []
+ for name, p in modelo.named_parameters():
+ parts = name.split(".")
+ if any(rn in parts for rn in routing_names):
+ routing_params.append(p)
+ else:
+ main_params.append(p)
+
+ n_routing = sum(p.numel() for p in routing_params)
+ n_main = sum(p.numel() for p in main_params)
+ logger.info(
+ "Param groups: routing=%.1fK (lr=%.1e) | main=%.1fM (lr=%.1e)",
+ n_routing / 1e3, args.lr_routing, n_main / 1e6, args.lr,
+ )
+
+ optimizer = torch.optim.AdamW(
+ [
+ {"params": main_params, "lr": args.lr},
+ {"params": routing_params, "lr": args.lr_routing},
+ ],
+ betas=(0.9, 0.95),
+ weight_decay=0.01,
+ eps=1e-8,
+ )
+
+ pasos_por_epoch = max(1, len(chunks) // args.batch_size)
+ total_pasos = min(args.max_pasos, args.epochs * pasos_por_epoch)
+ logger.info(
+ "Ghidra-Training: %d pasos | %d chunks | "
+ "alphas: diff=%.3f balance=%.3f | focal_gamma=%.1f | probe cada %d pasos",
+ total_pasos, len(chunks),
+ args.alpha_diff, args.alpha_balance, args.focal_gamma, args.probe_cada,
+ )
+
+ # ── Training Loop ─────────────────────────────────────────────
+ paso = 0
+ effective_steps = 0
+ t0 = time.time()
+ losses_ce: deque[float] = deque(maxlen=100)
+ losses_total: deque[float] = deque(maxlen=100)
+ best_ce = float("inf")
+ best_paso = 0
+ aborted = False
+
+ try:
+ for epoch in range(args.epochs):
+ idx = list(range(len(chunks)))
+ random.shuffle(idx)
+ logger.info("== Epoch %d/%d ==", epoch + 1, args.epochs)
+
+ for i in range(0, len(idx) - args.batch_size + 1, args.batch_size):
+ batch_idx = idx[i : i + args.batch_size]
+ tokens, mascara = _hacer_batch(
+ chunks, batch_idx, device, args.seq_len,
+ )
+
+ # LR scheduling
+ lr_main = _cosine_lr(
+ paso, args.warmup, total_pasos, args.lr, args.lr_min,
+ )
+ lr_rout = _cosine_lr(
+ paso, args.warmup, total_pasos,
+ args.lr_routing, args.lr_routing_min,
+ )
+ optimizer.param_groups[0]["lr"] = lr_main
+ optimizer.param_groups[1]["lr"] = lr_rout
+
+ wf = min(1.0, paso / max(1, args.aux_warmup))
+
+ loss_dict = paso_entrenamiento(
+ modelo, optimizer, tokens, mascara,
+ args.max_grad_norm, args.alpha_diff, args.alpha_balance,
+ territory_table, warmup_factor=wf,
+ focal_gamma=args.focal_gamma,
+ )
+
+ if not loss_dict.get("skipped", False) and loss_dict["ce"] > 0:
+ losses_ce.append(loss_dict["ce"])
+ losses_total.append(loss_dict["total"])
+ effective_steps += 1
+
+ # Track best CE
+ avg_ce = sum(losses_ce) / len(losses_ce)
+ if avg_ce < best_ce and paso > args.warmup:
+ best_ce = avg_ce
+ best_paso = paso
+
+ paso += 1
+
+ # Log cada 10 pasos
+ if paso % 10 == 0:
+ avg_ce = sum(losses_ce) / max(1, len(losses_ce))
+ elapsed = time.time() - t0
+ logger.info(
+ "paso %4d/%d | CE=%.3f diff=%.3f bal=%.3f "
+ "total=%.3f lr=%.1e (%.1f p/s)",
+ paso, total_pasos,
+ loss_dict["ce"], loss_dict["diff"],
+ loss_dict["balance"], avg_ce,
+ lr_rout, paso / elapsed,
+ )
+
+ # GhidraProbe cada N pasos
+ if paso % args.probe_cada == 0:
+ snap = _run_probe(probe, modelo, tok, device, paso)
+ _print_probe_report(snap, baseline)
+ probe_history.append(snap)
+
+ # Check regression
+ warnings = _check_regression(snap, baseline, probe_history)
+ if warnings:
+ print(f"\n \033[33m{'!' * 40}")
+ print(f" GHIDRA WARNINGS @ paso {paso}:")
+ for w in warnings:
+ print(f" - {w}")
+ print(f" {'!' * 40}\033[0m\n")
+
+ if args.abort_on_regression and len(warnings) >= 2:
+ logger.error(
+ "Abortando: %d warnings de regresion", len(warnings),
+ )
+ aborted = True
+ break
+
+ # Guardar checkpoint periódico
+ if paso % args.guardar_cada == 0:
+ _guardar(args.checkpoint_out, modelo, optimizer, paso)
+
+ if paso >= args.max_pasos:
+ break
+
+ if paso >= args.max_pasos or aborted:
+ break
+
+ except KeyboardInterrupt:
+ logger.info("Interrumpido, guardando...")
+
+ # ── Cleanup GhidraProbe ───────────────────────────────────────
+ probe.detach()
+
+ # ── Final probe + save ────────────────────────────────────────
+ probe_final = GhidraProbe(modelo)
+ snap_final = _run_probe(probe_final, modelo, tok, device, paso)
+ probe_final.detach()
+
+ _guardar(args.checkpoint_out, modelo, optimizer, paso)
+
+ # ── Reporte final ─────────────────────────────────────────────
+ elapsed = time.time() - t0
+ avg_ce = sum(losses_ce) / max(1, len(losses_ce))
+
+ print(f"\n{'=' * 60}")
+ print(f" GHIDRA-TRAINING COMPLETADO")
+ print(f"{'=' * 60}")
+ print(f" Pasos: {paso} ({effective_steps} efectivos)")
+ print(f" Tiempo: {int(elapsed // 60)}m{int(elapsed % 60):02d}s")
+ print(f" Loss CE avg: {avg_ce:.3f}")
+ print(f" PPL: {math.exp(min(avg_ce, 10)):.1f}")
+ print(f" Best CE avg: {best_ce:.3f} (paso {best_paso})")
+ print(f" Checkpoint: {args.checkpoint_out}")
+ if aborted:
+ print(f" \033[31mABORTADO por regresion detectada\033[0m")
+
+ print(f"\n --- Baseline vs Final ---")
+ print(f" {'Metrica':<20} {'Baseline':>10} {'Final':>10} {'Delta':>10}")
+ print(f" {'─' * 54}")
+
+ for i in range(len(snap_final.norms_per_level)):
+ b = baseline.norms_per_level[i] if i < len(baseline.norms_per_level) else 0
+ f_val = snap_final.norms_per_level[i]
+ print(f" {'Norm N' + str(i):<20} {b:>10.1f} {f_val:>10.1f} {f_val - b:>+10.1f}")
+
+ print(f" {'SEMA dominance':<20} {baseline.sema_dominance:>9.1f}% {snap_final.sema_dominance:>9.1f}% {snap_final.sema_dominance - baseline.sema_dominance:>+9.1f}%")
+ print(f" {'Routing std':<20} {baseline.routing_std:>10.4f} {snap_final.routing_std:>10.4f} {snap_final.routing_std - baseline.routing_std:>+10.4f}")
+ print(f" {'Stream balance':<20} {baseline.stream_balance:>10.3f} {snap_final.stream_balance:>10.3f} {snap_final.stream_balance - baseline.stream_balance:>+10.3f}")
+
+ # Guardar historial de probes como JSON
+ probe_log = args.checkpoint_out.with_suffix(".probe.json")
+ probe_entries = []
+ for s in probe_history + [snap_final]:
+ probe_entries.append({
+ "paso": s.paso,
+ "norms": s.norms_per_level,
+ "sema_dom": s.sema_dominance,
+ "routing_std": s.routing_std,
+ "llaves_pct": s.llaves_nonzero_pct,
+ "balance": s.stream_balance,
+ })
+ with open(probe_log, "w", encoding="utf-8") as f:
+ json.dump(probe_entries, f, indent=2)
+ print(f"\n Probe log: {probe_log.name}")
+
+ print(f"\n Verificar con Brain Scanner:")
+ print(
+ f" python -X utf8 scripts/brain_scanner.py --suite --device cuda"
+ f" --checkpoint {args.checkpoint_out}",
+ )
+ print(f"{'=' * 60}\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/launch_training_night.ps1 b/scripts/launch_training_night.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..106fe742c281070d705ba78e8acc54e666cfc9d7
--- /dev/null
+++ b/scripts/launch_training_night.ps1
@@ -0,0 +1,59 @@
+# launch_training_night.ps1
+# Prepara la PC para training overnight y lanza el proceso con maxima prioridad.
+
+Write-Host ""
+Write-Host "===================================================" -ForegroundColor Cyan
+Write-Host " MODO TRAINING NOCTURNO - PamparV3" -ForegroundColor Cyan
+Write-Host "===================================================" -ForegroundColor Cyan
+Write-Host ""
+
+Set-Location "c:\Users\lucas\Documents\Be Web\Lunux-AI\PAMPAr-Coder"
+
+# 1. Matar procesos que consumen recursos
+Write-Host "[1/5] Cerrando apps innecesarias..." -ForegroundColor Yellow
+@("chrome", "msedge", "firefox", "Discord", "slack", "OneDrive", "Teams") | ForEach-Object {
+ $killed = Stop-Process -Name $_ -Force -ErrorAction SilentlyContinue -PassThru
+ if ($killed) { Write-Host " Cerrado: $_" }
+}
+
+# 2. Desactivar indexacion de Windows Search (consume disco en background)
+Write-Host "[2/5] Deteniendo Windows Search Indexer..." -ForegroundColor Yellow
+Stop-Service -Name "WSearch" -Force -ErrorAction SilentlyContinue
+Write-Host " OK"
+
+# 3. Plan de energia: Alto rendimiento
+Write-Host "[3/5] Activando plan de energia 'Alto rendimiento'..." -ForegroundColor Yellow
+powercfg /setactive 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c 2>$null
+Write-Host " OK"
+
+# 4. Evitar suspension
+Write-Host "[4/5] Desactivando suspension..." -ForegroundColor Yellow
+powercfg /change standby-timeout-ac 0
+powercfg /change monitor-timeout-ac 0
+Write-Host " OK"
+
+# 5. Lanzar training con prioridad AboveNormal
+Write-Host "[5/5] Lanzando training..." -ForegroundColor Yellow
+$python = "C:\Users\lucas\AppData\Local\Programs\Python\Python313\python.exe"
+$trainArgs = "-X utf8 -u scripts/train_v3.py --checkpoint checkpoints/v3_train.pt --lr 1e-5 --batch-size 2 --seq-len 512 --guardar-cada 500"
+
+$proc = Start-Process -PassThru -NoNewWindow -FilePath $python `
+ -ArgumentList $trainArgs `
+ -RedirectStandardOutput "scripts/train_v3_log.txt" `
+ -RedirectStandardError "scripts/train_v3_err.txt"
+
+Start-Sleep -Seconds 3
+$proc.PriorityClass = "AboveNormal"
+
+Write-Host ""
+Write-Host "===================================================" -ForegroundColor Green
+Write-Host " TRAINING CORRIENDO" -ForegroundColor Green
+Write-Host " PID : $($proc.Id)" -ForegroundColor Green
+Write-Host " Prioridad : AboveNormal" -ForegroundColor Green
+Write-Host " Checkpoint: checkpoints/v3_train.pt" -ForegroundColor Green
+Write-Host " Log : scripts/train_v3_err.txt" -ForegroundColor Green
+Write-Host "===================================================" -ForegroundColor Green
+Write-Host ""
+Write-Host "Para monitorear:" -ForegroundColor DarkGray
+Write-Host " Get-Content scripts/train_v3_err.txt -Wait -Tail 10" -ForegroundColor DarkGray
+Write-Host ""
diff --git a/scripts/monitor.py b/scripts/monitor.py
new file mode 100644
index 0000000000000000000000000000000000000000..10fe495bdf453e14095fc37f2e00ac7028c452a6
--- /dev/null
+++ b/scripts/monitor.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""Monitor en vivo del entrenamiento PAMPAr.
+
+Uso:
+ python scripts/monitor.py
+ python scripts/monitor.py --cada 10 # actualizar cada 10s
+"""
+import argparse
+import json
+import os
+import time
+from pathlib import Path
+
+# Colores ANSI
+B = "\033[1m"
+AZ = "\033[94m"
+VE = "\033[92m"
+AM = "\033[93m"
+RO = "\033[91m"
+GR = "\033[90m"
+RE = "\033[0m"
+
+
+def limpiar():
+ os.system("cls" if os.name == "nt" else "clear")
+
+
+def leer_estado(ruta: Path) -> dict:
+ try:
+ return json.loads(ruta.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+
+
+def checkpoint_info(ruta: Path) -> tuple[str, str]:
+ if not ruta.exists():
+ return "no encontrado", "?"
+ stat = ruta.stat()
+ ts = time.strftime("%H:%M:%S", time.localtime(stat.st_mtime))
+ mb = stat.st_size / 1e6
+ return f"guardado a las {ts} ({mb:.1f} MB)", ts
+
+
+def render(estado_path: Path, ckpt_path: Path) -> None:
+ d = leer_estado(estado_path)
+ temas = d.get("temas", {})
+ nivel = d.get("nivel_actual", "?")
+ sesiones_total = d.get("sesiones_totales", 0)
+ n_dominados = d.get("temas_dominados", 0) # es un int
+ paso = d.get("paso_global", "?")
+
+ ck_str, _ = checkpoint_info(ckpt_path)
+
+ ahora = time.strftime("%H:%M:%S")
+ print(f"{B}{AZ}═══ PAMPAr Monitor — {ahora} ══════════════════════════════{RE}")
+ paso_fmt = f"{paso:,}" if isinstance(paso, int) else str(paso)
+ print(f" Paso global : {B}{paso_fmt}{RE} Nivel: {nivel}/6 Sesiones: {sesiones_total}")
+ print(f" Checkpoint : {GR}{ck_str}{RE}")
+ print()
+
+ if not temas:
+ print(f" {AM}Sin temas cargados aún{RE}")
+ return
+
+ # Temas con mayor loss (más difíciles = más trabajo pendiente)
+ dominados = [(k, v) for k, v in temas.items() if v.get("dominado")]
+ activos = [(k, v) for k, v in temas.items() if not v.get("dominado")]
+ activos.sort(key=lambda x: (x[1].get("historial_loss") or [0])[-1], reverse=True)
+
+ print(f" {B}Temas con mayor loss (más a trabajar):{RE}")
+ for tema, v in activos[:12]:
+ hist = v.get("historial_loss") or [0]
+ loss = hist[-1]
+ n = v.get("nivel_dificultad", 0)
+ ses = v.get("n_sesiones", 0)
+ cur = v.get("curiosidad", 0)
+ bar = "█" * min(int(loss), 8) + "░" * max(0, 8 - int(loss))
+ color = RO if loss > 3 else AM if loss > 1 else VE
+ print(f" {color}{bar}{RE} {tema:<30} loss={loss:.3f} cur={cur:.3f} sesiones={ses}")
+
+ print()
+ n_dom = n_dominados if n_dominados else len(dominados)
+ n_tot = len(temas)
+ pct = n_dom / n_tot * 100 if n_tot else 0
+ bloques = 30
+ llenos = int(pct / 100 * bloques)
+ barra_dom = VE + "█" * llenos + RE + GR + "░" * (bloques - llenos) + RE
+ print(f" Dominados: {VE}{n_dom}/{n_tot}{RE} [{barra_dom}] {pct:.0f}%")
+
+ if dominados:
+ print(f" {GR}Temas dominados: {', '.join(k for k, _ in dominados[:8])}{RE}")
+
+ print(f"\n{GR} (Ctrl+C para salir){RE}")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--cada", type=int, default=5, help="Segundos entre actualizaciones")
+ parser.add_argument(
+ "--estado", type=Path,
+ default=Path("checkpoints/curiosidad_estado.json"),
+ )
+ parser.add_argument(
+ "--checkpoint", type=Path,
+ default=Path("checkpoints/pampar_v2_best.pt"),
+ )
+ args = parser.parse_args()
+
+ try:
+ first = True
+ while True:
+ if not first:
+ limpiar()
+ first = False
+ render(args.estado, args.checkpoint)
+ if args.cada <= 0:
+ break
+ time.sleep(args.cada)
+ except KeyboardInterrupt:
+ print("\nMonitor detenido.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/monitor_and_eval.ps1 b/scripts/monitor_and_eval.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..a89791e934d5feace50d3274e2f39ee86722931d
--- /dev/null
+++ b/scripts/monitor_and_eval.ps1
@@ -0,0 +1,60 @@
+# monitor_and_eval.ps1 — Monitorea el training y lanza evaluación al terminar
+$ErrorActionPreference = "Continue"
+$projectDir = "C:\Users\lucas\Documents\Be Web\Lunux-AI\PAMPAr-Coder"
+$python = "C:\Users\lucas\AppData\Local\Programs\Python\Python313\python.exe"
+$targetPid = 8456
+
+Set-Location $projectDir
+Write-Host "=== Monitor de Training PAMPAr V3 ===" -ForegroundColor Cyan
+Write-Host "Monitoreando PID $targetPid..."
+Write-Host ""
+
+# --- Fase 1: Esperar a que termine el training ---
+while ($true) {
+ $proc = Get-Process -Id $targetPid -ErrorAction SilentlyContinue
+ if (-not $proc) {
+ Write-Host ""
+ Write-Host ">>> Training TERMINADO <<<" -ForegroundColor Green
+ break
+ }
+
+ # Mostrar progreso basado en checkpoints
+ $latest = Get-ChildItem "$projectDir\checkpoints\v3_pretrain*" |
+ Sort-Object LastWriteTime -Descending |
+ Select-Object -First 1
+ $cpuMin = [math]::Round($proc.CPU / 60, 1)
+ $wsMB = [math]::Round($proc.WS / 1MB)
+ $now = Get-Date -Format "HH:mm:ss"
+ Write-Host "[$now] Training activo | CPU=${cpuMin}min | RAM=${wsMB}MB | Ultimo ckpt: $($latest.Name) ($($latest.LastWriteTime.ToString('HH:mm:ss')))"
+
+ Start-Sleep -Seconds 60
+}
+
+Write-Host ""
+Write-Host "=============================================" -ForegroundColor Cyan
+Write-Host " FASE 2: Evaluacion de generacion de codigo" -ForegroundColor Cyan
+Write-Host "=============================================" -ForegroundColor Cyan
+Write-Host ""
+
+# --- Fase 2: Ejecutar eval_v3.py ---
+Write-Host "Ejecutando eval_v3.py..." -ForegroundColor Yellow
+& $python -X utf8 scripts/eval_v3.py --checkpoint checkpoints/v3_pretrain_best.pt --verbose 2>&1 | Tee-Object -FilePath "$projectDir\eval_pretrain_results.txt"
+
+Write-Host ""
+Write-Host "=============================================" -ForegroundColor Cyan
+Write-Host " FASE 3: Brain Scanner Suite (GhidraProbe)" -ForegroundColor Cyan
+Write-Host "=============================================" -ForegroundColor Cyan
+Write-Host ""
+
+# --- Fase 3: Ejecutar brain_scanner --suite ---
+Write-Host "Ejecutando brain_scanner.py --suite..." -ForegroundColor Yellow
+& $python -X utf8 scripts/brain_scanner.py --suite --checkpoint checkpoints/v3_pretrain_best.pt 2>&1 | Tee-Object -FilePath "$projectDir\brain_scanner_pretrain_results.txt"
+
+Write-Host ""
+Write-Host "=============================================" -ForegroundColor Green
+Write-Host " EVALUACION COMPLETA" -ForegroundColor Green
+Write-Host "=============================================" -ForegroundColor Green
+Write-Host ""
+Write-Host "Resultados guardados en:"
+Write-Host " - eval_pretrain_results.txt"
+Write-Host " - brain_scanner_pretrain_results.txt"
diff --git a/scripts/monitor_training.py b/scripts/monitor_training.py
new file mode 100644
index 0000000000000000000000000000000000000000..31e8d23aed5d7299e94acf160a35d1f2d9f216cd
--- /dev/null
+++ b/scripts/monitor_training.py
@@ -0,0 +1,87 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Monitor de entrenamiento en tiempo real.
+
+Muestra el progreso del entrenamiento background sin interferir.
+Ejecutar en otra terminal mientras entrena.
+"""
+
+import json
+import time
+import argparse
+from pathlib import Path
+from datetime import datetime
+
+
+def format_time(hours: float) -> str:
+ """Formatea horas a string legible."""
+ if hours < 1:
+ return f"{hours * 60:.0f} min"
+ elif hours < 24:
+ return f"{hours:.1f} h"
+ else:
+ return f"{hours / 24:.1f} dias"
+
+
+def monitor(checkpoint_dir: Path, refresh: float = 2.0):
+ """Monitorea el entrenamiento."""
+ status_file = checkpoint_dir / "training_status.json"
+
+ print("\n" + "="*50)
+ print(" PAMPAr-Coder - Monitor de Entrenamiento")
+ print("="*50)
+ print(f" Status file: {status_file}")
+ print(" Presiona Ctrl+C para salir")
+ print("="*50 + "\n")
+
+ last_step = 0
+
+ while True:
+ try:
+ if status_file.exists():
+ with open(status_file, 'r') as f:
+ status = json.load(f)
+
+ running = "Entrenando" if status.get('running', False) else "Pausado"
+ step = status.get('global_step', 0)
+ loss = status.get('loss', 0)
+ speed = status.get('speed', 0)
+ elapsed = status.get('elapsed_hours', 0)
+ epoch = status.get('epoch', 0)
+
+ # Calcular steps/min
+ steps_diff = step - last_step
+ steps_per_min = steps_diff * (60 / refresh) if steps_diff > 0 else 0
+ last_step = step
+
+ # Limpiar línea y mostrar
+ print(f"\r Step: {step:,} | Epoch: {epoch} | Loss: {loss:.4f} | "
+ f"Speed: {speed:.1f} s/s | Time: {format_time(elapsed)} | "
+ f"Status: {running} ", end="", flush=True)
+ else:
+ print(f"\r Esperando inicio del entrenamiento... ", end="", flush=True)
+
+ time.sleep(refresh)
+
+ except KeyboardInterrupt:
+ print("\n\n Monitor detenido.")
+ break
+ except Exception as e:
+ print(f"\r Error leyendo status: {e} ", end="", flush=True)
+ time.sleep(refresh)
+
+
+def main():
+ parser = argparse.ArgumentParser(description='Monitor de entrenamiento')
+ parser.add_argument('--checkpoint-dir', type=str, default='checkpoints',
+ help='Directorio de checkpoints')
+ parser.add_argument('--refresh', type=float, default=2.0,
+ help='Intervalo de refresh en segundos')
+ args = parser.parse_args()
+
+ monitor(Path(args.checkpoint_dir), args.refresh)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/neuro_trainer.py b/scripts/neuro_trainer.py
new file mode 100644
index 0000000000000000000000000000000000000000..4a40c56be31fcc30c0261d24ed29f7674418015b
--- /dev/null
+++ b/scripts/neuro_trainer.py
@@ -0,0 +1,936 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+PAMPAr Neuro-Trainer — Entrenamiento correctivo con losses auxiliares.
+
+Diagnóstico del Brain Scanner reveló 3 problemas en PamparV3:
+ 1. Tálamo no diferencia — TODOS los tokens van a SINTAXIS (~65-100%)
+ 2. Early Exit nunca activa — confianza máxima 48% (necesita 90%)
+ 3. Streams sub-utilizados — la especialización interna no se aprovecha
+
+Este entrenador agrega 3 losses auxiliares para corregir sin cambiar la arquitectura:
+ - loss_routing: Supervisión directa con LLAVES → CE contra territorio correcto
+ - loss_exit: Calibra la confianza del Early Exit
+ - loss_balance: Previene streams muertos
+
+la loss_routing usa clasificar_token() para determinar el territorio correcto
+de cada token, y entrena el routing con CE → gradiente fuerte y direccional
+que rompe la simetría del routing uniforme.
+
+Uso:
+ python scripts/neuro_trainer.py
+ python scripts/neuro_trainer.py --data data/sft_v5.jsonl --pasos 500
+ python scripts/neuro_trainer.py --alpha-diff 0.2 --alpha-exit 0.1
+"""
+
+from __future__ import annotations
+
+import argparse
+import dataclasses
+import json
+import logging
+import math
+import random
+import sys
+import time
+from collections import deque
+from pathlib import Path
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+import torch.nn.utils as nn_utils
+import sentencepiece as spm
+
+# Proyecto
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+
+from pampar.coder.v3 import PamparV3, ConfigV3, PRESET_V3
+from pampar.coder.v3.talamo import TalamoInicial
+from pampar.coder.v3.llaves import clasificar_token
+from pampar.coder.v3.zonas import ZONA_TERRITORIO
+
+logger = logging.getLogger("neuro_trainer")
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ datefmt="%H:%M:%S",
+)
+
+STREAM_NAMES = ["SINTAXIS", "SEMANTICA", "LOGICO", "ESTRUCTURAL"]
+_MARCADORES = ["### Solution:", "### Protocolo:"]
+
+
+# ─────────────────────────────────────────────────────────────────
+# Data Loading
+# ─────────────────────────────────────────────────────────────────
+
+def _cargar_datos(ruta: Path) -> list[str]:
+ """Carga textos desde JSONL."""
+ textos: list[str] = []
+ with open(ruta, "r", encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line:
+ continue
+ obj = json.loads(line)
+ texto = obj.get("text", "")
+ if texto:
+ textos.append(texto)
+ logger.info("Cargados %d ejemplos desde %s", len(textos), ruta.name)
+ return textos
+
+
+def _tokenizar_con_mascara(
+ textos: list[str],
+ tokenizer: spm.SentencePieceProcessor,
+ max_len: int,
+) -> list[tuple[list[int], list[bool]]]:
+ """Tokeniza y crea máscara: loss solo en porción Solution."""
+ chunks: list[tuple[list[int], list[bool]]] = []
+ for texto in textos:
+ ids_full = tokenizer.Encode(texto)
+ if len(ids_full) < 8:
+ continue
+ ids_full = ids_full[: max_len + 1]
+
+ n_prefijo = len(ids_full)
+ for marcador in _MARCADORES:
+ pos = texto.find(marcador)
+ if pos >= 0:
+ ids_pre = tokenizer.Encode(texto[: pos + len(marcador)])
+ n_prefijo = min(len(ids_pre), len(ids_full))
+ break
+
+ mascara = [False] * n_prefijo + [True] * (len(ids_full) - n_prefijo)
+ chunks.append((ids_full, mascara))
+ return chunks
+
+
+def _hacer_batch(
+ chunks: list[tuple[list[int], list[bool]]],
+ indices: list[int],
+ device: torch.device,
+ max_seq_len: int,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """Batch con padding y máscara de loss."""
+ sels = [chunks[i] for i in indices]
+ max_len = min(max(len(ids) for ids, _ in sels), max_seq_len + 1)
+ padded_ids: list[list[int]] = []
+ padded_mask: list[list[bool]] = []
+ for ids, mask in sels:
+ t = ids[:max_len]
+ m = mask[:max_len]
+ pad_len = max_len - len(t)
+ padded_ids.append(t + [0] * pad_len)
+ padded_mask.append(m + [False] * pad_len)
+ tokens = torch.tensor(padded_ids, dtype=torch.long, device=device)
+ mascara = torch.tensor(padded_mask, dtype=torch.bool, device=device)
+ return tokens, mascara
+
+
+# ─────────────────────────────────────────────────────────────────
+# Forward Instrumentado (con gradientes, sin checkpointing)
+# ─────────────────────────────────────────────────────────────────
+
+def forward_neuro(
+ model: PamparV3,
+ input_ids: torch.Tensor,
+) -> tuple[torch.Tensor, dict]:
+ """
+ Forward que captura datos por nivel para las losses auxiliares.
+
+ NO usa gradient checkpointing → guarda todas las activaciones.
+ Usar con seq_len cortas y batch=1 para caber en 4GB VRAM.
+
+ Returns:
+ logits [B, L, V], dict con datos por nivel
+ """
+ config = model.config
+ n_streams = config.n_streams
+
+ x = model.emb_drop(model.tok_emb(input_ids))
+ terr_acts, zona_acts_n0 = model.talamo(x, input_ids)
+ streams = [x.clone() for _ in range(n_streams)]
+
+ all_terr_acts: list[torch.Tensor] = [terr_acts]
+ all_conf_tensors: list[torch.Tensor] = []
+ all_stream_norms: list[torch.Tensor] = []
+ all_x_out: list[torch.Tensor] = []
+
+ for i, nivel in enumerate(model.niveles):
+ streams, terr_acts, _ = nivel(streams, terr_acts, TalamoInicial.agregar_fn)
+ all_terr_acts.append(terr_acts)
+
+ # Recomputar confianza per-token CON gradientes
+ # (dentro de nivel.forward() se calcula pero se detacha con .item())
+ x_out = sum(
+ streams[t] * terr_acts[:, :, t : t + 1] for t in range(n_streams)
+ )
+ conf = torch.sigmoid(nivel.exit_head(x_out)).squeeze(-1) # [B, L]
+ all_conf_tensors.append(conf)
+ all_x_out.append(x_out)
+
+ # Norma L2 promedio por stream (diferenciable)
+ norms = torch.stack(
+ [streams[t].norm(dim=-1).mean() for t in range(n_streams)]
+ )
+ all_stream_norms.append(norms)
+
+ # Combinación final + logits
+ x_final = model._combinar_streams(streams, terr_acts)
+ x_final = model.norm_f(x_final)
+ logits = model.lm_head(x_final)
+
+ return logits, {
+ "all_terr_acts": all_terr_acts, # [n_levels+1] × [B, L, 4]
+ "all_conf_tensors": all_conf_tensors, # [n_levels] × [B, L]
+ "all_stream_norms": all_stream_norms, # [n_levels] × [4]
+ "all_x_out": all_x_out, # [n_levels] × [B, L, D]
+ "zona_acts_n0": zona_acts_n0, # [B, L, 52] — zone acts from TalamoInicial
+ }
+
+
+# ─────────────────────────────────────────────────────────────────
+# Losses Auxiliares
+# ─────────────────────────────────────────────────────────────────
+
+def _build_territory_table(
+ tokenizer: spm.SentencePieceProcessor,
+) -> torch.Tensor:
+ """
+ Construye lookup table: token_id → territorio target (0-3).
+
+ Usa clasificar_token() de LLAVES para determinar la zona de cada
+ token del vocabulario, y ZONA_TERRITORIO para mapear a territorio.
+ Se ejecuta UNA vez al inicio (~48K tokens, <2s).
+ """
+ vocab_size = tokenizer.GetPieceSize()
+ table = torch.zeros(vocab_size, dtype=torch.long)
+ for token_id in range(vocab_size):
+ piece = tokenizer.IdToPiece(token_id)
+ zona, _conf = clasificar_token(piece)
+ table[token_id] = ZONA_TERRITORIO[zona].value
+ return table
+
+
+def _build_zone_table(
+ tokenizer: spm.SentencePieceProcessor,
+) -> torch.Tensor:
+ """Lookup table: token_id → zone index (0-51) para loss a nivel de zona."""
+ vocab_size = tokenizer.GetPieceSize()
+ table = torch.zeros(vocab_size, dtype=torch.long)
+ for token_id in range(vocab_size):
+ piece = tokenizer.IdToPiece(token_id)
+ zona, _conf = clasificar_token(piece)
+ table[token_id] = zona.value - 1 # 0-indexed (0-51)
+ return table
+
+
+def calcular_loss_talamo(
+ all_terr_acts: list[torch.Tensor],
+ zona_acts_n0: torch.Tensor,
+ input_ids: torch.Tensor,
+ territory_table: torch.Tensor,
+ zone_table: torch.Tensor,
+) -> torch.Tensor:
+ """
+ Loss enfocada en TalamoInicial: CE a nivel de zona + CE a nivel de territorio.
+
+ La zona-level CE bypasses la dilución del MEAN en agregar_zonas_a_territorios,
+ dando gradiente directo a attn_proj y context_conv para activar las zonas
+ correctas. Sin esto, el gradiente a través de MEAN(15 zonas) es ~0.013x.
+ """
+ device = input_ids.device
+
+ # CE sobre territorio en N0 (4-class)
+ targets_terr = territory_table.to(device)[input_ids]
+ terr_acts_n0 = all_terr_acts[0]
+ loss_terr = F.cross_entropy(
+ terr_acts_n0.reshape(-1, 4), targets_terr.reshape(-1)
+ )
+
+ # CE sobre zonas en N0 (52-class) — bypasses MEAN dilution
+ targets_zona = zone_table.to(device)[input_ids]
+ loss_zona = F.cross_entropy(
+ zona_acts_n0.reshape(-1, 52), targets_zona.reshape(-1)
+ )
+
+ return loss_terr + 2.0 * loss_zona
+
+
+def calcular_loss_routing(
+ all_terr_acts: list[torch.Tensor],
+ input_ids: torch.Tensor,
+ territory_table: torch.Tensor,
+) -> torch.Tensor:
+ """
+ Supervised routing loss: CE contra el territorio correcto de LLAVES.
+
+ Cada token tiene un territorio correcto determinado por clasificar_token().
+ Usa class weights inversamente proporcionales a la frecuencia para compensar
+ el desbalance masivo SEMA=47751 vs SINT=169 vs LOGI=72 vs ESTR=8.
+ Sin esto, el gradiente para SINT/LOGI tokens es demasiado débil.
+ """
+ device = input_ids.device
+ # SINT=169, SEMA=47751, LOGI=72, ESTR=8 → weights= sqrt(total/count), capped at 10
+ weights = torch.tensor(
+ [min(10.0, (48000 / 169) ** 0.5), # SINT: ~16.9 → 10
+ 1.0, # SEMA: ~1.0
+ min(10.0, (48000 / 72) ** 0.5), # LOGI: ~25.8 → 10
+ min(10.0, (48000 / 8) ** 0.5)], # ESTR: ~77.5 → 10
+ device=device,
+ dtype=torch.float32,
+ )
+
+ targets = territory_table.to(device)[input_ids] # [B, L]
+
+ total = torch.tensor(0.0, device=device)
+ for ta in all_terr_acts:
+ L_ta = ta.shape[1]
+ t = targets[:, :L_ta]
+ total = total + F.cross_entropy(
+ ta.reshape(-1, 4), t.reshape(-1), weight=weights
+ )
+ return total / len(all_terr_acts)
+
+
+def calcular_loss_exit(
+ all_conf_tensors: list[torch.Tensor],
+ all_intermediate_correct: list[torch.Tensor],
+ loss_mask: torch.Tensor,
+) -> torch.Tensor:
+ """
+ Loss de calibración de Early Exit en TODOS los niveles.
+
+ Para cada nivel: BCE entre su confianza y si predice correctamente
+ (usando el LM head compartido en representaciones intermedias).
+ Niveles profundos tienen más peso (predicen mejor).
+ """
+ device = all_conf_tensors[0].device
+ total = torch.tensor(0.0, device=device)
+ n_levels = len(all_conf_tensors)
+ valid = loss_mask.float()
+ n_valid = valid.sum().clamp(min=1)
+
+ # — Calibración en CADA nivel (peso creciente con profundidad) —
+ for i in range(n_levels):
+ conf = all_conf_tensors[i] # [B, L]
+ correct = all_intermediate_correct[i] # [B, L]
+ weight = (i + 1) / n_levels # 0.2, 0.4, 0.6, 0.8, 1.0
+ total = total + weight * F.binary_cross_entropy(
+ conf.clamp(1e-7, 1 - 1e-7) * valid,
+ correct * valid,
+ reduction="sum",
+ ) / n_valid
+
+ # — Monotonía: confianza debe subir con profundidad —
+ for i in range(1, n_levels):
+ violation = all_conf_tensors[i - 1] - all_conf_tensors[i]
+ total = total + F.relu(violation).mean()
+
+ return total
+
+
+def calcular_loss_balance(all_stream_norms: list[torch.Tensor]) -> torch.Tensor:
+ """
+ Loss de utilización de streams.
+
+ Penaliza el stream con menor norma → previene streams muertos.
+ Todos los streams deben contribuir, no solo SINTAXIS.
+ """
+ total = torch.tensor(0.0, device=all_stream_norms[0].device)
+ for norms in all_stream_norms:
+ min_norm = norms.min()
+ total = total - torch.log(min_norm.clamp(min=1e-8))
+ return total / len(all_stream_norms)
+
+
+def calcular_loss_norm(
+ all_stream_norms: list[torch.Tensor],
+ max_norms: list[float],
+) -> torch.Tensor:
+ """
+ Soft norm penalty: penaliza streams cuya norma supera el max_norm por nivel.
+
+ Enseña al modelo a mantener normas controladas SIN hard clamp.
+ max_norms[i] = límite suave del nivel i (50, 100, 200, 400, 800).
+ """
+ total = torch.tensor(0.0, device=all_stream_norms[0].device)
+ for i, norms in enumerate(all_stream_norms):
+ limit = max_norms[i] if i < len(max_norms) else max_norms[-1]
+ excess = F.relu(norms - limit) # [n_streams]
+ total = total + (excess ** 2).mean()
+ return total / len(all_stream_norms)
+
+
+# ─────────────────────────────────────────────────────────────────
+# Training Step
+# ─────────────────────────────────────────────────────────────────
+
+def paso_neuro(
+ modelo: PamparV3,
+ optimizer: torch.optim.Optimizer,
+ tokens: torch.Tensor,
+ mascara: torch.Tensor,
+ max_grad_norm: float,
+ alpha_diff: float,
+ alpha_exit: float,
+ alpha_balance: float,
+ alpha_norm: float,
+ territory_table: torch.Tensor,
+ warmup_factor: float = 1.0,
+) -> dict[str, float]:
+ """Un paso de entrenamiento con CE + 4 losses auxiliares."""
+ modelo.train()
+ optimizer.zero_grad(set_to_none=True)
+
+ input_ids = tokens[:, :-1]
+ targets = tokens[:, 1:]
+ loss_mask = mascara[:, 1:]
+
+ # Forward instrumentado (sin checkpointing)
+ logits, neuro_info = forward_neuro(modelo, input_ids)
+
+ B, T, V = logits.shape
+
+ # CE Loss (solo en solución)
+ targets_masked = targets.masked_fill(~loss_mask, -100)
+ loss_ce = F.cross_entropy(
+ logits.reshape(B * T, V),
+ targets_masked.reshape(B * T),
+ ignore_index=-100,
+ )
+
+ # Losses auxiliares (rampean con warmup_factor)
+ loss_diff = calcular_loss_routing(
+ neuro_info["all_terr_acts"], input_ids, territory_table
+ )
+
+ # Intermediate correctness para calibrar exit en TODOS los niveles
+ with torch.no_grad():
+ all_intermediate_correct: list[torch.Tensor] = []
+ for x_out in neuro_info["all_x_out"]:
+ inter_logits = modelo.lm_head(modelo.norm_f(x_out))
+ inter_pred = inter_logits.argmax(dim=-1)
+ correct = (inter_pred == targets_masked).float()
+ all_intermediate_correct.append(correct)
+
+ loss_exit = calcular_loss_exit(
+ neuro_info["all_conf_tensors"], all_intermediate_correct, loss_mask
+ )
+ loss_balance = calcular_loss_balance(neuro_info["all_stream_norms"])
+
+ # Norm penalty: enseña al modelo a mantener normas controladas
+ max_norms = [50.0 * (2.0 ** i) for i in range(len(neuro_info["all_stream_norms"]))]
+ loss_norm = calcular_loss_norm(neuro_info["all_stream_norms"], max_norms)
+
+ wf = warmup_factor
+ loss = (
+ loss_ce
+ + wf * alpha_diff * loss_diff
+ + wf * alpha_exit * loss_exit
+ + wf * alpha_balance * loss_balance
+ + wf * alpha_norm * loss_norm
+ )
+
+ if loss.isnan() or loss.isinf():
+ logger.warning("Loss inestable — skipping step")
+ return {"ce": 0.0, "diff": 0.0, "exit": 0.0, "balance": 0.0, "norm": 0.0, "total": 0.0}
+
+ loss.backward()
+ nn_utils.clip_grad_norm_(modelo.parameters(), max_grad_norm)
+ optimizer.step()
+
+ return {
+ "ce": float(loss_ce.detach()),
+ "diff": float(loss_diff.detach()),
+ "exit": float(loss_exit.detach()),
+ "balance": float(loss_balance.detach()),
+ "norm": float(loss_norm.detach()),
+ "total": float(loss.detach()),
+ }
+
+
+# ─────────────────────────────────────────────────────────────────
+# Diagnóstico Periódico
+# ─────────────────────────────────────────────────────────────────
+
+@torch.no_grad()
+def diagnostico(
+ modelo: PamparV3,
+ tokenizer: spm.SentencePieceProcessor,
+ device: torch.device,
+ territory_table: torch.Tensor | None = None,
+) -> str:
+ """Mini-scan con frase de test para ver si las losses están funcionando."""
+ modelo.eval()
+
+ test_code = "def fibonacci(n):"
+ ids = tokenizer.Encode(test_code)
+ input_ids = torch.tensor([ids], device=device)
+
+ _, info = forward_neuro(modelo, input_ids)
+
+ lines = [" ── Diagnóstico ──"]
+
+ # Routing diversity: std de terr_acts por nivel
+ for lvl, ta in enumerate(info["all_terr_acts"]):
+ std_mean = torch.std(ta, dim=-1).mean().item()
+ lines.append(f" Nivel {lvl} routing std: {std_mean:.4f}")
+
+ # Confianza por nivel
+ confs = [c.mean().item() for c in info["all_conf_tensors"]]
+ conf_str = " → ".join(f"{c:.3f}" for c in confs)
+ lines.append(f" Confianza: {conf_str}")
+
+ # Stream balance: normas del último nivel
+ last_norms = info["all_stream_norms"][-1]
+ norm_str = " | ".join(
+ f"{STREAM_NAMES[t][:4]}={last_norms[t]:.1f}" for t in range(4)
+ )
+ lines.append(f" Streams: {norm_str}")
+
+ # Routing dominante (actual)
+ final_ta = info["all_terr_acts"][-1] # [1, L, 4]
+ dom_counts = [0, 0, 0, 0]
+ for i in range(final_ta.shape[1]):
+ dom = final_ta[0, i].argmax().item()
+ dom_counts[dom] += 1
+ dom_str = " | ".join(
+ f"{STREAM_NAMES[t][:4]}={dom_counts[t]}" for t in range(4)
+ )
+ lines.append(f" Actual: {dom_str}")
+
+ # Routing esperado (LLAVES target)
+ if territory_table is not None:
+ expected = territory_table[input_ids[0].cpu()]
+ exp_counts = [0, 0, 0, 0]
+ for e in expected:
+ exp_counts[e.item()] += 1
+ exp_str = " | ".join(
+ f"{STREAM_NAMES[t][:4]}={exp_counts[t]}" for t in range(4)
+ )
+ lines.append(f" Esperado: {exp_str}")
+
+ modelo.train()
+ return "\n".join(lines)
+
+
+# ─────────────────────────────────────────────────────────────────
+# Utils
+# ─────────────────────────────────────────────────────────────────
+
+def _cosine_lr(
+ paso: int, warmup: int, total: int, lr_max: float, lr_min: float
+) -> float:
+ if paso < warmup:
+ return lr_max * (paso + 1) / warmup
+ progreso = (paso - warmup) / max(1, total - warmup)
+ return lr_min + 0.5 * (lr_max - lr_min) * (1 + math.cos(math.pi * progreso))
+
+
+def _guardar(
+ ruta: Path, modelo: PamparV3, optimizer: torch.optim.Optimizer, paso: int
+) -> None:
+ ruta.parent.mkdir(parents=True, exist_ok=True)
+ torch.save(
+ {
+ "modelo": modelo.state_dict(),
+ "optimizer": optimizer.state_dict(),
+ "paso_global": paso,
+ "config": dataclasses.asdict(modelo.config),
+ "tipo": "neuro_trainer",
+ },
+ ruta,
+ )
+ logger.info("Checkpoint guardado → %s (paso %d)", ruta.name, paso)
+
+
+# ─────────────────────────────────────────────────────────────────
+# CLI + main
+# ─────────────────────────────────────────────────────────────────
+
+def _parse_args() -> argparse.Namespace:
+ p = argparse.ArgumentParser(
+ description="PAMPAr Neuro-Trainer: corrige routing, exit y balance",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ p.add_argument(
+ "--checkpoint-in",
+ type=Path,
+ default=ROOT / "checkpoints" / "v3_sft_v8.pt",
+ )
+ p.add_argument(
+ "--checkpoint-out",
+ type=Path,
+ default=ROOT / "checkpoints" / "v3_neuro_v1.pt",
+ )
+ p.add_argument(
+ "--tokenizer",
+ type=Path,
+ default=ROOT / "data" / "tokenizer" / "pampar_48k.model",
+ )
+ p.add_argument("--data", type=Path, default=ROOT / "data" / "sft_v5.jsonl")
+
+ p.add_argument("--lr", type=float, default=3e-7,
+ help="LR para params principales (muy bajo, proteger CE)")
+ p.add_argument("--lr-min", type=float, default=3e-8)
+ p.add_argument("--lr-routing", type=float, default=1e-3,
+ help="LR para params de routing (talamo, lateral.scale)")
+ p.add_argument("--lr-routing-min", type=float, default=1e-4)
+ p.add_argument("--warmup", type=int, default=30)
+ p.add_argument("--max-pasos", type=int, default=1000)
+ p.add_argument("--epochs", type=int, default=20)
+ p.add_argument("--batch-size", type=int, default=1,
+ help="Batch=1 (4GB VRAM, sin checkpointing)")
+ p.add_argument("--seq-len", type=int, default=256,
+ help="Secuencia corta para caber sin checkpointing")
+ p.add_argument("--max-grad-norm", type=float, default=1.0)
+ p.add_argument("--guardar-cada", type=int, default=200)
+
+ # Pesos de losses auxiliares
+ p.add_argument("--alpha-diff", type=float, default=0.5,
+ help="Peso loss_routing (CE supervisado contra LLAVES)")
+ p.add_argument("--alpha-exit", type=float, default=0.0,
+ help="Peso loss_exit (desactivado hasta resolver routing)")
+ p.add_argument("--alpha-balance", type=float, default=0.05,
+ help="Peso loss_balance (utilizaci\u00f3n streams)")
+ p.add_argument("--alpha-norm", type=float, default=0.01,
+ help="Peso loss_norm (penaliza normas excesivas por nivel)")
+ p.add_argument("--aux-warmup", type=int, default=50,
+ help="Pasos para rampear aux losses de 0→1")
+
+ # Modo Focus-Talamo (Round 6)
+ p.add_argument("--focus-talamo", action="store_true",
+ help="Solo entrena TalamoInicial (attn_proj, conv, gate)")
+ p.add_argument("--lr-talamo", type=float, default=5e-3,
+ help="LR para TalamoInicial en modo focus-talamo")
+
+ # Norm clamping en training
+ p.add_argument("--train-norm-clamp", action="store_true",
+ help="Activa norm clamping durante training (regulariza normas)")
+
+ p.add_argument("--device", type=str, default="auto")
+ p.add_argument("--seed", type=int, default=42)
+ p.add_argument("--diag-cada", type=int, default=50,
+ help="Cada cuántos pasos mostrar diagnóstico")
+
+ return p.parse_args()
+
+
+def main() -> None:
+ args = _parse_args()
+ random.seed(args.seed)
+ torch.manual_seed(args.seed)
+
+ device = torch.device(
+ "cuda"
+ if args.device == "auto" and torch.cuda.is_available()
+ else args.device
+ if args.device != "auto"
+ else "cpu"
+ )
+ logger.info("Device: %s", device)
+ if device.type == "cuda":
+ torch.cuda.manual_seed(args.seed)
+ logger.info(
+ "GPU: %s (%.1f GiB)",
+ torch.cuda.get_device_name(0),
+ torch.cuda.get_device_properties(0).total_memory / 1e9,
+ )
+
+ # Tokenizer
+ tok = spm.SentencePieceProcessor()
+ tok.Load(str(args.tokenizer))
+ logger.info("Tokenizer vocab=%d", tok.GetPieceSize())
+
+ # Modelo
+ if not args.checkpoint_in.exists():
+ logger.error("Checkpoint no encontrado: %s", args.checkpoint_in)
+ sys.exit(1)
+
+ payload = torch.load(
+ args.checkpoint_in, map_location=device, weights_only=False
+ )
+ config = ConfigV3(**payload["config"]) if "config" in payload else PRESET_V3
+ modelo = PamparV3(config).to(device)
+ modelo.load_state_dict(payload["modelo"])
+ modelo.registrar_tokenizer(tok)
+ logger.info(
+ "Cargado '%s' (tipo: %s)", args.checkpoint_in.name, payload.get("tipo", "?")
+ )
+ del payload
+
+ n_params = sum(p.numel() for p in modelo.parameters() if p.requires_grad)
+ logger.info("PamparV3 %.1fM params", n_params / 1e6)
+
+ # Norm clamping durante training (regulariza normas por nivel)
+ if args.train_norm_clamp:
+ modelo.set_train_norm_clamp(True)
+ logger.info("Norm clamping ACTIVADO durante training")
+
+ # Datos
+ if not args.data.exists():
+ logger.error("Dataset no encontrado: %s", args.data)
+ sys.exit(1)
+
+ textos = _cargar_datos(args.data)
+ chunks = _tokenizar_con_mascara(textos, tok, args.seq_len)
+ logger.info("Chunks tokenizados: %d (seq_len=%d)", len(chunks), args.seq_len)
+ del textos
+
+ pct_solution = sum(sum(m) for _, m in chunks) / max(
+ 1, sum(len(ids) for ids, _ in chunks)
+ )
+ logger.info("Porcentaje tokens en zona Solution: %.1f%%", pct_solution * 100)
+
+ # Territory table para supervised routing loss
+ territory_table = _build_territory_table(tok)
+ counts = [(territory_table == t).sum().item() for t in range(4)]
+ logger.info(
+ "Territory table: SINT=%d SEMA=%d LOGI=%d ESTR=%d (total=%d)",
+ *counts, sum(counts),
+ )
+
+ # Diagnóstico inicial
+ logger.info("─── DIAGNÓSTICO INICIAL ───")
+ print(diagnostico(modelo, tok, device, territory_table))
+
+ # ── MODO FOCUS-TALAMO (Round 6) ──
+ if args.focus_talamo:
+ logger.info("═══ MODO FOCUS-TALAMO: entrenando solo TalamoInicial ═══")
+ zone_table = _build_zone_table(tok)
+
+ # Congelar TODO excepto TalamoInicial
+ for name, p in modelo.named_parameters():
+ if not name.startswith("talamo."):
+ p.requires_grad_(False)
+
+ n_trainable = sum(p.numel() for p in modelo.parameters() if p.requires_grad)
+ logger.info("Params entrenables: %.1fK (TalamoInicial)", n_trainable / 1e3)
+
+ talamo_params = [p for p in modelo.talamo.parameters() if p.requires_grad]
+ opt = torch.optim.AdamW(
+ talamo_params, lr=args.lr_talamo,
+ betas=(0.9, 0.95), weight_decay=0.01,
+ )
+
+ t0 = time.time()
+ losses_buf: deque[float] = deque(maxlen=100)
+ paso = 0
+
+ try:
+ for epoch in range(args.epochs):
+ idx_all = list(range(len(chunks)))
+ random.shuffle(idx_all)
+ logger.info("── Epoch %d/%d ──", epoch + 1, args.epochs)
+
+ for i in range(0, len(idx_all) - args.batch_size + 1, args.batch_size):
+ batch_idx = idx_all[i : i + args.batch_size]
+ tokens, _ = _hacer_batch(chunks, batch_idx, device, args.seq_len)
+
+ opt.zero_grad(set_to_none=True)
+ input_ids = tokens[:, :-1]
+ _, info = forward_neuro(modelo, input_ids)
+
+ loss = calcular_loss_talamo(
+ info["all_terr_acts"], info["zona_acts_n0"],
+ input_ids, territory_table, zone_table,
+ )
+
+ if loss.isnan() or loss.isinf():
+ continue
+
+ loss.backward()
+ nn_utils.clip_grad_norm_(modelo.parameters(), args.max_grad_norm)
+ opt.step()
+
+ losses_buf.append(loss.item())
+ paso += 1
+
+ if paso % 10 == 0:
+ avg = sum(losses_buf) / len(losses_buf)
+ elapsed = time.time() - t0
+ logger.info(
+ "paso %4d/%d | loss=%.4f avg=%.4f (%.1f p/s)",
+ paso, args.max_pasos, loss.item(), avg,
+ paso / elapsed,
+ )
+
+ if paso % args.diag_cada == 0:
+ print(diagnostico(modelo, tok, device, territory_table))
+
+ if paso % args.guardar_cada == 0:
+ _guardar(args.checkpoint_out, modelo, opt, paso)
+
+ if paso >= args.max_pasos:
+ break
+
+ if paso >= args.max_pasos:
+ break
+
+ except KeyboardInterrupt:
+ logger.info("Interrumpido — guardando...")
+
+ _guardar(args.checkpoint_out, modelo, opt, paso)
+ logger.info("─── DIAGNÓSTICO FINAL (TALAMO) ───")
+ print(diagnostico(modelo, tok, device, territory_table))
+
+ elapsed = time.time() - t0
+ avg = sum(losses_buf) / max(1, len(losses_buf))
+ print(f"\n── Focus-Talamo Completado ──")
+ print(f" Pasos: {paso}")
+ print(f" Tiempo: {int(elapsed // 60)}m{int(elapsed % 60):02d}s")
+ print(f" Loss final (avg): {avg:.4f}")
+ print(f" Checkpoint: {args.checkpoint_out}")
+ print(f"\n Verificar con Brain Scanner:")
+ print(
+ f' python -X utf8 scripts/brain_scanner.py --suite --device cuda'
+ f' --checkpoint {args.checkpoint_out}'
+ )
+ return
+
+ # Separar parámetros: routing (LR alto) vs resto (LR bajo)
+ routing_names = {"talamo", "talamo_nivel", "lateral"}
+ routing_params: list[torch.nn.Parameter] = []
+ main_params: list[torch.nn.Parameter] = []
+ for name, p in modelo.named_parameters():
+ parts = name.split(".")
+ if any(rn in parts for rn in routing_names):
+ routing_params.append(p)
+ else:
+ main_params.append(p)
+
+ n_routing = sum(p.numel() for p in routing_params)
+ n_main = sum(p.numel() for p in main_params)
+ logger.info(
+ "Param groups: routing=%.1fK (lr=%.1e) | main=%.1fM (lr=%.1e)",
+ n_routing / 1e3, args.lr_routing,
+ n_main / 1e6, args.lr,
+ )
+
+ optimizer = torch.optim.AdamW(
+ [
+ {"params": main_params, "lr": args.lr},
+ {"params": routing_params, "lr": args.lr_routing},
+ ],
+ betas=(0.9, 0.95),
+ weight_decay=0.01,
+ eps=1e-8,
+ )
+
+ pasos_por_epoch = max(1, len(chunks) // args.batch_size)
+ total_pasos = min(args.max_pasos, args.epochs * pasos_por_epoch)
+ logger.info(
+ "Neuro-Training: %d pasos | %d chunks | alphas: diff=%.3f exit=%.3f balance=%.3f norm=%.3f",
+ total_pasos,
+ len(chunks),
+ args.alpha_diff,
+ args.alpha_exit,
+ args.alpha_balance,
+ args.alpha_norm,
+ )
+
+ paso = 0
+ t0 = time.time()
+ losses_ce: deque[float] = deque(maxlen=100)
+ losses_total: deque[float] = deque(maxlen=100)
+
+ try:
+ for epoch in range(args.epochs):
+ idx = list(range(len(chunks)))
+ random.shuffle(idx)
+ logger.info("── Epoch %d/%d ──", epoch + 1, args.epochs)
+
+ for i in range(0, len(idx) - args.batch_size + 1, args.batch_size):
+ batch_idx = idx[i : i + args.batch_size]
+ tokens, mascara = _hacer_batch(
+ chunks, batch_idx, device, args.seq_len
+ )
+
+ lr_main = _cosine_lr(paso, args.warmup, total_pasos, args.lr, args.lr_min)
+ lr_rout = _cosine_lr(paso, args.warmup, total_pasos, args.lr_routing, args.lr_routing_min)
+ optimizer.param_groups[0]["lr"] = lr_main
+ optimizer.param_groups[1]["lr"] = lr_rout
+
+ wf = min(1.0, paso / max(1, args.aux_warmup))
+
+ loss_dict = paso_neuro(
+ modelo,
+ optimizer,
+ tokens,
+ mascara,
+ args.max_grad_norm,
+ args.alpha_diff,
+ args.alpha_exit,
+ args.alpha_balance,
+ args.alpha_norm,
+ territory_table,
+ warmup_factor=wf,
+ )
+
+ if loss_dict["ce"] > 0:
+ losses_ce.append(loss_dict["ce"])
+ losses_total.append(loss_dict["total"])
+
+ paso += 1
+
+ if paso % 10 == 0:
+ avg_ce = sum(losses_ce) / max(1, len(losses_ce))
+ avg_total = sum(losses_total) / max(1, len(losses_total))
+ elapsed = time.time() - t0
+ logger.info(
+ "paso %4d/%d | CE=%.3f diff=%.3f exit=%.3f bal=%.3f "
+ "norm=%.3f total=%.3f lr_r=%.1e (%.1f p/s)",
+ paso,
+ total_pasos,
+ loss_dict["ce"],
+ loss_dict["diff"],
+ loss_dict["exit"],
+ loss_dict["balance"],
+ loss_dict["norm"],
+ avg_total,
+ lr_rout,
+ paso / elapsed,
+ )
+
+ if paso % args.diag_cada == 0:
+ print(diagnostico(modelo, tok, device, territory_table))
+
+ if paso % args.guardar_cada == 0:
+ _guardar(args.checkpoint_out, modelo, optimizer, paso)
+
+ if paso >= args.max_pasos:
+ break
+
+ if paso >= args.max_pasos:
+ break
+
+ except KeyboardInterrupt:
+ logger.info("Interrumpido — guardando...")
+
+ _guardar(args.checkpoint_out, modelo, optimizer, paso)
+
+ # Diagnóstico final
+ logger.info("─── DIAGNÓSTICO FINAL ───")
+ print(diagnostico(modelo, tok, device, territory_table))
+
+ elapsed = time.time() - t0
+ avg_ce = sum(losses_ce) / max(1, len(losses_ce))
+ print(f"\n── Neuro-Training Completado ──")
+ print(f" Pasos: {paso}")
+ print(f" Tiempo: {int(elapsed // 3600)}h{int((elapsed % 3600) // 60):02d}m")
+ print(f" Loss CE final (avg100): {avg_ce:.3f}")
+ print(f" PPL final: {math.exp(min(avg_ce, 10)):.1f}")
+ print(f" Checkpoint: {args.checkpoint_out}")
+ print(f"\n Verificar con Brain Scanner:")
+ print(
+ f' python -X utf8 scripts/brain_scanner.py --code "def fibonacci(n):" --device cuda'
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/pretrain_local.py b/scripts/pretrain_local.py
new file mode 100644
index 0000000000000000000000000000000000000000..1c50b5ee41ac2768d8ddca0a97189b90555e5bfe
--- /dev/null
+++ b/scripts/pretrain_local.py
@@ -0,0 +1,585 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+pretrain_local.py — Continual pretrain de PamparV3 en GPU local (GTX 1650 4GB).
+
+Entrena con datos textbook en formato CLM (next token prediction).
+Optimizado para VRAM limitada: AMP fp16 + gradient accumulation + checkpointing.
+
+Uso:
+ # Pretrain con datos generados (cuando la generación termine)
+ python scripts/pretrain_local.py
+
+ # Con opciones custom
+ python scripts/pretrain_local.py --epochs 8 --lr 1e-4 --grad-accum 8
+
+ # Reanudar entrenamiento interrumpido
+ python scripts/pretrain_local.py --resume
+
+ # Esperar a que la generación termine antes de entrenar
+ python scripts/pretrain_local.py --wait-for-data 200
+
+Detener con Ctrl-C — guarda checkpoint antes de salir.
+"""
+
+import argparse
+import json
+import logging
+import math
+import random
+import sys
+import time
+from pathlib import Path
+from typing import Optional
+
+import sentencepiece as spm
+import torch
+import torch.nn.functional as F
+import torch.nn.utils as nn_utils
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+
+from pampar.coder.v3 import PRESET_V3, ConfigV3, PamparV3
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ datefmt="%H:%M:%S",
+)
+logger = logging.getLogger("pretrain_local")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Dataset
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+class TextbookDataset:
+ """
+ Carga textbook JSONL, tokeniza y sirve chunks aleatorios para CLM.
+
+ Divide textos largos en chunks solapados de max_seq_len+1 tokens.
+ El +1 es para tener input ([:L]) y target ([1:L+1]).
+ """
+
+ def __init__(
+ self,
+ ruta_jsonl: Path,
+ tokenizer: spm.SentencePieceProcessor,
+ max_seq_len: int = 512,
+ ) -> None:
+ self.tok = tokenizer
+ self.max_seq_len = max_seq_len
+ self.chunks: list[list[int]] = []
+
+ self._cargar(ruta_jsonl)
+
+ def _cargar(self, ruta: Path) -> None:
+ """Lee el JSONL y tokeniza en chunks."""
+ n_textos = 0
+ for linea in ruta.read_text(encoding="utf-8").splitlines():
+ if not linea.strip():
+ continue
+ try:
+ obj = json.loads(linea)
+ texto = obj.get("text", "")
+ except json.JSONDecodeError:
+ continue
+
+ if not texto or len(texto) < 50:
+ continue
+
+ ids = self.tok.Encode(texto)
+ n_textos += 1
+
+ # Chunks solapados (50% overlap)
+ step = self.max_seq_len // 2
+ for i in range(0, max(1, len(ids) - self.max_seq_len), step):
+ chunk = ids[i : i + self.max_seq_len + 1]
+ if len(chunk) >= 32:
+ self.chunks.append(chunk)
+
+ logger.info(
+ "Dataset: %d textos → %d chunks (seq_len=%d)",
+ n_textos,
+ len(self.chunks),
+ self.max_seq_len,
+ )
+
+ def __len__(self) -> int:
+ return len(self.chunks)
+
+ def get_batch(
+ self,
+ batch_size: int,
+ device: torch.device,
+ ) -> torch.Tensor:
+ """Devuelve un batch aleatorio [B, L+1] padded."""
+ indices = random.sample(
+ range(len(self.chunks)), min(batch_size, len(self.chunks))
+ )
+ seleccionados = [self.chunks[i] for i in indices]
+
+ max_len = min(
+ max(len(c) for c in seleccionados),
+ self.max_seq_len + 1,
+ )
+
+ padded = []
+ for chunk in seleccionados:
+ trunc = chunk[:max_len]
+ pad = [0] * (max_len - len(trunc))
+ padded.append(trunc + pad)
+
+ return torch.tensor(padded, dtype=torch.long, device=device)
+
+ @property
+ def total_tokens(self) -> int:
+ """Total de tokens en el dataset (sin padding)."""
+ return sum(len(c) for c in self.chunks)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# LR Scheduler
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+def cosine_lr(
+ step: int,
+ warmup_steps: int,
+ total_steps: int,
+ lr_max: float,
+ lr_min: float = 1e-6,
+) -> float:
+ """Cosine schedule con warmup lineal."""
+ if step < warmup_steps:
+ return lr_max * (step + 1) / warmup_steps
+ progreso = (step - warmup_steps) / max(1, total_steps - warmup_steps)
+ return lr_min + 0.5 * (lr_max - lr_min) * (1 + math.cos(math.pi * progreso))
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Training Loop
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+def entrenar(
+ modelo: PamparV3,
+ dataset: TextbookDataset,
+ optimizer: torch.optim.Optimizer,
+ device: torch.device,
+ *,
+ epochs: int,
+ batch_size: int,
+ grad_accum: int,
+ max_grad_norm: float,
+ lr_max: float,
+ guardar_cada: int,
+ ruta_ckpt: Path,
+ paso_inicio: int = 0,
+ mejor_loss: float = float("inf"),
+ use_amp: bool = False,
+) -> None:
+ """Bucle principal de continual pretrain. AMP opcional (fp32 por defecto)."""
+ steps_per_epoch = max(1, len(dataset) // (batch_size * grad_accum))
+ total_steps = steps_per_epoch * epochs
+ warmup_steps = min(total_steps // 10, 100)
+
+ logger.info(
+ "Config: epochs=%d batch=%d grad_accum=%d effective_batch=%d amp=%s",
+ epochs,
+ batch_size,
+ grad_accum,
+ batch_size * grad_accum,
+ use_amp,
+ )
+ logger.info(
+ "Steps: %d/epoch %d total warmup=%d lr_max=%.1e",
+ steps_per_epoch,
+ total_steps,
+ warmup_steps,
+ lr_max,
+ )
+ logger.info(
+ "Dataset: %d chunks ~%dK tokens", len(dataset), dataset.total_tokens // 1000
+ )
+
+ scaler = torch.amp.GradScaler("cuda", enabled=use_amp)
+ paso = paso_inicio
+ mejor = mejor_loss
+ t_inicio = time.time()
+
+ try:
+ for epoch in range(1, epochs + 1):
+ losses_epoch: list[float] = []
+ modelo.train()
+
+ for micro_step in range(steps_per_epoch * grad_accum):
+ # ── LR schedule ──────────────────────────────────────────
+ lr = cosine_lr(paso, warmup_steps, total_steps, lr_max)
+ for pg in optimizer.param_groups:
+ pg["lr"] = lr
+
+ # ── Forward + loss ───────────────────────────────────────
+ tokens = dataset.get_batch(batch_size, device)
+ input_ids = tokens[:, :-1]
+ targets = tokens[:, 1:]
+
+ with torch.amp.autocast("cuda", dtype=torch.float16, enabled=use_amp):
+ logits, loss, _info = modelo(input_ids, targets=targets)
+ loss = loss / grad_accum
+
+ # ── Backward ─────────────────────────────────────────────
+ scaler.scale(loss).backward()
+
+ loss_val = float(loss.detach()) * grad_accum
+ losses_epoch.append(loss_val)
+
+ # ── Optimizer step cada grad_accum micro-steps ────────────
+ if (micro_step + 1) % grad_accum == 0:
+ scaler.unscale_(optimizer)
+ nn_utils.clip_grad_norm_(modelo.parameters(), max_grad_norm)
+ scaler.step(optimizer)
+ scaler.update()
+ optimizer.zero_grad(set_to_none=True)
+ paso += 1
+
+ # ── Log cada 10 pasos ────────────────────────────────
+ if paso % 10 == 0:
+ avg_recent = sum(losses_epoch[-grad_accum * 10 :]) / min(
+ len(losses_epoch), grad_accum * 10
+ )
+ ppl = math.exp(min(avg_recent, 20.0))
+ elapsed = time.time() - t_inicio
+ eta_s = (
+ (total_steps - paso)
+ / max(1, (paso - paso_inicio) / elapsed)
+ if elapsed > 0
+ else 0
+ )
+ eta_h = eta_s / 3600
+
+ logger.info(
+ "epoch %d | paso %4d/%d | loss=%.3f ppl=%.1f | lr=%.1e | ETA=%.1fh",
+ epoch,
+ paso,
+ total_steps,
+ avg_recent,
+ ppl,
+ lr,
+ eta_h,
+ )
+
+ # ── VRAM log (solo paso 1) ───────────────────────────
+ if paso == paso_inicio + 1:
+ alloc = torch.cuda.max_memory_allocated() / 1e9
+ logger.info("VRAM pico: %.2f GB / 4.00 GB", alloc)
+
+ # ── Guardar periódico ─────────────────────────────────
+ if guardar_cada > 0 and paso % guardar_cada == 0:
+ _guardar_checkpoint(
+ modelo,
+ optimizer,
+ paso,
+ mejor,
+ ruta_ckpt.parent / f"v3_pretrain_step{paso}.pt",
+ )
+
+ # ── Fin de epoch ─────────────────────────────────────────────
+ avg_epoch = sum(losses_epoch) / len(losses_epoch)
+ ppl_epoch = math.exp(min(avg_epoch, 20.0))
+ elapsed = time.time() - t_inicio
+ hh, mm = int(elapsed // 3600), int((elapsed % 3600) // 60)
+
+ logger.info(
+ "═══ Epoch %d/%d loss=%.3f ppl=%.1f [%02dh%02dm] ═══",
+ epoch,
+ epochs,
+ avg_epoch,
+ ppl_epoch,
+ hh,
+ mm,
+ )
+
+ # Guardar checkpoint de epoch
+ _guardar_checkpoint(
+ modelo,
+ optimizer,
+ paso,
+ mejor,
+ ruta_ckpt.parent / f"v3_pretrain_epoch{epoch}.pt",
+ )
+
+ # Guardar best
+ if avg_epoch < mejor:
+ mejor = avg_epoch
+ _guardar_checkpoint(
+ modelo,
+ optimizer,
+ paso,
+ mejor,
+ ruta_ckpt,
+ )
+ logger.info("★ Nuevo mejor loss: %.3f", mejor)
+
+ except KeyboardInterrupt:
+ logger.info("\nInterrumpido — guardando checkpoint final...")
+
+ # Guardar checkpoint final
+ _guardar_checkpoint(
+ modelo,
+ optimizer,
+ paso,
+ mejor,
+ ruta_ckpt.parent / "v3_pretrain_last.pt",
+ )
+ elapsed = time.time() - t_inicio
+ logger.info(
+ "Pretrain completado — %d pasos, mejor loss=%.3f, tiempo=%.1f min",
+ paso - paso_inicio,
+ mejor,
+ elapsed / 60,
+ )
+
+
+def _guardar_checkpoint(
+ modelo: PamparV3,
+ optimizer: torch.optim.Optimizer,
+ paso: int,
+ mejor_loss: float,
+ ruta: Path,
+) -> None:
+ """Guarda checkpoint con modelo + optimizer + metadata."""
+ import dataclasses
+
+ ruta.parent.mkdir(parents=True, exist_ok=True)
+ torch.save(
+ {
+ "modelo": modelo.state_dict(),
+ "optimizer": optimizer.state_dict(),
+ "paso_global": paso,
+ "config": dataclasses.asdict(modelo.config),
+ "mejor_loss": mejor_loss,
+ "tipo": "pretrain_local",
+ },
+ ruta,
+ )
+ logger.info("✓ Checkpoint → %s (paso %d)", ruta.name, paso)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Esperar datos
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+def esperar_datos(ruta_jsonl: Path, min_ejemplos: int, intervalo: int = 30) -> None:
+ """Espera hasta que el JSONL tenga al menos min_ejemplos líneas."""
+ logger.info("Esperando ≥%d ejemplos en %s...", min_ejemplos, ruta_jsonl.name)
+ while True:
+ if ruta_jsonl.exists():
+ n = sum(
+ 1
+ for line in ruta_jsonl.read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ )
+ if n >= min_ejemplos:
+ logger.info("Datos listos: %d ejemplos", n)
+ return
+ logger.info(
+ " %d/%d ejemplos — esperando %ds...", n, min_ejemplos, intervalo
+ )
+ else:
+ logger.info(" Archivo no existe aún — esperando %ds...", intervalo)
+ time.sleep(intervalo)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# CLI
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+def _parse_args() -> argparse.Namespace:
+ p = argparse.ArgumentParser(
+ description="Continual pretrain de PamparV3 en GPU local",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+
+ # Rutas
+ p.add_argument(
+ "--checkpoint-base",
+ type=Path,
+ default=ROOT / "checkpoints" / "v3_ghidra_v9.pt",
+ help="Checkpoint base del que partir",
+ )
+ p.add_argument(
+ "--checkpoint-out",
+ type=Path,
+ default=ROOT / "checkpoints" / "v3_pretrain_best.pt",
+ help="Ruta del checkpoint de salida (best)",
+ )
+ p.add_argument(
+ "--data",
+ type=Path,
+ default=ROOT / "data" / "textbook_v3" / "textbook_pretrain.jsonl",
+ help="Datos de textbook JSONL",
+ )
+ p.add_argument(
+ "--tokenizer",
+ type=Path,
+ default=ROOT / "data" / "tokenizer" / "pampar_48k.model",
+ help="SentencePiece 48K",
+ )
+
+ # Hiperparámetros
+ p.add_argument("--epochs", type=int, default=5, help="Número de epochs")
+ p.add_argument("--lr", type=float, default=1e-4, help="Learning rate máximo")
+ p.add_argument("--batch-size", type=int, default=2, help="Micro-batch size")
+ p.add_argument(
+ "--grad-accum", type=int, default=4, help="Gradient accumulation steps"
+ )
+ p.add_argument(
+ "--seq-len", type=int, default=512, help="Longitud máxima de secuencia"
+ )
+ p.add_argument("--max-grad-norm", type=float, default=0.5, help="Gradient clipping")
+ p.add_argument("--weight-decay", type=float, default=0.1, help="Weight decay")
+ p.add_argument(
+ "--guardar-cada",
+ type=int,
+ default=200,
+ help="Guardar cada N pasos. 0=solo epochs",
+ )
+
+ # Control
+ p.add_argument(
+ "--resume", action="store_true", help="Reanudar desde checkpoint de salida"
+ )
+ p.add_argument(
+ "--wait-for-data",
+ type=int,
+ default=0,
+ help="Esperar hasta N ejemplos en el JSONL antes de empezar",
+ )
+ p.add_argument(
+ "--amp",
+ action="store_true",
+ help="Usar AMP fp16 (puede causar NaN en esta arquitectura)",
+ )
+
+ return p.parse_args()
+
+
+def main() -> None:
+ args = _parse_args()
+
+ # ── Validar CUDA ──────────────────────────────────────────────────────────
+ if not torch.cuda.is_available():
+ logger.error("CUDA no disponible. Este script requiere GPU.")
+ sys.exit(1)
+
+ device = torch.device("cuda")
+ gpu_name = torch.cuda.get_device_name(0)
+ vram_gb = torch.cuda.get_device_properties(0).total_memory / 1e9
+ logger.info("GPU: %s (%.1f GB VRAM)", gpu_name, vram_gb)
+
+ # ── Esperar datos si se pide ──────────────────────────────────────────────
+ if args.wait_for_data > 0:
+ esperar_datos(args.data, args.wait_for_data)
+
+ # ── Validar archivos ──────────────────────────────────────────────────────
+ if not args.data.exists():
+ logger.error("Datos no encontrados: %s", args.data)
+ sys.exit(1)
+ if not args.tokenizer.exists():
+ logger.error("Tokenizer no encontrado: %s", args.tokenizer)
+ sys.exit(1)
+
+ # ── Tokenizer ─────────────────────────────────────────────────────────────
+ tok = spm.SentencePieceProcessor()
+ tok.Load(str(args.tokenizer))
+ logger.info("Tokenizer: vocab=%d", tok.GetPieceSize())
+
+ # ── Dataset ───────────────────────────────────────────────────────────────
+ dataset = TextbookDataset(args.data, tok, max_seq_len=args.seq_len)
+ if len(dataset) == 0:
+ logger.error("Dataset vacío — ¿generación de datos incompleta?")
+ sys.exit(1)
+
+ # ── Modelo ────────────────────────────────────────────────────────────────
+ ruta_base = args.checkpoint_base
+ paso_inicio = 0
+ mejor_loss = float("inf")
+
+ if args.resume and args.checkpoint_out.exists():
+ ruta_base = args.checkpoint_out
+ logger.info("Reanudando desde %s", ruta_base)
+
+ if not ruta_base.exists():
+ logger.error("Checkpoint base no encontrado: %s", ruta_base)
+ sys.exit(1)
+
+ logger.info("Cargando checkpoint: %s", ruta_base.name)
+ payload = torch.load(ruta_base, map_location="cpu", weights_only=False)
+ config = ConfigV3(**payload["config"]) if "config" in payload else PRESET_V3
+ modelo = PamparV3(config).to(device)
+ modelo.load_state_dict(payload["modelo"])
+
+ if args.resume and "paso_global" in payload:
+ paso_inicio = int(payload["paso_global"])
+ mejor_loss = float(payload.get("mejor_loss", float("inf")))
+ logger.info(
+ "Reanudando desde paso %d, mejor_loss=%.3f", paso_inicio, mejor_loss
+ )
+
+ n_params = sum(p.numel() for p in modelo.parameters() if p.requires_grad)
+ logger.info(
+ "PamparV3 — %.1fM params, gradient checkpointing=%s",
+ n_params / 1e6,
+ config.use_checkpoint,
+ )
+
+ # ── Optimizer ─────────────────────────────────────────────────────────────
+ optimizer = torch.optim.AdamW(
+ modelo.parameters(),
+ lr=args.lr,
+ betas=(0.9, 0.95),
+ weight_decay=args.weight_decay,
+ eps=1e-8,
+ )
+
+ if args.resume and "optimizer" in payload:
+ try:
+ optimizer.load_state_dict(payload["optimizer"])
+ logger.info("Optimizer restaurado")
+ except Exception as e:
+ logger.warning("No se pudo restaurar optimizer: %s", e)
+
+ del payload
+ torch.cuda.empty_cache()
+
+ # ── Entrenar ──────────────────────────────────────────────────────────────
+ logger.info("=" * 60)
+ logger.info("CONTINUAL PRETRAIN — PamparV3 108M")
+ logger.info("=" * 60)
+
+ entrenar(
+ modelo=modelo,
+ dataset=dataset,
+ optimizer=optimizer,
+ device=device,
+ epochs=args.epochs,
+ batch_size=args.batch_size,
+ grad_accum=args.grad_accum,
+ max_grad_norm=args.max_grad_norm,
+ lr_max=args.lr,
+ guardar_cada=args.guardar_cada,
+ ruta_ckpt=args.checkpoint_out,
+ paso_inicio=paso_inicio,
+ mejor_loss=mejor_loss,
+ use_amp=args.amp,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/resume_training.py b/scripts/resume_training.py
new file mode 100644
index 0000000000000000000000000000000000000000..1552bf7fdd7cd360c4f645940794c46b9b404856
--- /dev/null
+++ b/scripts/resume_training.py
@@ -0,0 +1,198 @@
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+🔄 Resume Training — Retoma entrenamiento desde checkpoint
+
+Script para cuando el pod se para y necesitás retomar.
+
+Uso:
+ # En el pod: resume la última fase
+ python scripts/resume_training.py
+
+ # Resume fase específica
+ python scripts/resume_training.py --fase 2
+
+ # Resume con checkpoint específico
+ python scripts/resume_training.py --checkpoint checkpoints/cerebral/fase1_final.pt --fase 2
+
+ # Solo evaluar con HumanEval-Mini (rápido)
+ python scripts/resume_training.py --eval-only --checkpoint checkpoints/cerebral/fase1_final.pt
+
+Este script automatiza:
+ 1. Detectar último checkpoint
+ 2. Cargar modelo + estado
+ 3. Continuar con la siguiente fase (o la misma)
+ 4. Instalar watchdog auto-stop para el pod
+"""
+
+import argparse
+import json
+import os
+import sys
+import glob
+import subprocess
+from pathlib import Path
+from datetime import datetime
+
+import torch
+
+# Ajustar path
+script_dir = Path(__file__).parent
+project_dir = script_dir.parent
+sys.path.insert(0, str(project_dir))
+
+
+def find_latest_checkpoint(checkpoint_dir: str = "checkpoints/cerebral") -> dict:
+ """Encuentra el checkpoint más reciente y su fase."""
+ ckpt_dir = Path(checkpoint_dir)
+ if not ckpt_dir.exists():
+ return {"path": None, "fase": 0, "paso": 0}
+
+ # Buscar todos los checkpoints
+ ckpts = list(ckpt_dir.glob("*.pt"))
+ if not ckpts:
+ return {"path": None, "fase": 0, "paso": 0}
+
+ # Ordenar por fecha de modificación
+ ckpts.sort(key=lambda p: p.stat().st_mtime, reverse=True)
+
+ latest = ckpts[0]
+
+ # Extraer fase del nombre
+ name = latest.stem
+ fase = 0
+ paso = 0
+
+ if "fase1" in name:
+ fase = 1
+ elif "fase2" in name:
+ fase = 2
+ elif "fase3" in name:
+ fase = 3
+ elif "fase4" in name:
+ fase = 4
+ elif "fase5" in name:
+ fase = 5
+ elif "fase6" in name:
+ fase = 6
+ elif "cerebral_final" in name:
+ fase = 99
+
+ # Cargar metadata si existe
+ try:
+ ckpt = torch.load(str(latest), map_location="cpu", weights_only=False)
+ fase = ckpt.get("fase", fase)
+ paso = ckpt.get("paso", paso)
+ loss = ckpt.get("loss", "?")
+ print(f" 📦 Checkpoint: {latest.name}")
+ print(f" Fase: {fase}, Paso: {paso}, Loss: {loss}")
+ print(f" Fecha: {ckpt.get('timestamp', 'desconocida')}")
+ except Exception as e:
+ print(f" ⚠️ Error leyendo metadata: {e}")
+
+ return {"path": str(latest), "fase": fase, "paso": paso}
+
+
+def detect_next_fase(current_fase: int) -> int:
+ """Determina la siguiente fase a ejecutar."""
+ FINAL_PHASES = {
+ 0: 1, # Sin checkpoint → empezar desde cero
+ 1: 2, # Terminó Fase 1 → hacer Fase 2
+ 2: 3, # Terminó Fase 2 → hacer Fase 3
+ 3: 4, # ...
+ 4: 5,
+ 5: 6,
+ 99: 0, # Ya completó todo
+ }
+
+ if "final" not in str(current_fase):
+ # Si el checkpoint no es "final", repetir la misma fase
+ return current_fase
+
+ return FINAL_PHASES.get(current_fase, current_fase + 1)
+
+
+def main():
+ parser = argparse.ArgumentParser(description="🔄 Resume Training")
+ parser.add_argument("--checkpoint", type=str, default=None)
+ parser.add_argument("--fase", type=int, default=None,
+ help="Fase a ejecutar (override auto-detection)")
+ parser.add_argument("--preset", type=str, default="4gb",
+ choices=["4gb", "8gb", "1.5b"])
+ parser.add_argument("--batch-size", type=int, default=4)
+ parser.add_argument("--eval-only", action="store_true",
+ help="Solo evaluar, no entrenar")
+ parser.add_argument("--eval-mini", action="store_true",
+ help="Evaluar con HumanEval-Mini (rápido)")
+ args = parser.parse_args()
+
+ print(f"\n🔄 PAMPAr-Coder — Resume Training")
+ print(f"{'═' * 50}")
+
+ # 1. Encontrar checkpoint
+ if args.checkpoint:
+ ckpt_info = {"path": args.checkpoint, "fase": 0, "paso": 0}
+ # Cargar info
+ try:
+ ckpt = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
+ ckpt_info["fase"] = ckpt.get("fase", 0)
+ ckpt_info["paso"] = ckpt.get("paso", 0)
+ print(f" 📦 Checkpoint: {args.checkpoint}")
+ print(f" Fase: {ckpt_info['fase']}, Paso: {ckpt_info['paso']}")
+ except Exception as e:
+ print(f" ⚠️ Error: {e}")
+ else:
+ print(" Buscando último checkpoint...")
+ ckpt_info = find_latest_checkpoint()
+
+ if ckpt_info["path"] is None:
+ print(" ❌ No se encontró checkpoint")
+ print(" Ejecuta: python scripts/train_cerebral.py --fase 1 --preset 1.5b")
+ return
+
+ # 2. Determinar siguiente fase
+ if args.fase is not None:
+ next_fase = args.fase
+ else:
+ # Si checkpoint es "final", avanzar; si no, repetir
+ if "final" in Path(ckpt_info["path"]).stem:
+ next_fase = ckpt_info["fase"] + 1
+ else:
+ next_fase = ckpt_info["fase"]
+
+ if next_fase > 6:
+ print(" ✅ ¡Entrenamiento completo! Todas las fases terminadas.")
+ print(" Ejecuta evaluación: python scripts/evaluate_v2.py --checkpoint", ckpt_info["path"])
+ return
+
+ # 3. Eval-only?
+ if args.eval_only or args.eval_mini:
+ benchmark = "mini" if args.eval_mini else "humaneval"
+ cmd = [
+ sys.executable, str(project_dir / "scripts" / "evaluate_v2.py"),
+ "--checkpoint", ckpt_info["path"],
+ "--preset", args.preset,
+ "--benchmark", benchmark,
+ "--save-samples",
+ ]
+ print(f"\n Ejecutando evaluación ({benchmark})...")
+ os.execvp(cmd[0], cmd)
+ return
+
+ # 4. Lanzar entrenamiento
+ print(f"\n ▶️ Lanzando Fase {next_fase} desde checkpoint {Path(ckpt_info['path']).name}")
+
+ cmd = [
+ sys.executable, str(project_dir / "scripts" / "train_cerebral.py"),
+ "--fase", str(next_fase),
+ "--preset", args.preset,
+ "--batch-size", str(args.batch_size),
+ "--checkpoint", ckpt_info["path"],
+ ]
+
+ print(f" Comando: {' '.join(cmd)}")
+ os.execvp(cmd[0], cmd)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/sft_v5.py b/scripts/sft_v5.py
new file mode 100644
index 0000000000000000000000000000000000000000..4a25e888eb6c6cb9b0aff8423ad7ce9d6a77bf81
--- /dev/null
+++ b/scripts/sft_v5.py
@@ -0,0 +1,368 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+sft_v5.py — Third-pass SFT de PamparV3 sobre datos verificados en runtime.
+
+Diferencias respecto a sft_v4.py:
+ - Parte de v3_sft_v4.pt (el mejor checkpoint hasta ahora: 8/16)
+ - Usa sft_v5.jsonl — dataset generado por generate_sft_v5.py
+ donde CADA ejemplo fue exec() + assert antes de incluirse
+ - Cubre los 8 patrones que el modelo falla (fizzbuzz, cuadrados,
+ invertir_dict, busqueda_binaria, merge_sort, Punto, memoize, primos)
+ - LR aun mas bajo: 3e-6 -> 3e-7 (no destruir el SFT v4)
+ - Guarda en v3_sft_v5.pt
+
+Uso:
+ python -X utf8 scripts/sft_v5.py
+"""
+
+import argparse
+import dataclasses
+import json
+import logging
+import math
+import random
+import sys
+import time
+from collections import deque
+from pathlib import Path
+
+import torch
+import torch.nn.functional as F
+import torch.nn.utils as nn_utils
+import sentencepiece as spm
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+
+from pampar.coder.v3 import PamparV3, ConfigV3, PRESET_V3
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ datefmt="%H:%M:%S",
+)
+logger = logging.getLogger("sft_v5")
+
+# Marcadores válidos para la sección de respuesta
+_MARCADORES = ["### Solution:", "### Protocolo:"]
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Carga de datos
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _cargar_targeted(ruta: Path) -> list[str]:
+ """Carga targeted_sft.jsonl."""
+ ejemplos: list[str] = []
+ for linea in ruta.read_text(encoding="utf-8").splitlines():
+ if not linea.strip():
+ continue
+ try:
+ obj = json.loads(linea)
+ texto = obj.get("text", "")
+ if texto and any(m in texto for m in _MARCADORES):
+ ejemplos.append(texto)
+ except json.JSONDecodeError:
+ continue
+ logger.info("Targeted SFT: %d ejemplos cargados", len(ejemplos))
+ return ejemplos
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Tokenizacion con mascara de loss
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _tokenizar_con_mascara(
+ ejemplos: list[str],
+ tok: spm.SentencePieceProcessor,
+ max_seq_len: int,
+) -> list[tuple[list[int], list[bool]]]:
+ """
+ Tokeniza cada ejemplo y calcula una mascara booleana.
+ mask[i] = True si el token i pertenece a la seccion ### Solution: en adelante.
+ Solo se computa loss sobre posiciones donde mask=True.
+ """
+ chunks: list[tuple[list[int], list[bool]]] = []
+ max_chars = max_seq_len * 6
+
+ for texto in ejemplos:
+ if len(texto) > max_chars:
+ texto = texto[:max_chars]
+
+ # Encontrar posicion del marcador (cualquier marcador válido)
+ marker_pos = -1
+ matched_marker = _MARCADORES[0]
+ for _m in _MARCADORES:
+ pos = texto.find(_m)
+ if pos >= 0 and (marker_pos < 0 or pos < marker_pos):
+ marker_pos = pos
+ matched_marker = _m
+ if marker_pos < 0:
+ # Sin marcador: entrenar sobre todo (fallback)
+ ids = tok.Encode(texto)
+ if len(ids) >= 16:
+ trunc = ids[:max_seq_len + 1]
+ chunks.append((trunc, [True] * len(trunc)))
+ continue
+
+ # Tokenizar el prefijo hasta el marcador para saber la longitud en tokens
+ prefijo = texto[:marker_pos + len(matched_marker)]
+ ids_prefijo = tok.Encode(prefijo)
+ ids_full = tok.Encode(texto)
+
+ if len(ids_full) < 16:
+ continue
+
+ ids_full = ids_full[:max_seq_len + 1]
+ n_prefijo = min(len(ids_prefijo), len(ids_full))
+
+ # mask: False para el prefijo del problema, True para la solucion
+ mascara = [False] * n_prefijo + [True] * (len(ids_full) - n_prefijo)
+ chunks.append((ids_full, mascara))
+
+ return chunks
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Training helpers
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _hacer_batch_mascarado(
+ chunks: list[tuple[list[int], list[bool]]],
+ indices: list[int],
+ device: torch.device,
+ max_seq_len: int,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ """Batch con padding y mascara de loss."""
+ sels = [chunks[i] for i in indices]
+ max_len = min(max(len(ids) for ids, _ in sels), max_seq_len + 1)
+ padded_ids = []
+ padded_mask = []
+ for ids, mask in sels:
+ t = ids[:max_len]
+ m = mask[:max_len]
+ pad_len = max_len - len(t)
+ padded_ids.append(t + [0] * pad_len)
+ padded_mask.append(m + [False] * pad_len)
+ tokens = torch.tensor(padded_ids, dtype=torch.long, device=device)
+ mascara = torch.tensor(padded_mask, dtype=torch.bool, device=device)
+ return tokens, mascara
+
+
+def _paso_mascarado(
+ modelo: PamparV3,
+ optimizer: torch.optim.Optimizer,
+ tokens: torch.Tensor,
+ mascara: torch.Tensor,
+ max_grad_norm: float,
+) -> float:
+ """
+ Forward + backward con loss mascarado.
+ Solo se entrena sobre los tokens de la solucion.
+ """
+ modelo.train()
+ optimizer.zero_grad(set_to_none=True)
+
+ input_ids = tokens[:, :-1] # (B, T)
+ targets = tokens[:, 1:] # (B, T)
+ loss_mask = mascara[:, 1:] # alinear la mascara con targets
+
+ logits, _, _ = modelo(input_ids) # (B, T, V)
+ B, T, V = logits.shape
+
+ # Loss solo sobre posiciones de la solucion
+ logits_flat = logits.reshape(B * T, V)
+ targets_flat = targets.reshape(B * T)
+ mask_flat = loss_mask.reshape(B * T)
+
+ # Ignorar tokens no enmascarados poniendo target=-100
+ targets_masked = targets_flat.masked_fill(~mask_flat, -100)
+ loss = F.cross_entropy(logits_flat, targets_masked, ignore_index=-100)
+
+ if loss.isnan() or loss.isinf():
+ logger.warning("Loss inestable — skipping step")
+ return 0.0
+
+ loss.backward()
+ nn_utils.clip_grad_norm_(modelo.parameters(), max_grad_norm)
+ optimizer.step()
+
+ return float(loss.detach())
+
+
+def _cosine_lr(paso: int, warmup: int, total: int, lr_max: float, lr_min: float) -> float:
+ if paso < warmup:
+ return lr_max * (paso + 1) / warmup
+ progreso = (paso - warmup) / max(1, total - warmup)
+ return lr_min + 0.5 * (lr_max - lr_min) * (1 + math.cos(math.pi * progreso))
+
+
+def _guardar(ruta: Path, modelo: PamparV3, optimizer: torch.optim.Optimizer, paso: int) -> None:
+ ruta.parent.mkdir(parents=True, exist_ok=True)
+ torch.save({
+ "modelo": modelo.state_dict(),
+ "optimizer": optimizer.state_dict(),
+ "paso_global": paso,
+ "config": dataclasses.asdict(modelo.config),
+ "tipo": "sft_v5",
+ }, ruta)
+ logger.info("Checkpoint SFT-v5 guardado -> paso %d", paso)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# CLI + main
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _parse_args() -> argparse.Namespace:
+ p = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+ p.add_argument("--checkpoint-in", type=Path,
+ default=ROOT / "checkpoints" / "v3_sft_v4.pt")
+ p.add_argument("--checkpoint-out", type=Path,
+ default=ROOT / "checkpoints" / "v3_sft_v5.pt")
+ p.add_argument("--tokenizer", type=Path,
+ default=ROOT / "data" / "tokenizer" / "pampar_48k.model")
+ p.add_argument("--targeted", type=Path,
+ default=ROOT / "data" / "sft_v5.jsonl")
+ p.add_argument("--lr", type=float, default=3e-6)
+ p.add_argument("--lr-min", type=float, default=3e-7)
+ p.add_argument("--warmup", type=int, default=30)
+ p.add_argument("--max-pasos", type=int, default=2000)
+ p.add_argument("--epochs", type=int, default=30,
+ help="Más epochs sobre dataset pequeño verificado")
+ p.add_argument("--batch-size", type=int, default=2)
+ p.add_argument("--seq-len", type=int, default=512)
+ p.add_argument("--max-grad-norm", type=float, default=1.0)
+ p.add_argument("--guardar-cada", type=int, default=500)
+ p.add_argument("--device", type=str, default="auto")
+ p.add_argument("--seed", type=int, default=42)
+ return p.parse_args()
+
+
+def main() -> None:
+ args = _parse_args()
+ random.seed(args.seed)
+ torch.manual_seed(args.seed)
+
+ device = torch.device(
+ "cuda" if args.device == "auto" and torch.cuda.is_available()
+ else args.device if args.device != "auto" else "cpu"
+ )
+ logger.info("Device: %s", device)
+ if device.type == "cuda":
+ torch.cuda.manual_seed(args.seed)
+ logger.info("GPU: %s (%.1f GiB)",
+ torch.cuda.get_device_name(0),
+ torch.cuda.get_device_properties(0).total_memory / 1e9)
+
+ # Tokenizer
+ tok = spm.SentencePieceProcessor()
+ tok.Load(str(args.tokenizer))
+ logger.info("Tokenizer vocab=%d", tok.GetPieceSize())
+
+ # Modelo desde v3_sft.pt
+ if not args.checkpoint_in.exists():
+ logger.error("Checkpoint no encontrado: %s", args.checkpoint_in)
+ sys.exit(1)
+ payload = torch.load(args.checkpoint_in, map_location=device, weights_only=False)
+ config = ConfigV3(**payload["config"]) if "config" in payload else PRESET_V3
+ modelo = PamparV3(config).to(device)
+ modelo.load_state_dict(payload["modelo"])
+ logger.info("Cargado desde %s (tipo: %s) → fine-tune v5", args.checkpoint_in.name,
+ payload.get("tipo", "?"))
+ del payload
+
+ n_params = sum(p.numel() for p in modelo.parameters() if p.requires_grad)
+ logger.info("PamparV3 %.1fM params", n_params / 1e6)
+
+ # Datos
+ if not args.targeted.exists():
+ logger.error("Dataset dirigido no encontrado: %s", args.targeted)
+ logger.error("Primero ejecuta: python -X utf8 scripts/generate_sft_v5.py")
+ sys.exit(1)
+
+ ejemplos = _cargar_targeted(args.targeted)
+ chunks = _tokenizar_con_mascara(ejemplos, tok, args.seq_len)
+ logger.info("Chunks tokenizados con mascara: %d", len(chunks))
+ del ejemplos
+
+ # Verificar que hay suficiente texto en la zona de solucion
+ pct_solution = sum(sum(m) for _, m in chunks) / max(1, sum(len(ids) for ids, _ in chunks))
+ logger.info("Porcentaje de tokens en zona Solution: %.1f%%", pct_solution * 100)
+
+ # Optimizer con LR bajo (no destruir lo aprendido)
+ optimizer = torch.optim.AdamW(
+ modelo.parameters(),
+ lr=args.lr,
+ betas=(0.9, 0.95),
+ weight_decay=0.01,
+ eps=1e-8,
+ )
+
+ pasos_por_epoch = max(1, len(chunks) // args.batch_size)
+ total_pasos = min(args.max_pasos, args.epochs * pasos_por_epoch)
+ logger.info("SFT-v4: %d pasos | %d chunks | %d p/epoch | lr=%.1e->%.1e",
+ total_pasos, len(chunks), pasos_por_epoch, args.lr, args.lr_min)
+
+ paso = 0
+ t0 = time.time()
+ losses: deque[float] = deque(maxlen=100)
+
+ try:
+ for epoch in range(args.epochs):
+ idx = list(range(len(chunks)))
+ random.shuffle(idx)
+ logger.info("-- Epoch %d/%d --", epoch + 1, args.epochs)
+
+ for i in range(0, len(idx) - args.batch_size + 1, args.batch_size):
+ batch_idx = idx[i: i + args.batch_size]
+ tokens, mascara = _hacer_batch_mascarado(chunks, batch_idx, device, args.seq_len)
+
+ lr = _cosine_lr(paso, args.warmup, total_pasos, args.lr, args.lr_min)
+ for pg in optimizer.param_groups:
+ pg["lr"] = lr
+
+ loss = _paso_mascarado(modelo, optimizer, tokens, mascara, args.max_grad_norm)
+ if loss > 0:
+ losses.append(loss)
+ paso += 1
+
+ if paso % 10 == 0:
+ avg = sum(losses) / max(1, len(losses))
+ elapsed = time.time() - t0
+ logger.info(
+ "paso %5d/%d | loss=%.3f avg100=%.3f lr=%.1e ppl=%.1f (%.1f p/s)",
+ paso, total_pasos, loss, avg, lr,
+ math.exp(min(avg, 10)),
+ paso / elapsed,
+ )
+
+ if paso % args.guardar_cada == 0:
+ _guardar(args.checkpoint_out, modelo, optimizer, paso)
+
+ if paso >= args.max_pasos:
+ break
+
+ if paso >= args.max_pasos:
+ break
+
+ except KeyboardInterrupt:
+ logger.info("Interrumpido — guardando...")
+
+ _guardar(args.checkpoint_out, modelo, optimizer, paso)
+
+ elapsed = time.time() - t0
+ avg_final = sum(losses) / max(1, len(losses))
+ print(f"-- SFT-v5 Completado --")
+ print(f" Pasos: {paso}")
+ print(f" Tiempo: {int(elapsed // 3600)}h{int((elapsed % 3600) // 60):02d}m")
+ print(f" Loss final (avg100): {avg_final:.3f}")
+ print(f" PPL final: {math.exp(min(avg_final, 10)):.1f}")
+ print(f" Checkpoint: {args.checkpoint_out}")
+ print(f"\n Evaluar con:")
+ print(f" python -X utf8 scripts/eval_v3.py --checkpoint checkpoints/v3_sft_v5.pt")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_engrama.py b/scripts/test_engrama.py
new file mode 100644
index 0000000000000000000000000000000000000000..7052993fdf92701df629c97d8bdfdec10b3b1123
--- /dev/null
+++ b/scripts/test_engrama.py
@@ -0,0 +1,274 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+Test end-to-end de GhidraProbe + EngramaStream.
+
+Carga el modelo v3_neuro_v9 (Score=85), ejecuta GhidraProbe para
+diagnosticar el forward pass, captura engramas de ejemplos exitosos,
+y mide el impacto de la inyección en la inferencia.
+
+Uso:
+ python scripts/test_engrama.py
+ python scripts/test_engrama.py --checkpoint checkpoints/v3_neuro_v9.pt
+ python scripts/test_engrama.py --save-banco memoria/banco_engrama.json
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import json
+import sys
+import time
+from pathlib import Path
+
+import sentencepiece as spm
+import torch
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+
+from pampar.coder.v3 import PRESET_V3, PamparV3
+from pampar.coder.v3.engrama_stream import BancoEngrama, EngramaCapture
+from pampar.coder.v3.ghidra_probe import GhidraProbe
+from pampar.inference import load_model
+
+DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+
+EJEMPLOS_TEST = [
+ "### Instruction:\nWrite a function that returns the factorial of n.\n### Solution:\ndef factorial(n):\n return 1 if n <= 1 else n * factorial(n - 1)",
+ "### Instruction:\nWrite a function to check if a string is a palindrome.\n### Solution:\ndef is_palindrome(s):\n return s == s[::-1]",
+ "### Instruction:\nWrite a function to compute fibonacci.\n### Solution:\ndef fibonacci(n):\n a, b = 0, 1\n for _ in range(n):\n a, b = b, a + b\n return a",
+ "### Instruction:\nWrite a function to flatten a nested list.\n### Solution:\ndef flatten(lst):\n result = []\n for item in lst:\n if isinstance(item, list):\n result.extend(flatten(item))\n else:\n result.append(item)\n return result",
+ "### Instruction:\nWrite a function to count vowels in a string.\n### Solution:\ndef count_vowels(s):\n return sum(1 for c in s.lower() if c in 'aeiou')",
+]
+
+
+def evaluar_calidad(texto_generado: str) -> float:
+ """Evalúa calidad del código generado (0-1)."""
+ # Extraer código después de ### Solution:
+ marcador = "### Solution:"
+ pos = texto_generado.find(marcador)
+ if pos < 0:
+ return 0.1
+ codigo = texto_generado[pos + len(marcador) :].strip()
+
+ score = 0.0
+
+ # ¿Tiene def?
+ if "def " in codigo:
+ score += 0.3
+
+ # ¿Parsea como Python válido?
+ try:
+ ast.parse(codigo)
+ score += 0.4
+ except SyntaxError:
+ score += 0.1
+
+ # ¿Tiene return?
+ if "return" in codigo:
+ score += 0.2
+
+ # ¿No está vacío?
+ if len(codigo.strip()) > 10:
+ score += 0.1
+
+ return min(score, 1.0)
+
+
+def test_ghidra_probe(
+ model: PamparV3,
+ tokenizer: spm.SentencePieceProcessor,
+) -> None:
+ """Ejecuta GhidraProbe en ejemplos de test."""
+ print("\n" + "=" * 70)
+ print(" FASE 1: GhidraProbe — Diagnóstico del Forward Pass")
+ print("=" * 70)
+
+ probe = GhidraProbe(model)
+
+ for i, ejemplo in enumerate(EJEMPLOS_TEST[:3]):
+ ids = tokenizer.Encode(ejemplo)[:256]
+ input_ids = torch.tensor([ids], dtype=torch.long, device=DEVICE)
+
+ probe.reset()
+ with torch.no_grad():
+ logits, loss, info = model(input_ids[:, :-1], targets=input_ids[:, 1:])
+
+ print(f"\n{'─' * 70}")
+ print(f" Ejemplo {i + 1}: {ejemplo[:60]}...")
+ print(f" Loss: {loss.item():.4f}")
+ probe.print_summary()
+
+ # Mostrar trayectoria de routing del primer token interesante
+ tokens = [tokenizer.IdToPiece(t) for t in ids[:10]]
+ print(f"\n Primeros 10 tokens: {tokens}")
+ for t_idx in [0, 1, 2]:
+ traj = probe.routing_trajectory(t_idx)
+ if traj:
+ traj_str = " → ".join(
+ f"[{','.join(f'{v:.2f}' for v in step)}]" for step in traj
+ )
+ print(f" Token {t_idx} ({tokens[t_idx]!r}) routing: {traj_str}")
+
+ probe.detach()
+ print("\nGhidraProbe detachado.")
+
+
+def test_engrama_captura(
+ model: PamparV3,
+ tokenizer: spm.SentencePieceProcessor,
+ banco: BancoEngrama,
+) -> None:
+ """Captura engramas de ejemplos exitosos."""
+ print("\n" + "=" * 70)
+ print(" FASE 2: EngramaStream — Captura de Activaciones")
+ print("=" * 70)
+
+ probe = GhidraProbe(model)
+ capture = EngramaCapture(banco, score_minimo=0.5)
+ total_engramas = 0
+
+ for i, ejemplo in enumerate(EJEMPLOS_TEST):
+ ids = tokenizer.Encode(ejemplo)[:256]
+ input_ids = torch.tensor([ids], dtype=torch.long, device=DEVICE)
+
+ probe.reset()
+ with torch.no_grad():
+ logits, loss, info = model(input_ids[:, :-1], targets=input_ids[:, 1:])
+
+ # Evaluar calidad
+ score = evaluar_calidad(ejemplo)
+
+ # Capturar engramas si la calidad es suficiente
+ n = capture.capturar_desde_probe(probe._raw, score, n_levels=5)
+ total_engramas += n
+ print(f" Ejemplo {i + 1}: score={score:.2f}, engramas capturados={n}")
+
+ probe.detach()
+
+ # Stats del banco
+ stats = banco.stats()
+ print(f"\n Banco stats: {json.dumps(stats, indent=2, default=str)}")
+ print(f" Total engramas en banco: {banco.total_engramas}")
+
+
+def test_engrama_inyeccion(
+ model: PamparV3,
+ tokenizer: spm.SentencePieceProcessor,
+ banco: BancoEngrama,
+) -> None:
+ """Compara inferencia con y sin inyección de engramas."""
+ print("\n" + "=" * 70)
+ print(" FASE 3: EngramaStream — Inyección en Inferencia")
+ print("=" * 70)
+
+ prompt = "### Instruction:\nWrite a function to reverse a string.\n### Solution:\n"
+ ids = tokenizer.Encode(prompt)
+ input_ids = torch.tensor([ids], dtype=torch.long, device=DEVICE)
+
+ # Sin engramas
+ print("\n --- Sin EngramaStream ---")
+ t0 = time.perf_counter()
+ with torch.no_grad():
+ logits_sin, loss_sin, _ = model(input_ids[:, :-1], targets=input_ids[:, 1:])
+ t_sin = time.perf_counter() - t0
+
+ gen_sin = model.generate(input_ids, max_tokens=80, temperature=0.7)
+ texto_sin = tokenizer.Decode(gen_sin[0].tolist())
+ print(f" Loss: {loss_sin.item():.4f}")
+ print(f" Tiempo: {t_sin * 1000:.1f}ms")
+ print(f" Output: {texto_sin[:300]}")
+
+ # Con engramas
+ print("\n --- Con EngramaStream ---")
+ t0 = time.perf_counter()
+ with torch.no_grad():
+ logits_con, loss_con, _ = model(
+ input_ids[:, :-1],
+ targets=input_ids[:, 1:],
+ banco_engrama=banco,
+ )
+ t_con = time.perf_counter() - t0
+
+ gen_con = model.generate(
+ input_ids,
+ max_tokens=80,
+ temperature=0.7,
+ banco_engrama=banco,
+ )
+ texto_con = tokenizer.Decode(gen_con[0].tolist())
+ print(f" Loss: {loss_con.item():.4f}")
+ print(f" Tiempo: {t_con * 1000:.1f}ms")
+ print(f" Output: {texto_con[:300]}")
+
+ # Comparación
+ print("\n --- Comparación ---")
+ diff_loss = loss_con.item() - loss_sin.item()
+ print(f" ΔLoss: {diff_loss:+.4f} ({'mejor' if diff_loss < 0 else 'peor'})")
+ print(f" Inyecciones realizadas: {banco.total_inyecciones}")
+
+ # Distribución de logits
+ probs_sin = torch.softmax(logits_sin[0, -1], dim=-1)
+ probs_con = torch.softmax(logits_con[0, -1], dim=-1)
+ entropy_sin = -(probs_sin * probs_sin.log().clamp(min=-100)).sum().item()
+ entropy_con = -(probs_con * probs_con.log().clamp(min=-100)).sum().item()
+ print(f" Entropy sin engrama: {entropy_sin:.2f}")
+ print(f" Entropy con engrama: {entropy_con:.2f}")
+ print(
+ f" ΔEntropy: {entropy_con - entropy_sin:+.2f} ({'más certero' if entropy_con < entropy_sin else 'más disperso'})"
+ )
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Test GhidraProbe + EngramaStream")
+ parser.add_argument(
+ "--checkpoint",
+ default=str(ROOT / "checkpoints" / "v3_neuro_v9.pt"),
+ help="Ruta al checkpoint del modelo",
+ )
+ parser.add_argument(
+ "--save-banco",
+ default="",
+ help="Ruta para guardar el banco de engramas (JSON)",
+ )
+ parser.add_argument(
+ "--load-banco",
+ default="",
+ help="Ruta para cargar un banco existente",
+ )
+ args = parser.parse_args()
+
+ print(f"Device: {DEVICE}")
+ print(f"Checkpoint: {args.checkpoint}")
+
+ model, tokenizer = load_model(args.checkpoint, DEVICE, verbose=True)
+
+ # Crear o cargar banco
+ banco = BancoEngrama(dim=PRESET_V3.dim, max_engramas_por_clave=10)
+ if args.load_banco:
+ n = banco.cargar(Path(args.load_banco))
+ print(f"Banco cargado: {n} engramas desde {args.load_banco}")
+
+ # Fase 1: Diagnóstico
+ test_ghidra_probe(model, tokenizer)
+
+ # Fase 2: Captura
+ test_engrama_captura(model, tokenizer, banco)
+
+ # Fase 3: Inyección
+ test_engrama_inyeccion(model, tokenizer, banco)
+
+ # Guardar banco si se pidió
+ if args.save_banco:
+ ruta = Path(args.save_banco)
+ banco.guardar(ruta)
+ print(f"\nBanco guardado en {ruta} ({banco.total_engramas} engramas)")
+
+ print("\n✓ Test completo.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_inference_integration.py b/scripts/test_inference_integration.py
new file mode 100644
index 0000000000000000000000000000000000000000..799adf3f0db12b10131da2493765064f8d2d19da
--- /dev/null
+++ b/scripts/test_inference_integration.py
@@ -0,0 +1,356 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+"""
+test_inference_integration.py — Prueba fehaciente del servidor pampar.inference.
+
+Lanza el servidor como subprocess real, carga el checkpoint, y verifica que:
+ 1. El proceso arranca y emite READY
+ 2. Responde a una petición de inferencia con código Python real
+ 3. El código generado al menos tiene sintaxis válida
+ 4. Responde a una petición de boot con AGENTS.md bien formado
+ 5. Maneja JSON inválido sin caer
+
+Uso:
+ python scripts/test_inference_integration.py
+ python scripts/test_inference_integration.py --checkpoint checkpoints/v3_sft_v8.pt
+ python scripts/test_inference_integration.py --device cpu --timeout 60
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import json
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+# ---------------------------------------------------------------------------
+
+PROJECT_ROOT = Path(__file__).parent.parent
+DEFAULT_CHECKPOINT = PROJECT_ROOT / "checkpoints" / "v3_sft_v8.pt"
+VENV_PYTHON = PROJECT_ROOT.parent / ".venv" / "Scripts" / "python.exe"
+
+
+def _python_has_torch(python_path: str) -> bool:
+ """Verifica rápido si ese intérprete tiene torch disponible."""
+ try:
+ result = subprocess.run(
+ [python_path, "-c", "import torch"],
+ capture_output=True, timeout=10,
+ )
+ return result.returncode == 0
+ except Exception:
+ return False
+
+
+def find_python() -> str:
+ """Devuelve el primer intérprete Python que tenga torch instalado."""
+ candidates = [
+ str(VENV_PYTHON) if VENV_PYTHON.exists() else None,
+ sys.executable,
+ ]
+ for candidate in candidates:
+ if candidate and _python_has_torch(candidate):
+ return candidate
+ # Último recurso: sys.executable aunque no tenga torch (el servidor fallará con mensaje claro)
+ return sys.executable
+
+
+# ---------------------------------------------------------------------------
+# Color helpers
+# ---------------------------------------------------------------------------
+
+def ok(msg: str) -> None:
+ print(f" ✅ {msg}")
+
+def fail(msg: str) -> None:
+ print(f" ❌ {msg}")
+
+def section(title: str) -> None:
+ print(f"\n{'─'*50}")
+ print(f" {title}")
+ print(f"{'─'*50}")
+
+
+# ---------------------------------------------------------------------------
+# Server wrapper
+# ---------------------------------------------------------------------------
+
+class InferenceServer:
+ """Prozess-wrapper para pampar.inference."""
+
+ def __init__(self, checkpoint: str, device: str, timeout_ready: int = 90):
+ python = find_python()
+ cmd = [python, "-m", "pampar.inference", "--checkpoint", checkpoint, "--device", device]
+ import os as _os
+ _env = _os.environ.copy()
+ _env["PYTHONIOENCODING"] = "utf-8"
+ self.proc = subprocess.Popen(
+ cmd,
+ cwd=str(PROJECT_ROOT),
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ encoding="utf-8",
+ bufsize=1,
+ env=_env,
+ )
+ self._wait_ready(timeout_ready)
+
+ def _wait_ready(self, timeout: int) -> None:
+ print(f" Esperando READY (timeout={timeout}s)…", end="", flush=True)
+ deadline = time.time() + timeout
+ stderr_lines: list[str] = []
+ while time.time() < deadline:
+ line = self.proc.stderr.readline()
+ if not line:
+ if self.proc.poll() is not None:
+ raise RuntimeError(
+ f"Proceso terminó antes de READY (código {self.proc.returncode}).\n"
+ + "".join(stderr_lines)
+ )
+ time.sleep(0.1)
+ continue
+ stderr_lines.append(line)
+ print(".", end="", flush=True)
+ if "READY" in line:
+ print(" listo.")
+ # Leer también la línea {"type":"ready"} de stdout
+ self.proc.stdout.readline()
+ return
+ raise TimeoutError("El servidor no emitió READY a tiempo.\n" + "".join(stderr_lines[-20:]))
+
+ def send(self, msg: dict) -> dict:
+ line = json.dumps(msg, ensure_ascii=False) + "\n"
+ self.proc.stdin.write(line)
+ self.proc.stdin.flush()
+ resp_line = self.proc.stdout.readline()
+ if not resp_line:
+ raise EOFError("El servidor cerró stdout inesperadamente.")
+ return json.loads(resp_line)
+
+ def send_raw(self, raw: str) -> str:
+ self.proc.stdin.write(raw + "\n")
+ self.proc.stdin.flush()
+ return self.proc.stdout.readline()
+
+ def close(self) -> None:
+ try:
+ self.proc.stdin.close()
+ except Exception:
+ pass
+ self.proc.wait(timeout=5)
+
+
+# ---------------------------------------------------------------------------
+# Casos de prueba
+# ---------------------------------------------------------------------------
+
+PRUEBA_INFER = {
+ "type": "infer",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `suma(a, b)` that returns the sum of two numbers.\n"
+ "### Solution:\n"
+ ),
+ "max_tokens": 80,
+ "temperature": 0.1,
+}
+
+PRUEBA_INFER_ALGO_REAL = {
+ "type": "infer",
+ "prompt": (
+ "### Problem:\n"
+ "Write a Python function `es_par(n)` that returns True if n is even, False otherwise.\n"
+ "### Solution:\n"
+ ),
+ "max_tokens": 256,
+ "temperature": 0.1,
+}
+
+
+def run_tests(checkpoint: str, device: str, timeout: int) -> int:
+ """Ejecuta todos los tests. Devuelve número de fallos."""
+ fallos = 0
+
+ section("Iniciando servidor de inferencia")
+ try:
+ server = InferenceServer(checkpoint=checkpoint, device=device, timeout_ready=timeout)
+ except Exception as exc:
+ fail(f"No se pudo iniciar el servidor: {exc}")
+ return 1
+
+ # ------------------------------------------------------------------
+ # Test 1: responde a inferencia básica
+ # ------------------------------------------------------------------
+ section("Test 1: Respuesta a petición de inferencia")
+ try:
+ resp = server.send(PRUEBA_INFER)
+ if resp.get("type") == "infer_ok":
+ texto = resp.get("text", "").strip()
+ if texto:
+ ok(f"Texto generado ({len(texto)} chars): {repr(texto[:80])}")
+ else:
+ fail("infer_ok pero text está vacío")
+ fallos += 1
+ else:
+ fail(f"Respuesta inesperada: {resp}")
+ fallos += 1
+ except Exception as exc:
+ fail(f"Excepción: {exc}")
+ fallos += 1
+
+ # ------------------------------------------------------------------
+ # Test 2: sintaxis válida en el código generado
+ # ------------------------------------------------------------------
+ section("Test 2: Código generado tiene sintaxis Python válida")
+ try:
+ resp = server.send(PRUEBA_INFER_ALGO_REAL)
+ texto = resp.get("text", "")
+ # Limpiamos markdown y truncamos en stop markers
+ texto_limpio = texto.replace("```python", "").replace("```", "").strip()
+ if "###" in texto_limpio:
+ texto_limpio = texto_limpio[:texto_limpio.index("###")].rstrip()
+ # Intentar parsear progresivamente — quitar líneas del final hasta que sea válido
+ lines = texto_limpio.split("\n")
+ parsed = False
+ while lines:
+ candidate = "\n".join(lines).rstrip()
+ if candidate:
+ try:
+ ast.parse(candidate)
+ texto_limpio = candidate
+ parsed = True
+ break
+ except SyntaxError:
+ lines.pop()
+ else:
+ break
+ if parsed:
+ ok(f"Sintaxis válida: {repr(texto_limpio[:100])}")
+ else:
+ try:
+ ast.parse(texto_limpio)
+ ok(f"Sintaxis válida: {repr(texto_limpio[:100])}")
+ except SyntaxError as se:
+ fail(f"SyntaxError en código generado: {se}\nCódigo: {repr(texto_limpio[:200])}")
+ fallos += 1
+ except Exception as exc:
+ fail(f"Excepción: {exc}")
+ fallos += 1
+
+ # ------------------------------------------------------------------
+ # Test 3: boot genera AGENTS.md
+ # ------------------------------------------------------------------
+ section("Test 3: Boot genera AGENTS.md")
+ try:
+ resp = server.send({"type": "boot", "workspace": str(PROJECT_ROOT)})
+ if resp.get("type") == "boot_ok":
+ md = resp.get("agents_md", "")
+ checks = [
+ ("## Quick Reference" in md, "Sección Quick Reference"),
+ ("## Boot protocol" in md, "Sección Boot protocol"),
+ (len(md) > 200, "Contenido suficiente (>200 chars)"),
+ ]
+ for passed, label in checks:
+ if passed:
+ ok(label)
+ else:
+ fail(label)
+ fallos += 1
+ else:
+ fail(f"Respuesta inesperada: {resp}")
+ fallos += 1
+ except Exception as exc:
+ fail(f"Excepción: {exc}")
+ fallos += 1
+
+ # ------------------------------------------------------------------
+ # Test 4: prompt vacío → error (no crash)
+ # ------------------------------------------------------------------
+ section("Test 4: Prompt vacío → error controlado")
+ try:
+ resp = server.send({"type": "infer", "prompt": ""})
+ if resp.get("type") == "error":
+ ok(f"Error controlado: {resp.get('message', '')[:60]}")
+ else:
+ fail(f"Se esperaba error, se recibió: {resp}")
+ fallos += 1
+ except Exception as exc:
+ fail(f"Excepción: {exc}")
+ fallos += 1
+
+ # ------------------------------------------------------------------
+ # Test 5: JSON inválido no baja el servidor
+ # ------------------------------------------------------------------
+ section("Test 5: JSON inválido no mata el servidor")
+ try:
+ raw = server.send_raw("esto no es json {{{{")
+ if raw.strip():
+ resp = json.loads(raw)
+ if resp.get("type") == "error":
+ ok("El servidor respondió error y sigue vivo")
+ else:
+ ok(f"El servidor respondió (tipo={resp.get('type')}) y sigue vivo")
+ else:
+ fail("No hubo respuesta al JSON inválido")
+ fallos += 1
+ # Verificar que el servidor sigue respondiendo
+ resp2 = server.send({"type": "infer", "prompt": "### Problem:\nhi\n### Solution:\n", "max_tokens": 20})
+ if resp2.get("type") == "infer_ok":
+ ok("Servidor sigue respondiendo después del JSON inválido")
+ else:
+ fail(f"El servidor dejó de responder: {resp2}")
+ fallos += 1
+ except Exception as exc:
+ fail(f"Excepción: {exc}")
+ fallos += 1
+
+ # ------------------------------------------------------------------
+ # Resumen
+ # ------------------------------------------------------------------
+ server.close()
+ section("RESUMEN")
+ if fallos == 0:
+ print(f" ✅ TODOS LOS TESTS PASARON (5/5)")
+ else:
+ print(f" ❌ {fallos} test(s) fallaron")
+
+ return fallos
+
+
+# ---------------------------------------------------------------------------
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Test de integración del servidor pampar.inference")
+ parser.add_argument(
+ "--checkpoint",
+ default=str(DEFAULT_CHECKPOINT),
+ help=f"Ruta al checkpoint .pt (default: {DEFAULT_CHECKPOINT})",
+ )
+ parser.add_argument(
+ "--device", default="auto", choices=["auto", "cpu", "cuda"],
+ help="Dispositivo de inferencia"
+ )
+ parser.add_argument(
+ "--timeout", type=int, default=90,
+ help="Segundos máximos para esperar READY del servidor"
+ )
+ args = parser.parse_args()
+
+ if not Path(args.checkpoint).exists():
+ print(f"ERROR: Checkpoint no encontrado: {args.checkpoint}")
+ print(f" Checkpoints disponibles:")
+ for pt in sorted((PROJECT_ROOT / "checkpoints").glob("*.pt")):
+ print(f" {pt.name}")
+ sys.exit(1)
+
+ fallos = run_tests(args.checkpoint, args.device, args.timeout)
+ sys.exit(0 if fallos == 0 else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/test_mixed_selectivity.py b/scripts/test_mixed_selectivity.py
new file mode 100644
index 0000000000000000000000000000000000000000..2164199e8a746e36f527d683799551613086e817
--- /dev/null
+++ b/scripts/test_mixed_selectivity.py
@@ -0,0 +1,57 @@
+"""Test de compilación y conteo de params para Mixed Selectivity."""
+
+import sys
+
+sys.path.insert(0, ".")
+
+from pampar.coder.v3.config import PRESET_V3, ConfigV3
+
+# Mixed Selectivity (nueva)
+cfg_ms = PRESET_V3
+params_ms = cfg_ms.estimate_params()
+print("=== Mixed Selectivity ===")
+for k, v in params_ms.items():
+ print(f" {k}: {v:>12,}")
+
+# Legacy (4 FFN separados)
+cfg_leg = ConfigV3(use_mixed_selectivity=False)
+params_leg = cfg_leg.estimate_params()
+print("\n=== Legacy (4 FFN) ===")
+for k, v in params_leg.items():
+ print(f" {k}: {v:>12,}")
+
+diff = params_leg["total"] - params_ms["total"]
+pct = diff / params_leg["total"] * 100
+print(f"\nAHORRO: {diff:,} params ({pct:.1f}%)")
+total_leg = params_leg["total"] / 1e6
+total_ms = params_ms["total"] / 1e6
+print(f"Legacy: {total_leg:.1f}M -> Mixed: {total_ms:.1f}M")
+
+# Test de instanciación del modelo completo
+print("\n=== Instanciando modelo con Mixed Selectivity... ===")
+import torch
+from pampar.coder.v3.modelo import PamparV3
+
+model = PamparV3(cfg_ms)
+real_params = sum(p.numel() for p in model.parameters())
+print(f"Parámetros reales: {real_params:,} ({real_params / 1e6:.1f}M)")
+
+# Test forward pass
+print("\n=== Forward pass... ===")
+input_ids = torch.randint(0, 48000, (1, 32))
+with torch.no_grad():
+ logits, loss, info = model(input_ids)
+print(f"logits shape: {logits.shape}")
+print(f"exit_nivel: {info['exit_nivel']}")
+print(f"terr_acts shape: {info['terr_acts'].shape}")
+
+# Verificar que el modelo tiene ffn_shared y modulators
+nivel0 = model.niveles[0]
+has_shared = hasattr(nivel0, "ffn_shared")
+has_mods = hasattr(nivel0, "modulators")
+has_legacy = hasattr(nivel0, "ffns")
+print(f"\nffn_shared: {has_shared}")
+print(f"modulators: {has_mods} (count: {len(nivel0.modulators) if has_mods else 0})")
+print(f"ffns (legacy): {has_legacy}")
+
+print("\n=== TODO OK ===")
diff --git a/scripts/train_v3.py b/scripts/train_v3.py
new file mode 100644
index 0000000000000000000000000000000000000000..cc7d53bb1f8259fdb0daa3990c37273e0f0eb987
--- /dev/null
+++ b/scripts/train_v3.py
@@ -0,0 +1,663 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BUSL-1.1
+# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
+"""
+train_v3.py — Entrenamiento autónomo de PamparV3.
+
+Arquitectura:
+ - MotorCuriosidad → elige qué tema estudiar (ZDP Vygotsky)
+ - LectorBiblioteca → devuelve batches de tokens
+ - PamparV3 → calcula logits y loss internamente
+ - Replay buffer → revisión de experiencias duras (deque)
+
+Uso rápido:
+ python scripts/train_v3.py
+ python scripts/train_v3.py --checkpoint checkpoints/run.pt --max-pasos 0
+ python scripts/train_v3.py --lr 3e-4 --seq-len 256 --batch-size 2
+
+Detener limpiamente con Ctrl-C — guarda el checkpoint antes de salir.
+"""
+
+import argparse
+import dataclasses
+import json
+import logging
+import math
+import sys
+import time
+from collections import deque
+from pathlib import Path
+from typing import Optional
+
+import sentencepiece as spm
+import torch
+import torch.nn.utils as nn_utils
+
+# ── Rutas relativas al script ──────────────────────────────────────────────────
+ROOT = Path(__file__).resolve().parent.parent # PAMPAr-Coder/
+sys.path.insert(0, str(ROOT))
+
+from pampar.coder.v3 import PRESET_V3, ConfigV3, PamparV3
+from pampar.memoria import ClasificadorPareto
+from pampar.training import LectorBiblioteca, MotorCuriosidad
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ datefmt="%H:%M:%S",
+)
+logger = logging.getLogger("train_v3")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Helpers
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+def _cargar_indice(ruta: Path) -> dict:
+ """Carga biblioteca/indice.json y valida el formato básico."""
+ if not ruta.exists():
+ raise FileNotFoundError(f"Índice no encontrado: {ruta}")
+ with ruta.open(encoding="utf-8") as f:
+ data = json.load(f)
+ return data
+
+
+def _construir_mapa_tema(indice: dict) -> dict[str, str]:
+ """
+ Devuelve {nombre_tema: ruta_relativa_jsonl}.
+ Ignora claves del índice que no sean listas.
+ """
+ mapa: dict[str, str] = {}
+ for _categoria, temas in indice.items():
+ if not isinstance(temas, list):
+ continue
+ for t in temas:
+ nombre = t.get("nombre", "")
+ archivo = t.get("archivo", "")
+ if nombre and archivo:
+ mapa[nombre] = archivo
+ return mapa
+
+
+def _pp_loss(loss: float) -> str:
+ """Formatea el loss con su perplexity."""
+ ppl = math.exp(min(loss, 20.0))
+ return f"loss={loss:.3f} ppl={ppl:.1f}"
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# ViajeIntelectualV3 — bucle de entrenamiento autónomo
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+class ReplayPareto:
+ """
+ Buffer de replay con prioridad Pareto.
+
+ En lugar de un deque FIFO, guarda los N tensores MÁS importantes
+ según ClasificadorPareto (densidad de patrones + novedad + loss).
+ Cuando está lleno, descarta el de menor importancia.
+ """
+
+ def __init__(self, maxlen: int = 256, tokenizer=None):
+ self._buffer: list[tuple[float, torch.Tensor]] = [] # (importancia, tensor)
+ self._maxlen = maxlen
+ self._clasificador = ClasificadorPareto()
+ self._tok = tokenizer
+ self._textos_recientes: list[str] = [] # Para calcular novedad
+
+ def _decodificar(self, tensor: torch.Tensor) -> str:
+ """Decodifica el primer ejemplo del batch a texto para clasificar."""
+ if self._tok is None:
+ return ""
+ try:
+ ids = tensor[0].tolist()
+ ids = [i for i in ids if i > 0][:200]
+ return self._tok.Decode(ids)
+ except Exception:
+ return ""
+
+ def agregar(self, tensor: torch.Tensor, loss: float) -> None:
+ """Clasifica el batch y lo añade si supera el umbral L1 de Pareto."""
+ texto = self._decodificar(tensor)
+ entrada = self._clasificador.clasificar(
+ texto,
+ tipo="codigo",
+ loss_modelo=loss,
+ fragmentos_existentes=self._textos_recientes[-20:],
+ )
+
+ if entrada.nivel < 1: # Descarta nivel 0 (no importante)
+ return
+
+ # Actualizar lista de textos recientes para cálculo de novedad
+ if texto:
+ self._textos_recientes.append(texto)
+ if len(self._textos_recientes) > 100:
+ self._textos_recientes.pop(0)
+
+ self._buffer.append((entrada.importancia, tensor.cpu()))
+
+ # Si supera capacidad, descartar el menos importante
+ if len(self._buffer) > self._maxlen:
+ self._buffer.sort(key=lambda x: x[0], reverse=True)
+ self._buffer = self._buffer[: self._maxlen]
+
+ def sample(self, device: torch.device) -> Optional[torch.Tensor]:
+ """Devuelve un tensor aleatorio sesgado hacia alta importancia."""
+ if not self._buffer:
+ return None
+ # Muestreo ponderado por importancia
+ pesos = [imp for imp, _ in self._buffer]
+ total = sum(pesos) or 1.0
+ rand = torch.rand(1).item() * total
+ acum = 0.0
+ for imp, tensor in self._buffer:
+ acum += imp
+ if rand <= acum:
+ return tensor.to(device)
+ return self._buffer[-1][1].to(device)
+
+ def __len__(self) -> int:
+ return len(self._buffer)
+
+ def stats(self) -> dict:
+ if not self._buffer:
+ return {"size": 0, "imp_media": 0.0, "imp_max": 0.0}
+ imps = [imp for imp, _ in self._buffer]
+ return {
+ "size": len(self._buffer),
+ "imp_media": round(sum(imps) / len(imps), 3),
+ "imp_max": round(max(imps), 3),
+ }
+
+
+class ViajeIntelectualV3:
+ """
+ Orquesta el ciclo estudiar → retroalimentar → avanzar de PamparV3.
+
+ Args:
+ modelo: Instancia de PamparV3 ya en `device`.
+ optimizer: Optimizer Torch (AdamW recomendado).
+ motor: MotorCuriosidad con temas ya registrados.
+ biblioteca: LectorBiblioteca apuntando a biblioteca/.
+ mapa_tema: {nombre → ruta_relativa_jsonl}
+ device: Dispositivo de entrenamiento.
+ ruta_ckpt: Ruta del archivo de checkpoint.
+ paso_global: Paso inicial (0 si es nuevo entrenamiento).
+ replay_cada: Pasos entre cada sesión de replay. 0 = desactivado.
+ guardar_cada: Pasos entre checkpoints.
+ pasos_por_tema: Gradients steps por sesión de tema.
+ max_grad_norm: Clip de gradiente.
+ replay_size: Nº muestras máximas en el buffer de replay.
+ tokenizer: SentencePieceProcessor para decodificar textos en el replay.
+ """
+
+ def __init__(
+ self,
+ modelo: PamparV3,
+ optimizer: torch.optim.Optimizer,
+ motor: MotorCuriosidad,
+ biblioteca: LectorBiblioteca,
+ mapa_tema: dict[str, str],
+ device: torch.device,
+ ruta_ckpt: Path,
+ paso_global: int = 0,
+ replay_cada: int = 500,
+ guardar_cada: int = 200,
+ pasos_por_tema: int = 20,
+ max_grad_norm: float = 1.0,
+ replay_size: int = 256,
+ tokenizer=None,
+ ) -> None:
+ self.modelo = modelo
+ self.optimizer = optimizer
+ self.motor = motor
+ self.biblioteca = biblioteca
+ self.mapa_tema = mapa_tema
+ self.device = device
+ self.ruta_ckpt = ruta_ckpt
+
+ self.paso_global = paso_global
+ self.replay_cada = replay_cada
+ self.guardar_cada = guardar_cada
+ self.pasos_por_tema = pasos_por_tema
+ self.max_grad_norm = max_grad_norm
+
+ # Replay buffer con prioridad Pareto (reemplaza el deque simple)
+ self._replay_buffer = ReplayPareto(maxlen=replay_size, tokenizer=tokenizer)
+
+ # Historial de losses para el banner
+ self._historial_loss: deque[float] = deque(maxlen=100)
+
+ # Estadísticas
+ self._temas_estudiados = 0
+ self._tiempo_inicio = time.time()
+
+ # ── Paso de entrenamiento ─────────────────────────────────────────────────
+
+ def _gradiente(self, tokens: torch.Tensor) -> dict:
+ """
+ Realiza un paso de gradiente y devuelve métricas.
+
+ Args:
+ tokens: Tensor [B, L] con tokens de entrada + target.
+ Returns:
+ Dict con loss, exit_nivel, terr_ratio.
+ """
+ self.modelo.train()
+ self.optimizer.zero_grad(set_to_none=True)
+
+ input_ids = tokens[:, :-1]
+ targets = tokens[:, 1:]
+
+ logits, loss, info = self.modelo(input_ids, targets=targets)
+
+ loss.backward()
+ nn_utils.clip_grad_norm_(self.modelo.parameters(), self.max_grad_norm)
+ self.optimizer.step()
+
+ loss_val = float(loss.detach())
+ self._historial_loss.append(loss_val)
+
+ # Extraer métricas de salida temprana
+ terr_acts = info.get("terr_acts", None)
+ terr_ratio = float(terr_acts.float().mean()) if terr_acts is not None else 0.0
+
+ return {
+ "loss": loss_val,
+ "exit_nivel": info.get("exit_nivel", -1),
+ "terr_ratio": terr_ratio,
+ }
+
+ # ── Paso de replay ────────────────────────────────────────────────────────
+
+ def _paso_replay(self) -> Optional[float]:
+ """Repasa una muestra del buffer Pareto (sesgado a alta importancia)."""
+ batch = self._replay_buffer.sample(self.device)
+ if batch is None:
+ return None
+ metricas = self._gradiente(batch)
+ return metricas["loss"]
+
+ def _agregar_a_replay(self, tokens: torch.Tensor, loss: float) -> None:
+ """Clasifica el batch con Pareto y lo añade si es suficientemente importante."""
+ self._replay_buffer.agregar(tokens, loss)
+
+ # ── Guardado ──────────────────────────────────────────────────────────────
+
+ def _guardar(self, ruta_motor: Path) -> None:
+ """Guarda checkpoint del modelo + estado del motor."""
+ self.ruta_ckpt.parent.mkdir(parents=True, exist_ok=True)
+
+ payload = {
+ "modelo": self.modelo.state_dict(),
+ "optimizer": self.optimizer.state_dict(),
+ "paso_global": self.paso_global,
+ "config": dataclasses.asdict(self.modelo.config),
+ }
+ torch.save(payload, self.ruta_ckpt)
+
+ self.motor.guardar(ruta_motor)
+ logger.info("✓ Checkpoint guardado → paso %d", self.paso_global)
+
+ # ── Banner ────────────────────────────────────────────────────────────────
+
+ def _banner(self, nombre_tema: str, loss: float) -> None:
+ """Imprime un resumen compacto del estado actual."""
+ res = self.motor.resumen()
+ elapsed = time.time() - self._tiempo_inicio
+ hh = int(elapsed // 3600)
+ mm = int((elapsed % 3600) // 60)
+
+ avg_loss = (
+ sum(self._historial_loss) / len(self._historial_loss)
+ if self._historial_loss
+ else 0.0
+ )
+
+ logger.info(
+ "[paso %6d %02dh%02dm] nivel=%d dom=%d/%d %s avg100=%.3f tema=%s",
+ self.paso_global,
+ hh,
+ mm,
+ res["nivel_actual"],
+ res["temas_dominados"],
+ res["temas_total"],
+ _pp_loss(loss),
+ avg_loss,
+ nombre_tema,
+ )
+
+ # ── Bucle principal ───────────────────────────────────────────────────────
+
+ def estudiar(self, max_pasos: int = 0, ruta_motor: Optional[Path] = None) -> None:
+ """
+ Entra en el bucle de entrenamiento autónomo.
+
+ Args:
+ max_pasos: Límite de pasos totales. 0 = infinito (Ctrl-C para parar).
+ ruta_motor: Ruta donde persistir el estado del motor de curiosidad.
+ """
+ if ruta_motor is None:
+ ruta_motor = self.ruta_ckpt.parent / "motor_v3.json"
+
+ logger.info(
+ "Iniciando ViajeIntelectualV3 — %s",
+ "∞ pasos" if max_pasos == 0 else f"{max_pasos} pasos",
+ )
+ logger.info("Checkpoint → %s", self.ruta_ckpt)
+
+ try:
+ self._bucle_principal(max_pasos, ruta_motor)
+ except KeyboardInterrupt:
+ logger.info("\nInterrumpido por el usuario — guardando...")
+ finally:
+ self._guardar(ruta_motor)
+
+ res = self.motor.resumen()
+ print(
+ f"\n── Fin del viaje ──"
+ f"\n Pasos totales : {self.paso_global}"
+ f"\n Temas dominados: {res['temas_dominados']}/{res['temas_total']}"
+ f"\n Nivel actual : {res['nivel_actual']}"
+ )
+
+ def _bucle_principal(self, max_pasos: int, ruta_motor: Path) -> None:
+ """Bucle interno de entrenamiento tema a tema."""
+ pasos_sin_tema = 0
+
+ while True:
+ # ── 1. ELEGIR ────────────────────────────────────────────────────
+ nombre_tema = self.motor.siguiente_tema()
+ if nombre_tema is None:
+ pasos_sin_tema += 1
+ if pasos_sin_tema > 10:
+ logger.warning("Sin temas disponibles — esperando 5s...")
+ time.sleep(5)
+ continue
+ pasos_sin_tema = 0
+
+ archivo = self.mapa_tema.get(nombre_tema)
+ if not archivo:
+ logger.debug("Tema '%s' sin archivo mapeado, saltando.", nombre_tema)
+ self.motor.retroalimentar(nombre_tema, 4.0) # penalizar
+ continue
+
+ # ── 2. SESIÓN DE TEMA ────────────────────────────────────────────
+ losses_sesion: list[float] = []
+
+ for _ in range(self.pasos_por_tema):
+ # ── 2a. LEER
+ tokens = self.biblioteca.obtener_batch(archivo, self.device)
+ if tokens is None:
+ break
+
+ # ── 2b. GRADIENTE
+ metricas = self._gradiente(tokens)
+ loss = metricas["loss"]
+ losses_sesion.append(loss)
+ self.paso_global += 1
+
+ # ── Log en vivo cada 10 pasos
+ if self.paso_global % 10 == 0:
+ logger.info(
+ "paso %d | %s | tema=%s",
+ self.paso_global,
+ _pp_loss(loss),
+ nombre_tema,
+ )
+
+ # ── 2c. REPLAY BUFFER (clasificado con Pareto)
+ self._agregar_a_replay(tokens, loss)
+
+ # ── 2d. REPLAY periódico
+ if self.replay_cada > 0 and self.paso_global % self.replay_cada == 0:
+ rl = self._paso_replay()
+ if rl is not None:
+ logger.debug("Replay loss: %.3f", rl)
+
+ # ── 2e. GUARDAR periódico
+ if self.paso_global % self.guardar_cada == 0:
+ self._guardar(ruta_motor)
+
+ # ── 2f. Límite de pasos
+ if max_pasos > 0 and self.paso_global >= max_pasos:
+ return
+
+ # ── 3. FEEDBACK ──────────────────────────────────────────────────
+ if losses_sesion:
+ loss_media = sum(losses_sesion) / len(losses_sesion)
+ self.motor.retroalimentar(nombre_tema, loss_media)
+ self._temas_estudiados += 1
+
+ if self._temas_estudiados % 5 == 0:
+ self._banner(nombre_tema, loss_media)
+ replay_st = self._replay_buffer.stats()
+ if replay_st["size"] > 0:
+ logger.info(
+ "ReplayPareto: %d muestras imp_media=%.3f imp_max=%.3f",
+ replay_st["size"],
+ replay_st["imp_media"],
+ replay_st["imp_max"],
+ )
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# CLI
+# ─────────────────────────────────────────────────────────────────────────────
+
+
+def _parse_args() -> argparse.Namespace:
+ p = argparse.ArgumentParser(
+ description="Entrenamiento autónomo de PamparV3",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+
+ # Rutas
+ p.add_argument(
+ "--checkpoint",
+ type=Path,
+ default=ROOT / "checkpoints" / "v3_train.pt",
+ help="Ruta del checkpoint a guardar/reanudar",
+ )
+ p.add_argument(
+ "--tokenizer",
+ type=Path,
+ default=ROOT / "data" / "tokenizer" / "pampar_48k.model",
+ help="Modelo SentencePiece 48K",
+ )
+ p.add_argument(
+ "--biblioteca",
+ type=Path,
+ default=ROOT / "biblioteca",
+ help="Raíz de la biblioteca de conocimiento",
+ )
+ p.add_argument(
+ "--indice",
+ type=Path,
+ default=None,
+ help="Ruta a indice.json (por defecto: biblioteca/indice.json)",
+ )
+ p.add_argument(
+ "--estado",
+ type=Path,
+ default=None,
+ help="Ruta para el estado del MotorCuriosidad (JSON)",
+ )
+
+ # Hiperparámetros
+ p.add_argument("--lr", type=float, default=3e-4, help="Tasa de aprendizaje (AdamW)")
+ p.add_argument(
+ "--pasos-por-tema", type=int, default=20, help="Gradients steps por sesión"
+ )
+ p.add_argument(
+ "--replay-cada", type=int, default=500, help="Pasos entre replay. 0=desactivado"
+ )
+ p.add_argument(
+ "--guardar-cada", type=int, default=200, help="Pasos entre checkpoints"
+ )
+ p.add_argument(
+ "--max-pasos", type=int, default=0, help="Límite total de pasos. 0=infinito"
+ )
+ p.add_argument("--seq-len", type=int, default=512, help="max_seq_len del lector")
+ p.add_argument("--batch-size", type=int, default=2, help="Tamaño de batch")
+ p.add_argument("--max-grad-norm", type=float, default=1.0, help="Clip de gradiente")
+ p.add_argument(
+ "--replay-size", type=int, default=256, help="Tamaño del replay buffer"
+ )
+
+ # Dispositivo
+ p.add_argument(
+ "--device",
+ type=str,
+ default="auto",
+ help="'auto' (cuda si disponible), 'cuda', 'cpu'",
+ )
+
+ return p.parse_args()
+
+
+def _resolver_device(arg: str) -> torch.device:
+ if arg == "auto":
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ return torch.device(arg)
+
+
+def _cargar_o_init_modelo(
+ ruta_ckpt: Path, device: torch.device
+) -> tuple[PamparV3, int]:
+ """
+ Carga modelo desde checkpoint o inicializa uno nuevo.
+
+ Returns:
+ (modelo, paso_global)
+ """
+ if ruta_ckpt.exists():
+ logger.info("Reanudando desde %s", ruta_ckpt)
+ payload = torch.load(ruta_ckpt, map_location=device, weights_only=False)
+ config = ConfigV3(**payload["config"]) if "config" in payload else PRESET_V3
+ modelo = PamparV3(config).to(device)
+ modelo.load_state_dict(payload["modelo"])
+ paso = int(payload.get("paso_global", 0))
+ logger.info("Modelo cargado — paso %d", paso)
+ return modelo, paso
+ else:
+ logger.info("Nuevo entrenamiento con PRESET_V3")
+ modelo = PamparV3(PRESET_V3).to(device)
+ return modelo, 0
+
+
+def main() -> None:
+ args = _parse_args()
+
+ device = _resolver_device(args.device)
+ logger.info("Device: %s", device)
+ if device.type == "cuda":
+ logger.info(
+ "GPU: %s (%.1f GiB VRAM)",
+ torch.cuda.get_device_name(0),
+ torch.cuda.get_device_properties(0).total_memory / 1e9,
+ )
+
+ # ── Tokenizer ─────────────────────────────────────────────────────────────
+ if not args.tokenizer.exists():
+ logger.error("Tokenizer no encontrado: %s", args.tokenizer)
+ sys.exit(1)
+
+ tok = spm.SentencePieceProcessor()
+ tok.Load(str(args.tokenizer))
+ vocab_size = tok.GetPieceSize()
+ logger.info("Tokenizer cargado — vocab=%d", vocab_size)
+
+ # ── Modelo ────────────────────────────────────────────────────────────────
+ modelo, paso_global = _cargar_o_init_modelo(args.checkpoint, device)
+
+ if modelo.config.vocab_size != vocab_size:
+ logger.error(
+ "vocab_size mismatch: modelo=%d, tokenizer=%d",
+ modelo.config.vocab_size,
+ vocab_size,
+ )
+ sys.exit(1)
+
+ n_params = sum(p.numel() for p in modelo.parameters() if p.requires_grad)
+ logger.info("PamparV3 — %.1fM parámetros", n_params / 1e6)
+
+ # ── Optimizer ─────────────────────────────────────────────────────────────
+ optimizer = torch.optim.AdamW(
+ modelo.parameters(),
+ lr=args.lr,
+ betas=(0.9, 0.95),
+ weight_decay=0.1,
+ eps=1e-8,
+ )
+ if args.checkpoint.exists():
+ payload = torch.load(args.checkpoint, map_location="cpu", weights_only=False)
+ if "optimizer" in payload:
+ try:
+ optimizer.load_state_dict(payload["optimizer"])
+ except Exception as e:
+ logger.warning("No se pudo restaurar optimizer: %s", e)
+
+ # ── Biblioteca ────────────────────────────────────────────────────────────
+ if not args.biblioteca.exists():
+ logger.error("Biblioteca no encontrada: %s", args.biblioteca)
+ sys.exit(1)
+
+ indice_path = args.indice or (args.biblioteca / "indice.json")
+ indice = _cargar_indice(indice_path)
+ mapa_tema = _construir_mapa_tema(indice)
+ logger.info("Índice cargado — %d temas mapeados", len(mapa_tema))
+
+ biblioteca = LectorBiblioteca(
+ raiz=args.biblioteca,
+ tokenizer=tok,
+ max_seq_len=args.seq_len,
+ batch_size=args.batch_size,
+ )
+
+ # ── Motor de curiosidad ────────────────────────────────────────────────────
+ ruta_motor = args.estado or (args.checkpoint.parent / "motor_v3.json")
+ # Fresh start: no cargar estado previo del motor (pertenece a otro modelo)
+ if not args.checkpoint.exists() and Path(ruta_motor).exists():
+ logger.info("Fresh start — motor reiniciado (ignorando %s)", ruta_motor.name)
+ motor = MotorCuriosidad(ruta_estado=None, nivel_actual=1)
+ motor.ruta_estado = ruta_motor # guardar aquí en adelante
+ else:
+ motor = MotorCuriosidad(ruta_estado=ruta_motor, nivel_actual=1)
+
+ n_nuevos = motor.registrar_temas_desde_indice(indice)
+ logger.info("Motor listo — %d temas nuevos registrados", n_nuevos)
+ res = motor.resumen()
+ logger.info(
+ "Estado motor: nivel=%d dominados=%d/%d",
+ res["nivel_actual"],
+ res["temas_dominados"],
+ res["temas_total"],
+ )
+
+ # ── Viaje ─────────────────────────────────────────────────────────────────
+ viaje = ViajeIntelectualV3(
+ modelo=modelo,
+ optimizer=optimizer,
+ motor=motor,
+ biblioteca=biblioteca,
+ mapa_tema=mapa_tema,
+ device=device,
+ ruta_ckpt=args.checkpoint,
+ paso_global=paso_global,
+ replay_cada=args.replay_cada,
+ guardar_cada=args.guardar_cada,
+ pasos_por_tema=args.pasos_por_tema,
+ max_grad_norm=args.max_grad_norm,
+ replay_size=args.replay_size,
+ tokenizer=tok,
+ )
+
+ viaje.estudiar(max_pasos=args.max_pasos, ruta_motor=ruta_motor)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/visualizar_flujo.py b/scripts/visualizar_flujo.py
new file mode 100644
index 0000000000000000000000000000000000000000..26b6f065ece8473a9c2893345a4ed5e1c4a5cb40
--- /dev/null
+++ b/scripts/visualizar_flujo.py
@@ -0,0 +1,454 @@
+#!/usr/bin/env python3
+"""
+visualizar_flujo.py — Visualizador interactivo del flujo de datos en PamparV3.
+
+Genera un HTML standalone con 4 paneles:
+ 1. Embeddings iniciales (PCA 2D) — dónde vive cada token en el espacio
+ 2. Mapa de atención por nivel — qué tokens miran a cuáles
+ 3. Norma de activaciones por nivel/stream — cómo crece/decae la señal
+ 4. Benchmark de velocidad y memoria
+
+Uso:
+ python visualizar_flujo.py "Hola, me llamo Pampar"
+ python visualizar_flujo.py --texto "def suma(a, b): return a + b"
+ python visualizar_flujo.py # usa texto por defecto
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+import time
+from pathlib import Path
+
+import torch
+import torch.nn.functional as F
+
+# ── Setup de paths ──────────────────────────────────────────────────────────
+ROOT = Path(__file__).parent.parent
+sys.path.insert(0, str(ROOT))
+sys.path.insert(0, str(Path(__file__).parent))
+
+# ── Captura de activaciones via hooks ───────────────────────────────────────
+
+
+class ActivationCapture:
+ """Registra hooks en PamparV3 y captura tensores del forward pass."""
+
+ def __init__(self):
+ self.handles: list = []
+ self.embeddings: torch.Tensor | None = None # [B, L, dim]
+ self.nivel_outputs: list[torch.Tensor] = [] # por nivel: [B, L, dim]
+ self.attn_weights: list[torch.Tensor] = [] # por nivel: [B, H, L, L]
+ self.stream_norms: list[list[float]] = [] # por nivel: [4 streams]
+
+ def attach(self, model) -> None:
+ """Registra hooks en el modelo."""
+
+ # Hook en embedding
+ def hook_emb(module, inp, out):
+ self.embeddings = out.detach().cpu()
+
+ self.handles.append(model.tok_emb.register_forward_hook(hook_emb))
+
+ # Hook en cada NivelProfundo
+ for i, nivel in enumerate(model.niveles):
+
+ def make_nivel_hook(idx):
+ def hook(module, inp, out):
+ # out = (streams_list, terr_acts, conf)
+ # streams_list es una lista de n_streams tensores [B, L, D]
+ streams_out = out[0] if isinstance(out, (tuple, list)) else out
+ if isinstance(streams_out, (list, tuple)):
+ x = torch.stack([s.detach().cpu() for s in streams_out]).mean(0)
+ else:
+ x = streams_out.detach().cpu()
+ self.nivel_outputs.append(x)
+
+ # Norma por stream (cada cuarto del dim como proxy de stream)
+ B, L, D = x.shape
+ chunk = max(1, D // 4)
+ norms = [
+ x[:, :, i * chunk : min((i + 1) * chunk, D)]
+ .norm(dim=-1)
+ .mean()
+ .item()
+ for i in range(4)
+ ]
+ self.stream_norms.append(norms)
+
+ return hook
+
+ self.handles.append(nivel.register_forward_hook(make_nivel_hook(i)))
+
+ # Hook en la atención del nivel
+ def make_attn_hook(idx):
+ def hook(module, inp, out):
+ # Recalcular pesos de atención desde el input
+ x = inp[0]
+ B, L, D = x.shape
+ H = module.n_heads
+ Hkv = module.n_kv_heads
+ head_dim = module.head_dim
+
+ # Q, K usando nombres reales del BloqueAttn
+ q = module.q_proj(x).view(B, L, H, head_dim).transpose(1, 2)
+ k = module.k_proj(x).view(B, L, Hkv, head_dim).transpose(1, 2)
+ k = module._repeat_kv(k) # GQA expand
+
+ scale = head_dim**-0.5
+ scores = (
+ torch.matmul(q.float(), k.float().transpose(-2, -1)) * scale
+ )
+ # Máscara causal
+ mask = torch.triu(
+ torch.ones(L, L, device=x.device), diagonal=1
+ ).bool()
+ scores = scores.masked_fill(
+ mask.unsqueeze(0).unsqueeze(0), float("-inf")
+ )
+ weights = F.softmax(scores, dim=-1)
+ self.attn_weights.append(weights.detach().cpu())
+
+ return hook
+
+ self.handles.append(nivel.attn.register_forward_hook(make_attn_hook(i)))
+
+ def detach(self) -> None:
+ for h in self.handles:
+ h.remove()
+ self.handles.clear()
+
+
+# ── PCA manual (sin sklearn) ────────────────────────────────────────────────
+
+
+def pca_2d(matrix: torch.Tensor) -> torch.Tensor:
+ """Reduce [N, D] a [N, 2] via PCA (SVD)."""
+ m = matrix.float()
+ m = m - m.mean(0, keepdim=True)
+ _, _, V = torch.svd(m)
+ return m @ V[:, :2]
+
+
+# ── Generación del HTML ─────────────────────────────────────────────────────
+
+
+def build_html(
+ tokens: list[str],
+ capture: ActivationCapture,
+ text: str,
+ elapsed_ms: float,
+ mem_mb: float,
+) -> str:
+
+ n_tokens = len(tokens)
+ n_levels = len(capture.attn_weights)
+
+ # ── Datos embedding PCA ─────────────────────────────────────────────────
+ emb = capture.embeddings[0] # [L, dim]
+ if n_tokens >= 2:
+ coords = pca_2d(emb).tolist()
+ else:
+ coords = [[0.0, 0.0]] * n_tokens
+
+ emb_x = [c[0] for c in coords]
+ emb_y = [c[1] for c in coords]
+ emb_norm = emb.norm(dim=-1).tolist()
+
+ # ── Datos atención — promedio de cabezas por nivel ───────────────────────
+ attn_data = []
+ for lvl_w in capture.attn_weights:
+ # lvl_w: [B, H, L, L] → promedio de H → [L, L]
+ avg = lvl_w[0].mean(0).tolist()
+ attn_data.append(avg)
+
+ # ── Normas por nivel/stream ──────────────────────────────────────────────
+ stream_norms = capture.stream_norms # [[s0,s1,s2,s3], ...] por nivel
+
+ # ── Serializar a JSON inline ─────────────────────────────────────────────
+ import json
+
+ tokens_json = json.dumps(tokens)
+ emb_x_json = json.dumps(emb_x)
+ emb_y_json = json.dumps(emb_y)
+ emb_norm_json = json.dumps(emb_norm)
+ attn_json = json.dumps(attn_data)
+ stream_norms_json = json.dumps(stream_norms)
+ n_levels_json = n_levels
+ elapsed_json = elapsed_ms
+ mem_json = mem_mb
+ text_escaped = text.replace('"', '\\"')
+
+ return f"""
+
+
+
+PamparV3 — Flujo de datos
+
+
+
+
+PamparV3 — Visualizador de Flujo Interno
+Arquitectura: 640d · {n_levels_json} niveles · 4 streams · GQA
+
+"{text_escaped}"
+
+
+
+
+
{elapsed_ms:.1f}ms
+
forward pass
+
+
+
{mem_mb:.0f}MB
+
VRAM usada
+
+
+
{n_levels_json}
+
niveles de profundidad
+
+
+
+
+
+
1. Embeddings iniciales (PCA 2D)
+
+
+
+
2. Mapa de atención
+
+
+
+
+
+
3. Norma de activaciones por stream
+
+
+
+
4. Distancia que recorre cada token
+
+
+
+
+
+
+"""
+
+
+# ── Main ─────────────────────────────────────────────────────────────────────
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Visualizador de flujo PamparV3")
+ parser.add_argument(
+ "texto",
+ nargs="?",
+ default="Hola me llamo Pampar y aprendo a programar",
+ help="Texto a analizar",
+ )
+ parser.add_argument(
+ "--texto", dest="texto_flag", help="Alternativa: --texto 'tu frase'"
+ )
+ parser.add_argument("--checkpoint", default="checkpoints/v3_classroom.pt")
+ parser.add_argument("--out", default="sessions/flujo_pampar.html")
+ args = parser.parse_args()
+
+ text = args.texto_flag or args.texto
+
+ # ── Cargar modelo ─────────────────────────────────────────────────────────
+ print(f"Cargando modelo desde {args.checkpoint}...")
+ import sentencepiece as spm
+ from pampar.coder.v3.config import PRESET_V3
+ from pampar.coder.v3.modelo import PamparV3
+
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ tok = spm.SentencePieceProcessor()
+ tok.Load(str(ROOT / "data" / "tokenizer" / "pampar_48k.model"))
+
+ model = PamparV3(PRESET_V3).to(device)
+ ckpt_path = ROOT / args.checkpoint
+ if ckpt_path.exists():
+ ckpt = torch.load(str(ckpt_path), map_location=device, weights_only=False)
+ state = ckpt.get("modelo", ckpt.get("model", ckpt))
+ model.load_state_dict(state, strict=False)
+ print(f" Checkpoint cargado: {ckpt_path.name}")
+ else:
+ print(f" Checkpoint no encontrado, usando pesos iniciales")
+
+ model.registrar_tokenizer(tok)
+ model.eval()
+
+ # ── Forward pass con hooks ────────────────────────────────────────────────
+ cap = ActivationCapture()
+ cap.attach(model)
+
+ ids = tok.Encode(text)
+ tokens_str = [tok.IdToPiece(i).replace("▁", " ").strip() or "" for i in ids]
+ input_ids = torch.tensor([ids], dtype=torch.long, device=device)
+
+ # Medir memoria antes
+ if device.type == "cuda":
+ torch.cuda.reset_peak_memory_stats()
+
+ t0 = time.perf_counter()
+ with torch.no_grad():
+ logits, _, _ = model(input_ids)
+ elapsed_ms = (time.perf_counter() - t0) * 1000
+
+ if device.type == "cuda":
+ mem_mb = torch.cuda.max_memory_allocated() / 1024**2
+ else:
+ import os
+
+ import psutil
+
+ proc = psutil.Process(os.getpid())
+ mem_mb = proc.memory_info().rss / 1024**2
+
+ cap.detach()
+
+ # ── Top-5 predicciones del último token ──────────────────────────────────
+ last_logits = logits[0, -1]
+ top5 = last_logits.topk(5)
+ print(f"\nTexto: '{text}'")
+ print(f"Tokens ({len(ids)}): {tokens_str}")
+ print(f"Forward pass: {elapsed_ms:.1f}ms | Memoria: {mem_mb:.0f}MB")
+ print(f"\nTop-5 siguiente token:")
+ for score, idx in zip(top5.values.tolist(), top5.indices.tolist()):
+ piece = tok.IdToPiece(idx).replace("▁", " ")
+ prob = torch.softmax(last_logits, dim=0)[idx].item()
+ print(f" '{piece}' — {prob * 100:.1f}%")
+
+ # ── Generar HTML ──────────────────────────────────────────────────────────
+ out_path = ROOT / args.out
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ html = build_html(tokens_str, cap, text, elapsed_ms, mem_mb)
+ out_path.write_text(html, encoding="utf-8")
+ print(f"\nHTML generado: {out_path}")
+ print("Abre ese archivo en Chrome/Edge para ver la visualización.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..5c8c366ad97999e9e5ac274233b19d5f5da23c31
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,134 @@
+# SPDX-License-Identifier: BUSL-1.1
+"""
+Fixtures compartidas para la suite de tests de PAMPAr-Coder v3.
+
+Patrón:
+ - Usar PRESET_V3_SMALL para tests de arquitectura (más rápido, misma API)
+ - Usar tmp_path de pytest para aislamiento de I/O
+ - No depender de checkpoints pre-entrenados ni GPU
+"""
+
+import sys
+import tempfile
+from pathlib import Path
+
+import pytest
+import torch
+
+# Asegurar que el proyecto raíz esté en el PATH
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from pampar.coder.v3.config import ConfigV3
+from pampar.coder.v3.modelo import PamparV3
+from pampar.memoria.clasificador import ClasificadorPareto
+
+
+# =============================================================================
+# PRESETS DE CONFIGURACIÓN
+# =============================================================================
+
+@pytest.fixture(scope="session")
+def config_small() -> ConfigV3:
+ """
+ Configuración mínima para tests rápidos.
+
+ Misma API que PRESET_V3 pero órdenes de magnitud más pequeña.
+ No usa gradient checkpointing (innecesario sin backward en tests).
+ """
+ return ConfigV3(
+ vocab_size=256,
+ dim=64,
+ n_streams=4,
+ n_levels=3,
+ n_heads=4,
+ n_kv_heads=2,
+ ffn_mult=2.0,
+ n_zonas=52, # Hardcodeado en v2 LLAVES — NO cambiar
+ n_territorios=4,
+ lateral_bottleneck=16,
+ ventana_contexto=4,
+ max_seq_len=64,
+ dropout=0.0, # Sin dropout en eval
+ use_checkpoint=False, # Sin gradient checkpointing en tests
+ )
+
+
+# =============================================================================
+# MODELO
+# =============================================================================
+
+@pytest.fixture(scope="session")
+def modelo(config_small: ConfigV3) -> PamparV3:
+ """
+ Instancia de PamparV3 inicializada aleatoriamente.
+
+ Session-scoped: compartida en todos los tests de arquitectura,
+ creada una sola vez (evita overhead de init 4× streams × 3 niveles).
+ """
+ m = PamparV3(config_small)
+ m.eval()
+ return m
+
+
+# =============================================================================
+# ENTRADAS DE PRUEBA
+# =============================================================================
+
+@pytest.fixture
+def tokens_cortos(config_small: ConfigV3) -> torch.Tensor:
+ """Batch de 2 secuencias de 16 tokens dentro del vocab pequeño."""
+ return torch.randint(0, config_small.vocab_size, (2, 16))
+
+
+@pytest.fixture
+def tokens_single(config_small: ConfigV3) -> torch.Tensor:
+ """Una sola secuencia de 8 tokens para generate()."""
+ return torch.randint(0, config_small.vocab_size, (1, 8))
+
+
+# =============================================================================
+# CLASIFICADOR PARETO
+# =============================================================================
+
+@pytest.fixture
+def clasificador() -> ClasificadorPareto:
+ """ClasificadorPareto sin dependencias externas."""
+ return ClasificadorPareto()
+
+
+# =============================================================================
+# CÓDIGO EJEMPLO (fixture reutilizable en test_skills y test_memoria)
+# =============================================================================
+
+CODIGO_RICO = '''
+from typing import List, Optional
+import asyncio
+from dataclasses import dataclass
+
+@dataclass
+class Resultado:
+ """Resultado de la evaluación."""
+ valor: float
+ exitoso: bool = True
+ errores: List[str] = None
+
+ def __post_init__(self):
+ if self.errores is None:
+ self.errores = []
+
+async def evaluar(items: List[str], max_items: Optional[int] = None) -> Resultado:
+ """Evalúa una lista de items de forma asíncrona."""
+ try:
+ resultado = [x for x in items if x.strip()]
+ if max_items:
+ resultado = resultado[:max_items]
+ return Resultado(valor=len(resultado) / len(items))
+ except ZeroDivisionError as e:
+ return Resultado(valor=0.0, exitoso=False, errores=[str(e)])
+'''.strip()
+
+
+@pytest.fixture
+def codigo_rico() -> str:
+ """Fragmento Python denso: dataclass, async, type hints, comprehension, try/except."""
+ return CODIGO_RICO
diff --git a/tests/test_generar_agents.py b/tests/test_generar_agents.py
new file mode 100644
index 0000000000000000000000000000000000000000..f009b42eacd46c7848c3b41535cbffde4f81a48f
--- /dev/null
+++ b/tests/test_generar_agents.py
@@ -0,0 +1,207 @@
+# SPDX-License-Identifier: BUSL-1.1
+"""
+Tests de pampar.runtime.generar_agents — Milestone 3: generador determinista
+del AGENTS.md contextual.
+
+Cubren:
+ - generar_agents_md produce string no vacío
+ - Secciones principales presentes (Quick Reference, Sistema, Boot protocol)
+ - Info de GPU se incluye cuando está presente
+ - Rama CPU-only funciona
+ - Paquetes clave filtrados correctamente
+ - Servicios activos/inactivos listados
+"""
+
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from pampar.runtime.generar_agents import generar_agents_md
+from pampar.runtime.scanner import InfoArchivo, InfoSistema, ResultadoScan
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Fixtures
+# ──────────────────────────────────────────────────────────────────────────────
+
+@pytest.fixture
+def scan_con_gpu() -> ResultadoScan:
+ return ResultadoScan(
+ workspace_root="/workspace",
+ archivos=[
+ InfoArchivo(ruta="scripts/train.py", funciones=["main", "entrenar"], clases=[], lineas=150),
+ InfoArchivo(ruta="pampar/__init__.py", funciones=[], clases=[], lineas=10),
+ ],
+ lenguajes={"Python": 2},
+ paquetes={
+ "torch": "2.5.1",
+ "transformers": "4.47.1",
+ "peft": "0.13.2",
+ "sentencepiece": "0.2.0",
+ "colorama": "0.4.6", # no es clave → no debe aparecer
+ },
+ servicios={"PostgreSQL": True, "Redis": False, "HTTP-8000": True},
+ sistema=InfoSistema(
+ os="Linux Ubuntu 22.04",
+ os_version="22.04",
+ python_version="3.11.9",
+ arquitectura="x86_64",
+ gpu="NVIDIA GeForce RTX 4090",
+ vram_mb=24576,
+ ram_gb=64.0,
+ ),
+ voz=["espeak"],
+ )
+
+
+@pytest.fixture
+def scan_sin_gpu() -> ResultadoScan:
+ return ResultadoScan(
+ workspace_root="/workspace",
+ archivos=[],
+ lenguajes={},
+ paquetes={
+ "fastapi": "0.115.0",
+ "uvicorn": "0.32.0",
+ "pydantic": "2.10.0",
+ },
+ servicios={"PostgreSQL": False, "Redis": False},
+ sistema=InfoSistema(
+ os="macOS 14.0 Sonoma",
+ os_version="14.0",
+ python_version="3.12.4",
+ arquitectura="arm64",
+ gpu=None,
+ vram_mb=None,
+ ram_gb=16.0,
+ ),
+ voz=[],
+ )
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Tests
+# ──────────────────────────────────────────────────────────────────────────────
+
+class TestGenerarAgentsMd:
+ def test_retorna_string_no_vacio(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ assert isinstance(md, str)
+ assert len(md) > 100
+
+ def test_tiene_seccion_quick_reference(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ assert "## Quick Reference" in md
+
+ def test_tiene_seccion_sistema_detectado(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ assert "## Sistema detectado" in md
+
+ def test_tiene_seccion_boot_protocol(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ assert "## Boot protocol" in md
+
+ def test_incluye_os_en_quick_reference(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ assert "Linux Ubuntu 22.04" in md
+
+ def test_incluye_python_version(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ assert "3.11.9" in md
+
+ def test_incluye_gpu_cuando_disponible(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ assert "RTX 4090" in md
+
+ def test_incluye_vram(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ # 24576 MB = 24.0 GB
+ assert "24.0 GB" in md
+
+ def test_cpu_only_cuando_no_gpu(self, scan_sin_gpu):
+ md = generar_agents_md(scan_sin_gpu)
+ assert "CPU only" in md or "solo CPU" in md
+
+ def test_incluye_paquetes_clave(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ assert "## Paquetes clave" in md
+ assert "torch" in md
+ assert "transformers" in md
+
+ def test_excluye_paquetes_no_clave(self, scan_con_gpu):
+ # 'colorama' no está en _PAQUETES_CLAVE
+ md = generar_agents_md(scan_con_gpu)
+ idx_paquetes = md.find("## Paquetes clave")
+ assert idx_paquetes >= 0
+ idx_siguiente = md.find("\n## ", idx_paquetes + 1)
+ if idx_siguiente < 0:
+ idx_siguiente = len(md)
+ seccion_paquetes = md[idx_paquetes:idx_siguiente]
+ assert "colorama" not in seccion_paquetes
+
+ def test_servicios_activos_e_inactivos(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ assert "Activos" in md
+ assert "PostgreSQL" in md
+ assert "HTTP-8000" in md
+
+ def test_servicios_inactivos(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ assert "Inactivos" in md
+ assert "Redis" in md
+
+ def test_voz_incluida(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ assert "espeak" in md
+
+ def test_voz_no_disponible_cuando_vacia(self, scan_sin_gpu):
+ md = generar_agents_md(scan_sin_gpu)
+ assert "no disponible" in md
+
+ def test_ram_incluida(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ assert "64.0 GB" in md
+
+ def test_proyecto_custom(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu, proyecto="mi-proyecto-custom")
+ assert "mi-proyecto-custom" in md
+
+ def test_nombre_agente_en_titulo(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu, agente_nombre="PAMPAr")
+ assert "PAMPAr" in md
+
+ def test_son_headers_markdown_validos(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ headers = [l for l in md.splitlines() if l.startswith("## ")]
+ assert len(headers) >= 3, f"Esperaba al menos 3 headers ##, encontró: {headers}"
+
+ def test_tiene_tabla_quick_reference(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ # quick reference debe tener líneas con |
+ idx = md.find("## Quick Reference")
+ assert idx >= 0
+ seccion = md[idx:idx + 500]
+ assert "|" in seccion
+
+ def test_boot_protocol_tiene_pasos(self, scan_con_gpu):
+ md = generar_agents_md(scan_con_gpu)
+ assert "RAG L3" in md
+ assert "RAG L2" in md
+
+ def test_fastapi_en_paquetes_clave(self, scan_sin_gpu):
+ md = generar_agents_md(scan_sin_gpu)
+ assert "fastapi" in md
+
+ def test_sin_paquetes_relevantes_no_hay_seccion(self):
+ scan = ResultadoScan(
+ workspace_root="/test",
+ archivos=[],
+ paquetes={"colorama": "0.4.6", "certifi": "2025.1.31"}, # ninguno clave
+ servicios={},
+ sistema=InfoSistema(os="Linux", python_version="3.11"),
+ )
+ md = generar_agents_md(scan)
+ assert "## Paquetes clave" not in md
diff --git a/tests/test_inference_server.py b/tests/test_inference_server.py
new file mode 100644
index 0000000000000000000000000000000000000000..d937cee785577df6fb21c5b87209ceea1e18e4ef
--- /dev/null
+++ b/tests/test_inference_server.py
@@ -0,0 +1,161 @@
+# SPDX-License-Identifier: BUSL-1.1
+"""
+Tests unitarios del servidor de inferencia (pampar.inference).
+
+Qué prueban:
+ - handle_infer: responde con infer_ok y texto no vacío
+ - handle_infer: prompt vacío → error
+ - handle_boot: responde con boot_ok y agents_md con secciones
+ - Tipo desconocido → error
+ - JSON inválido no rompe el loop (simulado)
+ - Tokenizer y modelo son mockeados — no se carga el checkpoint real
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from io import StringIO
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+import torch
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from pampar.inference import handle_boot, handle_infer
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+@pytest.fixture()
+def mock_tokenizer():
+ """Tokenizer falso que 'codifica' como lista de ints y 'decodifica' texto fijo."""
+ tok = MagicMock()
+ tok.Encode.return_value = [1, 2, 3, 4, 5]
+ tok.Decode.return_value = "def hello():\n return 'world'"
+ return tok
+
+
+@pytest.fixture()
+def mock_model():
+ """Modelo falso cuyo generate devuelve un tensor con tokens extra."""
+ model = MagicMock()
+ # generate devuelve [1, L+N]: los 5 de prompt + 10 nuevos (ids 10..19)
+ fake_output = torch.tensor([[1, 2, 3, 4, 5, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]])
+ model.generate.return_value = fake_output
+ return model
+
+
+@pytest.fixture()
+def device():
+ return torch.device("cpu")
+
+
+# ---------------------------------------------------------------------------
+# handle_infer
+# ---------------------------------------------------------------------------
+
+class TestHandleInfer:
+ def test_responde_infer_ok(self, mock_model, mock_tokenizer, device, capsys):
+ msg = {"type": "infer", "prompt": "### Problem:\nhello\n### Solution:\n", "max_tokens": 50}
+ handle_infer(mock_model, mock_tokenizer, device, msg)
+
+ captured = capsys.readouterr().out.strip()
+ resp = json.loads(captured)
+ assert resp["type"] == "infer_ok"
+ assert "text" in resp
+ assert len(resp["text"]) > 0
+
+ def test_llama_generate_con_params(self, mock_model, mock_tokenizer, device, capsys):
+ msg = {"type": "infer", "prompt": "abc", "max_tokens": 128, "temperature": 0.2}
+ handle_infer(mock_model, mock_tokenizer, device, msg)
+
+ call_kwargs = mock_model.generate.call_args
+ assert call_kwargs[1]["max_tokens"] == 128 or call_kwargs.kwargs.get("max_tokens") == 128 or call_kwargs[0][1] == 128
+
+ def test_prompt_vacio_devuelve_error(self, mock_model, mock_tokenizer, device, capsys):
+ msg = {"type": "infer", "prompt": ""}
+ handle_infer(mock_model, mock_tokenizer, device, msg)
+
+ captured = capsys.readouterr().out.strip()
+ resp = json.loads(captured)
+ assert resp["type"] == "error"
+ assert "prompt" in resp["message"].lower() or "vacío" in resp["message"].lower() or "vac" in resp["message"]
+
+ def test_prompt_ausente_devuelve_error(self, mock_model, mock_tokenizer, device, capsys):
+ msg = {"type": "infer"}
+ handle_infer(mock_model, mock_tokenizer, device, msg)
+
+ captured = capsys.readouterr().out.strip()
+ resp = json.loads(captured)
+ assert resp["type"] == "error"
+
+ def test_tokenizer_encode_llamado(self, mock_model, mock_tokenizer, device, capsys):
+ msg = {"type": "infer", "prompt": "test prompt"}
+ handle_infer(mock_model, mock_tokenizer, device, msg)
+ mock_tokenizer.Encode.assert_called_once_with("test prompt", out_type=int)
+
+ def test_temperatura_default_valida(self, mock_model, mock_tokenizer, device, capsys):
+ """Sin temperature en el msg usa el default 0.4 — no debe explotar."""
+ msg = {"type": "infer", "prompt": "hello"}
+ handle_infer(mock_model, mock_tokenizer, device, msg)
+ captured = capsys.readouterr().out.strip()
+ resp = json.loads(captured)
+ assert resp["type"] == "infer_ok"
+
+
+# ---------------------------------------------------------------------------
+# handle_boot
+# ---------------------------------------------------------------------------
+
+class TestHandleBoot:
+ def test_responde_boot_ok(self, tmp_path, capsys):
+ """boot con workspace válido devuelve boot_ok con agents_md."""
+ # Crear un .py mínimo para que el scanner tenga algo que parsear
+ (tmp_path / "main.py").write_text("def hello():\n pass\n")
+
+ msg = {"type": "boot", "workspace": str(tmp_path)}
+ handle_boot(msg)
+
+ captured = capsys.readouterr().out.strip()
+ resp = json.loads(captured)
+ assert resp["type"] == "boot_ok"
+ assert "agents_md" in resp
+ assert len(resp["agents_md"]) > 50 # algo de contenido
+
+ def test_agents_md_tiene_secciones(self, tmp_path, capsys):
+ """El AGENTS.md generado debe tener al menos Quick Reference y Boot protocol."""
+ msg = {"type": "boot", "workspace": str(tmp_path)}
+ handle_boot(msg)
+
+ captured = capsys.readouterr().out.strip()
+ resp = json.loads(captured)
+ agents_md = resp["agents_md"]
+
+ assert "## Quick Reference" in agents_md
+ assert "## Boot protocol" in agents_md
+
+ def test_workspace_inexistente_no_explota(self, capsys):
+ """Un workspace inexistente debe devolver error, no excepción sin capturar."""
+ msg = {"type": "boot", "workspace": "/ruta/que/no/existe/12345"}
+ # No debe lanzar excepción — debe responder error o boot_ok vacío
+ try:
+ handle_boot(msg)
+ captured = capsys.readouterr().out.strip()
+ resp = json.loads(captured)
+ assert resp["type"] in ("boot_ok", "error")
+ except Exception as exc:
+ pytest.fail(f"handle_boot lanzó excepción no capturada: {exc}")
+
+ def test_workspace_vacio_no_explota(self, tmp_path, capsys):
+ """Workspace sin archivos .py — debe funcionar igual."""
+ msg = {"type": "boot", "workspace": str(tmp_path)}
+ handle_boot(msg)
+ captured = capsys.readouterr().out.strip()
+ resp = json.loads(captured)
+ assert resp["type"] == "boot_ok"
diff --git a/tests/test_memoria.py b/tests/test_memoria.py
new file mode 100644
index 0000000000000000000000000000000000000000..0def6840335357bf3060192ba65926dc7cd55ff8
--- /dev/null
+++ b/tests/test_memoria.py
@@ -0,0 +1,318 @@
+# SPDX-License-Identifier: BUSL-1.1
+"""
+Tests del sistema de memoria de PAMPAr-Coder v3.
+
+Cubren:
+ ClasificadorPareto:
+ - Texto vacío → nivel 0
+ - Código pobre → nivel 0 o 1 (score bajo)
+ - Código denso (dataclass, async, type hints, comprehension) → nivel ≥ 1
+ - loss_modelo alta sube importancia
+ - actualizar_frecuencia impulsa correctamente
+ RAGResidual:
+ - agregar entrada nivel 0 → retorna False, no agrega
+ - agregar entrada nivel 1 → retorna True, len==1
+ - agregar duplicado → retorna False, frecuencia aumenta
+ - recuperar → lista ordenada por score
+ - eliminar_por_nivel → solo elimina el nivel pedido
+ - formatear_contexto → incluye marcador [MEMORIA RELEVANTE]
+ - stats → claves esperadas presentes
+ ColaFinetune:
+ - agregar nivel < 3 → no se agrega
+ - agregar nivel 3 → se agrega, len==1
+ - agregar duplicado → no se duplica
+ - exportar_dataset → JSONL válido con campos instruction/input/output
+ - vaciar_post_finetune → vacía la cola
+ - proponer_usuario → retorna string indicativo
+"""
+
+import json
+import tempfile
+from pathlib import Path
+
+import pytest
+
+from pampar.memoria.clasificador import ClasificadorPareto, EntradaMemoria
+from pampar.memoria.rag import RAGResidual
+from pampar.memoria.cola_finetune import ColaFinetune
+
+
+# ==============================================================================
+# HELPERS DE FIXTURES LOCALES
+# ==============================================================================
+
+def _entrada(nivel: int = 1, texto: str = "def foo(): pass", tipo: str = "codigo") -> EntradaMemoria:
+ """Crea una EntradaMemoria de prueba con importancia acorde al nivel."""
+ importancia_por_nivel = {0: 0.1, 1: 0.4, 2: 0.7, 3: 0.9}
+ e = EntradaMemoria(
+ texto=texto,
+ tipo=tipo,
+ importancia=importancia_por_nivel.get(nivel, 0.4),
+ novedad=0.8,
+ densidad=0.5,
+ nivel=nivel,
+ )
+ return e
+
+
+# ==============================================================================
+# TESTS: ClasificadorPareto
+# ==============================================================================
+
+class TestClasificadorPareto:
+ def test_texto_vacio_es_nivel_cero(self, clasificador: ClasificadorPareto):
+ """Un texto vacío siempre debe clasificarse como nivel 0 (ignorar)."""
+ resultado = clasificador.clasificar("")
+ assert resultado.nivel == 0
+ assert resultado.importancia == 0.0
+
+ def test_texto_whitespace_es_nivel_cero(self, clasificador: ClasificadorPareto):
+ """Un texto de solo espacios tampoco se guarda."""
+ resultado = clasificador.clasificar(" \n\t ")
+ assert resultado.nivel == 0
+
+ def test_codigo_simple_score_bajo(self, clasificador: ClasificadorPareto):
+ """Una asignación simple sin patrones avanzados tiene importancia baja."""
+ resultado = clasificador.clasificar("x = 1")
+ # No debe pasar L2 (importancia >= 0.6)
+ assert resultado.importancia < 0.6
+
+ def test_codigo_denso_nivel_minimo_uno(self, clasificador: ClasificadorPareto, codigo_rico: str):
+ """Código con dataclass, async, type hints, comprehension → nivel ≥ 1."""
+ resultado = clasificador.clasificar(codigo_rico)
+ assert resultado.nivel >= 1, (
+ f"Código rico debería estar en L1+, obtuvo nivel {resultado.nivel} "
+ f"(importancia={resultado.importancia:.4f})"
+ )
+
+ def test_importancia_en_rango_valido(self, clasificador: ClasificadorPareto, codigo_rico: str):
+ """La importancia siempre debe estar en [0, 1]."""
+ resultado = clasificador.clasificar(codigo_rico)
+ assert 0.0 <= resultado.importancia <= 1.0
+
+ def test_loss_alta_sube_importancia(self, clasificador: ClasificadorPareto):
+ """Un fragmento con loss_modelo alta debe tener mayor importancia."""
+ low_loss = clasificador.clasificar("x = 1", loss_modelo=0.1)
+ high_loss = clasificador.clasificar("x = 1", loss_modelo=10.0)
+ assert high_loss.importancia > low_loss.importancia
+
+ def test_novedad_maxima_sin_existentes(self, clasificador: ClasificadorPareto):
+ """Sin fragmentos existentes, novedad debe ser 1.0."""
+ resultado = clasificador.clasificar("def f(): pass", fragmentos_existentes=[])
+ assert resultado.novedad == 1.0
+
+ def test_novedad_baja_con_texto_identico(self, clasificador: ClasificadorPareto):
+ """Si el texto es igual a uno existente, novedad debe bajar drásticamente."""
+ fragmento = "def calcular_area(radio): return 3.14 * radio * radio"
+ r1 = clasificador.clasificar(fragmento, fragmentos_existentes=[])
+ r2 = clasificador.clasificar(fragmento, fragmentos_existentes=[fragmento])
+ assert r2.novedad < r1.novedad
+
+ def test_id_generado_automaticamente(self, clasificador: ClasificadorPareto):
+ """El id debe ser un hash hex de 16 caracteres."""
+ resultado = clasificador.clasificar("def f(): pass")
+ assert isinstance(resultado.id, str)
+ assert len(resultado.id) == 16
+ # Hex characters only
+ int(resultado.id, 16)
+
+ def test_territorio_detectado(self, clasificador: ClasificadorPareto):
+ """El territorio dominante debe ser uno de los 4 esperados."""
+ resultado = clasificador.clasificar("def f(x: int) -> str: return str(x)")
+ validos = {"SINTAXIS", "SEMANTICA", "LOGICO", "ESTRUCTURAL", ""}
+ assert resultado.territorio_dominante in validos
+
+ def test_tipo_preservado(self, clasificador: ClasificadorPareto):
+ """El tipo pasado se debe preservar en la entrada resultante."""
+ resultado = clasificador.clasificar("ZeroDivisionError", tipo="error")
+ assert resultado.tipo == "error"
+
+
+# ==============================================================================
+# TESTS: RAGResidual
+# ==============================================================================
+
+class TestRAGResidual:
+ @pytest.fixture
+ def rag(self, tmp_path: Path) -> RAGResidual:
+ """RAG aislado en directorio temporal."""
+ return RAGResidual(directorio=str(tmp_path / "rag"), max_entradas=100, n_resultados=3)
+
+ def test_agregar_nivel_cero_retorna_false(self, rag: RAGResidual):
+ """Entradas nivel 0 no deben entrar al RAG."""
+ entrada = _entrada(nivel=0)
+ resultado = rag.agregar(entrada)
+ assert resultado is False
+ assert len(rag._entradas) == 0
+
+ def test_agregar_nivel_uno_retorna_true(self, rag: RAGResidual):
+ """Entradas nivel 1 se deben guardar en el RAG."""
+ entrada = _entrada(nivel=1)
+ resultado = rag.agregar(entrada)
+ assert resultado is True
+ assert len(rag._entradas) == 1
+
+ def test_agregar_nivel_dos_y_tres(self, rag: RAGResidual):
+ """Niveles 2 y 3 también se aceptan."""
+ assert rag.agregar(_entrada(nivel=2, texto="def a(): pass")) is True
+ assert rag.agregar(_entrada(nivel=3, texto="def b(): pass")) is True
+ assert len(rag._entradas) == 2
+
+ def test_agregar_duplicado_no_duplica(self, rag: RAGResidual):
+ """Agregar el mismo texto dos veces no duplica la entrada."""
+ e = _entrada(nivel=1, texto="def foo(): return 42")
+ rag.agregar(e)
+ resultado = rag.agregar(e) # Mismo id → duplicado
+ assert resultado is False
+ assert len(rag._entradas) == 1
+
+ def test_agregar_duplicado_incrementa_frecuencia(self, rag: RAGResidual):
+ """El duplicado debe incrementar la frecuencia de la entrada existente."""
+ e = _entrada(nivel=1, texto="def foo(): return 42")
+ e.frecuencia = 1
+ rag.agregar(e)
+ rag.agregar(e)
+ assert rag._entradas[0].frecuencia == 2
+
+ def test_recuperar_retorna_lista(self, rag: RAGResidual):
+ """recuperar() debe retornar una lista (aunque vacía)."""
+ result = rag.recuperar("def foo(): pass")
+ assert isinstance(result, list)
+
+ def test_recuperar_encuentra_entradas(self, rag: RAGResidual):
+ """Tras agregar una entrada, recuperar() debe encontrarla."""
+ texto = "def calcular_suma(a: int, b: int) -> int: return a + b"
+ rag.agregar(_entrada(nivel=1, texto=texto))
+ resultados = rag.recuperar(texto)
+ assert len(resultados) >= 1
+ entradas = [e.texto for e, _ in resultados]
+ assert texto in entradas
+
+ def test_eliminar_por_nivel_correcto(self, rag: RAGResidual):
+ """eliminar_por_nivel(2) debe eliminar solo las entradas de nivel 2."""
+ rag.agregar(_entrada(nivel=1, texto="def nivel_uno(): pass"))
+ rag.agregar(_entrada(nivel=2, texto="class NivelDos: pass"))
+ rag.agregar(_entrada(nivel=3, texto="async def nivel_tres(): pass"))
+
+ eliminados = rag.eliminar_por_nivel(2)
+
+ assert eliminados == 1
+ niveles_restantes = {e.nivel for e in rag._entradas}
+ assert 2 not in niveles_restantes
+ assert 1 in niveles_restantes
+ assert 3 in niveles_restantes
+
+ def test_formatear_contexto_vacio(self, rag: RAGResidual):
+ """Sin resultados, formatear_contexto debe retornar string vacío."""
+ ctx = rag.formatear_contexto([])
+ assert ctx == ""
+
+ def test_formatear_contexto_incluye_marcador(self, rag: RAGResidual):
+ """Con resultados, debe incluir el bloque [MEMORIA RELEVANTE]."""
+ e = _entrada(nivel=1, texto="def foo(): pass")
+ rag.agregar(e)
+ resultados = rag.recuperar("def foo", nivel_minimo=1)
+ ctx = rag.formatear_contexto(resultados)
+ assert "[MEMORIA RELEVANTE]" in ctx
+ assert "[/MEMORIA RELEVANTE]" in ctx
+
+ def test_stats_claves_esperadas(self, rag: RAGResidual):
+ """stats() debe incluir las claves esperadas."""
+ s = rag.stats()
+ for clave in ("total_entradas", "nivel_1_rag", "nivel_2_alta_prio", "nivel_3_finetune", "modo_encoder"):
+ assert clave in s, f"Falta clave '{clave}' en stats()"
+
+ def test_stats_contadores_coherentes(self, rag: RAGResidual):
+ """Los contadores de stats deben sumar el total."""
+ rag.agregar(_entrada(nivel=1, texto="# code 1"))
+ rag.agregar(_entrada(nivel=2, texto="# code 2"))
+ rag.agregar(_entrada(nivel=3, texto="# code 3"))
+ s = rag.stats()
+ suma = s["nivel_1_rag"] + s["nivel_2_alta_prio"] + s["nivel_3_finetune"]
+ assert suma == s["total_entradas"]
+
+
+# ==============================================================================
+# TESTS: ColaFinetune
+# ==============================================================================
+
+class TestColaFinetune:
+ @pytest.fixture
+ def cola(self, tmp_path: Path) -> ColaFinetune:
+ """Cola aislada en directorio temporal con umbral bajo para tests."""
+ return ColaFinetune(directorio=str(tmp_path / "cola"), min_ejemplos=5)
+
+ def test_agregar_nivel_menor_tres_no_agrega(self, cola: ColaFinetune):
+ """Entradas nivel < 3 no deben entrar en la cola."""
+ cola.agregar(_entrada(nivel=2))
+ assert len(cola) == 0
+
+ def test_agregar_nivel_tres_agrega(self, cola: ColaFinetune):
+ """Entradas nivel 3 deben agregarse."""
+ cola.agregar(_entrada(nivel=3))
+ assert len(cola) == 1
+
+ def test_agregar_duplicado_no_duplica(self, cola: ColaFinetune):
+ """El mismo id no debe aparecer dos veces en la cola."""
+ e = _entrada(nivel=3, texto="async def train(): pass")
+ cola.agregar(e)
+ cola.agregar(e) # duplicado
+ assert len(cola) == 1
+
+ def test_stats_total_correcto(self, cola: ColaFinetune):
+ """stats()['total'] debe coincidir con len(cola)."""
+ cola.agregar(_entrada(nivel=3, texto="def a(): pass"))
+ cola.agregar(_entrada(nivel=3, texto="def b(): pass"))
+ assert cola.stats()["total"] == 2
+
+ def test_stats_listos_cuando_supera_umbral(self, cola: ColaFinetune):
+ """stats()['listos'] debe ser True cuando len >= min_ejemplos."""
+ for i in range(5):
+ cola.agregar(_entrada(nivel=3, texto=f"def fn_{i}(): pass"))
+ assert cola.stats()["listos"] is True
+
+ def test_exportar_dataset_crea_jsonl(self, cola: ColaFinetune, tmp_path: Path):
+ """exportar_dataset() debe crear un archivo JSONL con al menos una línea."""
+ cola.agregar(_entrada(nivel=3, texto="class Trainer: pass"))
+ ruta_out = str(tmp_path / "ft.jsonl")
+ ruta = cola.exportar_dataset(ruta_salida=ruta_out)
+
+ assert ruta.exists()
+ lineas = [l for l in ruta.read_text(encoding="utf-8").splitlines() if l.strip()]
+ assert len(lineas) >= 1
+
+ def test_exportar_dataset_formato_alpaca(self, cola: ColaFinetune, tmp_path: Path):
+ """Cada línea del JSONL debe tener las claves instruction, input, output."""
+ cola.agregar(_entrada(nivel=3, texto="def train(model): pass"))
+ ruta = cola.exportar_dataset()
+
+ for linea in ruta.read_text(encoding="utf-8").splitlines():
+ if linea.strip():
+ obj = json.loads(linea)
+ assert "instruction" in obj, "Falta 'instruction'"
+ assert "input" in obj, "Falta 'input'"
+ assert "output" in obj, "Falta 'output'"
+
+ def test_vaciar_post_finetune(self, cola: ColaFinetune):
+ """vaciar_post_finetune() debe vaciar la cola y retornar el count previo."""
+ cola.agregar(_entrada(nivel=3, texto="def a(): pass"))
+ cola.agregar(_entrada(nivel=3, texto="def b(): pass"))
+ n = cola.vaciar_post_finetune()
+ assert n == 2
+ assert len(cola) == 0
+
+ def test_proponer_usuario_retorna_string(self, cola: ColaFinetune):
+ """proponer_usuario() siempre debe retornar un string no vacío."""
+ msg = cola.proponer_usuario()
+ assert isinstance(msg, str)
+ assert len(msg) > 10
+
+ def test_persistencia_entre_instancias(self, tmp_path: Path):
+ """Los datos persistidos deben cargarse en una nueva instancia."""
+ dir_cola = str(tmp_path / "cola_persist")
+ c1 = ColaFinetune(directorio=dir_cola, min_ejemplos=50)
+ c1.agregar(_entrada(nivel=3, texto="def persistida(): pass"))
+ assert len(c1) == 1
+
+ c2 = ColaFinetune(directorio=dir_cola, min_ejemplos=50)
+ assert len(c2) == 1
diff --git a/tests/test_runtime.py b/tests/test_runtime.py
new file mode 100644
index 0000000000000000000000000000000000000000..04947d0620c195ebaa09c9d61b7c996f413e67d2
--- /dev/null
+++ b/tests/test_runtime.py
@@ -0,0 +1,258 @@
+# SPDX-License-Identifier: BUSL-1.1
+"""
+Tests del runtime.Agente de PAMPAr-Coder v3.
+
+El Agente requiere sentencepiece + tokenizer .model para instanciarse,
+por eso estos tests evitan usar el constructor real y en su lugar:
+ - Testean los métodos puramente lógicos usando mocks
+ - Testean componentes integrados (sin modelo real) para el orquestador
+
+Cubren:
+ - _parece_codigo() detecta código correctamente
+ - _construir_prompt() incluye todos los bloques esperados
+ - _procesar_acciones() parsea y ejecuta [LEER:], [EJECUTAR:], [TESTS:]
+ - limpiar_historial() limpia el estado
+ - stats() retorna dict con claves esperadas
+ - aceptar_finetune/rechazar_finetune retornan strings
+ - SYSTEM_PROMPT tiene las instrucciones esperadas
+"""
+
+import sys
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch, PropertyMock
+
+import pytest
+import torch
+
+# Garantizar que el root del proyecto esté en el PATH
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from pampar.runtime.agente import SYSTEM_PROMPT
+
+
+# ==============================================================================
+# FIXTURE: Agente con dependencias mockeadas
+# ==============================================================================
+
+def _make_agente(tmp_path: Path) -> "Agente":
+ """
+ Crea un Agente real con modelo mini + tokenizer mockeado.
+
+ Evita depender del tokenizer .model y del checkpoint pre-entrenado.
+ El modelo se inicializa desde cero (pesos aleatorios).
+ """
+ from pampar.runtime.agente import Agente
+ from pampar.coder.v3.config import ConfigV3
+
+ config_mini = ConfigV3(
+ vocab_size=256,
+ dim=32,
+ n_streams=4,
+ n_levels=2,
+ n_heads=2,
+ n_kv_heads=1,
+ ffn_mult=2.0,
+ n_zonas=52, # Hardcodeado en v2 LLAVES — NO cambiar
+ n_territorios=4,
+ lateral_bottleneck=8,
+ ventana_contexto=2,
+ max_seq_len=32,
+ dropout=0.0,
+ use_checkpoint=False,
+ )
+
+ # Mock del tokenizer SentencePiece
+ mock_tok = MagicMock()
+ mock_tok.Encode.side_effect = lambda text: [1, 2, 3, 4, 5] # siempre 5 tokens
+ mock_tok.Decode.side_effect = lambda ids: "respuesta mockeada"
+ mock_tok.vocab_size.return_value = 256
+ mock_tok.GetPieceSize.return_value = 256 # necesario para registrar_tokenizer
+ mock_tok.IdToPiece.side_effect = lambda i: str(i) # retorna string para clasificar_token
+
+ # Parchar sentencepiece.SentencePieceProcessor para que retorne nuestro mock
+ with patch("pampar.runtime.agente.spm") as mock_spm:
+ mock_spm.SentencePieceProcessor.return_value = mock_tok
+
+ agente = Agente(
+ checkpoint="no_existe_checkpoint.pt", # Inicia con pesos aleatorios
+ tokenizer_path="no_existe_tokenizer.model",
+ config=config_mini,
+ workspace_root=str(tmp_path),
+ memoria_dir=str(tmp_path / "memoria"),
+ device="cpu",
+ max_historial=5,
+ )
+
+ return agente
+
+
+@pytest.fixture
+def agente(tmp_path: Path):
+ """Agente con modelo pequeño y tokenizer mockeado."""
+ return _make_agente(tmp_path)
+
+
+# ==============================================================================
+# TESTS: SYSTEM_PROMPT
+# ==============================================================================
+
+class TestSystemPrompt:
+ def test_contiene_instruccion_leer(self):
+ """El SYSTEM_PROMPT debe documentar la acción [LEER:]."""
+ assert "[LEER:" in SYSTEM_PROMPT
+
+ def test_contiene_instruccion_ejecutar(self):
+ """El SYSTEM_PROMPT debe documentar la acción [EJECUTAR:]."""
+ assert "[EJECUTAR:" in SYSTEM_PROMPT
+
+ def test_contiene_instruccion_tests(self):
+ """El SYSTEM_PROMPT debe documentar la acción [TESTS:]."""
+ assert "[TESTS:" in SYSTEM_PROMPT
+
+ def test_en_espanol(self):
+ """El SYSTEM_PROMPT debe contener al menos una palabra española."""
+ palabras_esp = ["archivos", "código", "Sos", "siempre", "acceso"]
+ assert any(p in SYSTEM_PROMPT for p in palabras_esp)
+
+
+# ==============================================================================
+# TESTS: Métodos lógicos sin llamada al modelo
+# ==============================================================================
+
+class TestLogicaSinModelo:
+ def test_parece_codigo_con_def(self, agente):
+ """Un texto con 'def' debe detectarse como código."""
+ assert agente._parece_codigo("def calcular(x):\n return x * 2") is True
+
+ def test_parece_codigo_con_class(self, agente):
+ """Un texto con 'class' debe detectarse como código."""
+ assert agente._parece_codigo("class Trainer:\n pass") is True
+
+ def test_parece_codigo_con_import(self, agente):
+ """Un texto con 'import' debe detectarse como código."""
+ assert agente._parece_codigo("import torch\nimport numpy as np") is True
+
+ def test_no_parece_codigo_texto_simple(self, agente):
+ """Un texto conversacional corriente no debe detectarse como código."""
+ assert agente._parece_codigo("¿Cómo estás? Cuéntame sobre Python.") is False
+
+ def test_construir_prompt_incluye_system(self, agente):
+ """El prompt construido debe incluir el SYSTEM_PROMPT."""
+ prompt = agente._construir_prompt("hola", "")
+ assert "PAMPAr" in prompt
+
+ def test_construir_prompt_incluye_mensaje_usuario(self, agente):
+ """El mensaje del usuario debe aparecer en el prompt."""
+ prompt = agente._construir_prompt("¿qué es un decorador?", "")
+ assert "¿qué es un decorador?" in prompt
+
+ def test_construir_prompt_incluye_ctx_rag(self, agente):
+ """El contexto RAG debe estar presente si se pasa."""
+ ctx = "[MEMORIA RELEVANTE]\nEjemplo de código\n[/MEMORIA RELEVANTE]"
+ prompt = agente._construir_prompt("explica esto", ctx)
+ assert "[MEMORIA RELEVANTE]" in prompt
+
+ def test_construir_prompt_incluye_historial(self, agente):
+ """El historial previo debe aparecer en el prompt."""
+ agente._historial = [
+ {"role": "user", "text": "primer turno"},
+ {"role": "assistant", "text": "primera respuesta"},
+ ]
+ prompt = agente._construir_prompt("segundo turno", "")
+ assert "primer turno" in prompt
+ assert "primera respuesta" in prompt
+
+ def test_limpiar_historial(self, agente):
+ """limpiar_historial() debe dejar el historial vacío."""
+ agente._historial = [
+ {"role": "user", "text": "algo"},
+ {"role": "assistant", "text": "respuesta"},
+ ]
+ agente.limpiar_historial()
+ assert agente._historial == []
+
+ def test_stats_estructura(self, agente):
+ """stats() debe retornar un dict con las claves esperadas."""
+ s = agente.stats()
+ for clave in ("modelo", "rag", "cola_finetune", "historial_turnos", "device"):
+ assert clave in s, f"Falta clave '{clave}' en stats()"
+
+ def test_stats_historial_turnos(self, agente):
+ """stats()['historial_turnos'] debe contar pares user/assistant."""
+ agente._historial = [
+ {"role": "user", "text": "t1"},
+ {"role": "assistant", "text": "r1"},
+ {"role": "user", "text": "t2"},
+ {"role": "assistant", "text": "r2"},
+ ]
+ assert agente.stats()["historial_turnos"] == 2
+
+ def test_rechazar_finetune_retorna_string(self, agente):
+ """rechazar_finetune() debe retornar un string no vacío."""
+ msg = agente.rechazar_finetune()
+ assert isinstance(msg, str)
+ assert len(msg) > 10
+
+ def test_aceptar_finetune_retorna_string(self, agente):
+ """aceptar_finetune() debe retornar un string (éxito o error)."""
+ # No importa si el training falla (no hay script real)
+ msg = agente.aceptar_finetune()
+ assert isinstance(msg, str)
+ assert len(msg) > 10
+
+ def test_describe_retorna_string(self, agente):
+ """describe() debe delegar al modelo y retornar string."""
+ desc = agente.describe()
+ assert isinstance(desc, str)
+ assert len(desc) > 0
+
+
+# ==============================================================================
+# TESTS: _procesar_acciones (parsing de actions)
+# ==============================================================================
+
+class TestProcesarAcciones:
+ def test_sin_acciones_devuelve_original(self, agente):
+ """Sin marcadores de acción, la respuesta se devuelve sin cambios."""
+ respuesta = "Esto es una respuesta normal sin acciones."
+ resultado = agente._procesar_acciones(respuesta)
+ assert resultado == respuesta
+
+ def test_accion_ejecutar(self, agente, tmp_path: Path):
+ """[EJECUTAR: codigo] debe ejecutar el código e insertar el output."""
+ respuesta = "[EJECUTAR:\nprint('desde accion')\n]"
+ resultado = agente._procesar_acciones(respuesta)
+ # El marcador debe haberse reemplazado por algo
+ assert "[EJECUTAR:" not in resultado
+ # El output del código debe estar presente
+ assert "desde accion" in resultado or "STDOUT" in resultado
+
+ def test_accion_leer_archivo_valido(self, agente, tmp_path: Path):
+ """[LEER: ruta] debe leer el archivo e insertar su contenido."""
+ # Crear un archivo en el workspace del agente
+ archivo = Path(agente.lector.root) / "leeme.py"
+ archivo.write_text("# archivo de prueba\nx = 42\n", encoding="utf-8")
+
+ respuesta = "[LEER: leeme.py]"
+ resultado = agente._procesar_acciones(respuesta)
+ assert "[LEER:" not in resultado
+ # El contenido del archivo debe estar en la respuesta
+ assert "leeme.py" in resultado or "x = 42" in resultado
+
+ def test_accion_leer_archivo_inexistente(self, agente):
+ """[LEER: archivo_que_no_existe.py] debe insertar un mensaje de error."""
+ respuesta = "[LEER: archivo_fantasma.py]"
+ resultado = agente._procesar_acciones(respuesta)
+ assert "[LEER:" not in resultado
+ # El marcador fue reemplazado (por un error del lector)
+ assert len(resultado) > 0
+
+ def test_multiples_acciones(self, agente):
+ """Múltiples acciones [EJECUTAR:] en la misma respuesta se procesan todas."""
+ respuesta = (
+ "Primero: [EJECUTAR:\nprint(1)\n]\n"
+ "Luego: [EJECUTAR:\nprint(2)\n]"
+ )
+ resultado = agente._procesar_acciones(respuesta)
+ assert "[EJECUTAR:" not in resultado
diff --git a/tests/test_skills.py b/tests/test_skills.py
new file mode 100644
index 0000000000000000000000000000000000000000..c0eebb5ed128458dcf160a2d51d05aac3822d586
--- /dev/null
+++ b/tests/test_skills.py
@@ -0,0 +1,251 @@
+# SPDX-License-Identifier: BUSL-1.1
+"""
+Tests de las skills de PAMPAr-Coder v3.
+
+Cubren:
+ LectorArchivos:
+ - Leer archivo existente → exito=True, contenido incluye nombre
+ - Leer fuera del workspace → exito=False, "Acceso denegado"
+ - Leer archivo inexistente → exito=False
+ - Extensión no permitida → exito=False
+ - Listar directorio → exito=True, contiene items
+ - Rango de líneas funciona
+ - buscar_en_workspace encuentra el patrón
+
+ EjecutorCodigo:
+ - print simple → exito=True, stdout correcto
+ - Código vacío → exito=False
+ - Error de sintaxis → exito=False, stderr con traceback
+ - Excepción en runtime → exito=False
+ - Timeout → exito=False, "TIMEOUT" en contenido
+ - Operación bloqueada → exito=False, "no permitida"
+ - ejecutar_tests con test que pasa → exito=True
+"""
+
+import textwrap
+from pathlib import Path
+
+import pytest
+
+from pampar.skills.lector_archivos import LectorArchivos
+from pampar.skills.ejecutar_codigo import EjecutorCodigo
+from pampar.skills.base import ResultadoSkill
+
+
+# ==============================================================================
+# HELPERS LOCALES
+# ==============================================================================
+
+def _crear_archivo(directorio: Path, nombre: str, contenido: str) -> Path:
+ """Crea un archivo de texto en el directorio dado y retorna su Path."""
+ ruta = directorio / nombre
+ ruta.write_text(contenido, encoding="utf-8")
+ return ruta
+
+
+# ==============================================================================
+# TESTS: LectorArchivos
+# ==============================================================================
+
+class TestLectorArchivos:
+ @pytest.fixture
+ def workspace(self, tmp_path: Path) -> Path:
+ """Directorio workspace temporal con algunos archivos de prueba."""
+ (tmp_path / "subdir").mkdir()
+ _crear_archivo(tmp_path, "hola.py", "def hola():\n return 42\n")
+ _crear_archivo(tmp_path, "config.json", '{"key": "value"}')
+ _crear_archivo(tmp_path / "subdir", "inner.py", "x = 1\ny = 2\nz = 3\n")
+ return tmp_path
+
+ @pytest.fixture
+ def lector(self, workspace: Path) -> LectorArchivos:
+ return LectorArchivos(workspace_root=str(workspace))
+
+ def test_leer_archivo_existente(self, lector: LectorArchivos):
+ """Leer un .py existente debe retornar exito=True con su contenido."""
+ resultado = lector.execute("hola.py")
+ assert resultado.exito is True
+ assert "hola.py" in resultado.contenido
+ assert "def hola" in resultado.contenido
+
+ def test_leer_archivo_incluye_numeros_linea(self, lector: LectorArchivos):
+ """El resultado debe incluir la info de líneas totales."""
+ resultado = lector.execute("hola.py")
+ assert resultado.exito is True
+ assert resultado.datos["total_lineas"] >= 1
+
+ def test_leer_archivo_rango_lineas(self, lector: LectorArchivos):
+ """Con linea_inicio y linea_fin solo se leen las líneas del rango."""
+ resultado = lector.execute("subdir/inner.py", linea_inicio=1, linea_fin=1)
+ assert resultado.exito is True
+ assert "x = 1" in resultado.contenido
+ assert "y = 2" not in resultado.contenido
+
+ def test_leer_extension_no_permitida(self, workspace: Path):
+ """Archivos con extensión no en la whitelist deben rechazarse."""
+ _crear_archivo(workspace, "binario.bin", "\x00\x01\x02")
+ lector = LectorArchivos(workspace_root=str(workspace))
+ resultado = lector.execute("binario.bin")
+ assert resultado.exito is False
+ assert "no permitida" in resultado.error.lower() or "extensión" in resultado.error.lower()
+
+ def test_leer_archivo_inexistente(self, lector: LectorArchivos):
+ """Leer un archivo que no existe debe retornar exito=False."""
+ resultado = lector.execute("no_existe.py")
+ assert resultado.exito is False
+ assert resultado.error != ""
+
+ def test_acceso_fuera_workspace_bloqueado(self, lector: LectorArchivos):
+ """Intentar salir del workspace con path traversal debe fallar."""
+ resultado = lector.execute("../../etc/passwd")
+ assert resultado.exito is False
+ assert "Acceso denegado" in resultado.error or resultado.error != ""
+
+ def test_listar_directorio(self, lector: LectorArchivos):
+ """Listar un directorio debe retornar exito=True con lista de items."""
+ resultado = lector.execute(".")
+ assert resultado.exito is True
+ assert "[DIRECTORIO" in resultado.contenido
+
+ def test_listar_subdirectorio(self, lector: LectorArchivos):
+ """Listar un subdirectorio debe mostrar sus archivos."""
+ resultado = lector.execute("subdir")
+ assert resultado.exito is True
+ assert "inner.py" in resultado.contenido
+
+ def test_buscar_patron_existente(self, lector: LectorArchivos):
+ """buscar_en_workspace debe encontrar el patrón en archivos .py."""
+ resultado = lector.buscar_en_workspace("def hola", extension=".py")
+ assert resultado.exito is True
+ assert "hola.py" in resultado.contenido
+
+ def test_buscar_patron_inexistente(self, lector: LectorArchivos):
+ """Buscar un patrón que no existe debe retornar exito=True pero sin matches."""
+ resultado = lector.buscar_en_workspace("__NO_EXISTE_NUNCA__", extension=".py")
+ assert resultado.exito is True
+ # El contenido debe indicar que no hay resultados
+ assert "No se encontraron" in resultado.contenido or resultado.datos == {}
+
+ def test_leer_json(self, lector: LectorArchivos):
+ """Archivos .json también deben ser legibles."""
+ resultado = lector.execute("config.json")
+ assert resultado.exito is True
+ assert "key" in resultado.contenido
+
+ def test_resultado_es_resultadoskill(self, lector: LectorArchivos):
+ """El return siempre debe ser una instancia de ResultadoSkill."""
+ resultado = lector.execute("hola.py")
+ assert isinstance(resultado, ResultadoSkill)
+
+
+# ==============================================================================
+# TESTS: EjecutorCodigo
+# ==============================================================================
+
+class TestEjecutorCodigo:
+ @pytest.fixture
+ def ejecutor(self) -> EjecutorCodigo:
+ """Ejecutor con timeout corto para que los tests de timeout sean rápidos."""
+ return EjecutorCodigo(timeout=5)
+
+ def test_print_simple(self, ejecutor: EjecutorCodigo):
+ """Un print básico debe retornar exito=True y el stdout correcto."""
+ resultado = ejecutor.execute("print(42)")
+ assert resultado.exito is True
+ assert "42" in resultado.contenido
+ assert resultado.datos["stdout"] == "42"
+
+ def test_stdout_multilinea(self, ejecutor: EjecutorCodigo):
+ """Múltiples prints deben aparecer en el stdout."""
+ codigo = "for i in range(3):\n print(i)"
+ resultado = ejecutor.execute(codigo)
+ assert resultado.exito is True
+ assert "0" in resultado.contenido
+ assert "1" in resultado.contenido
+ assert "2" in resultado.contenido
+
+ def test_codigo_vacio_falla(self, ejecutor: EjecutorCodigo):
+ """Código vacío debe retornar exito=False."""
+ resultado = ejecutor.execute("")
+ assert resultado.exito is False
+ assert resultado.error != ""
+
+ def test_error_sintaxis(self, ejecutor: EjecutorCodigo):
+ """Código con error de sintaxis debe dar exito=False con traceback."""
+ resultado = ejecutor.execute("def f(\n pass")
+ assert resultado.exito is False
+ # El stderr debe contener algún mensaje de error
+ assert resultado.error != "" or "STDERR" in resultado.contenido or "Error" in resultado.contenido
+
+ def test_excepcion_en_runtime(self, ejecutor: EjecutorCodigo):
+ """Una excepción en runtime debe dar exito=False."""
+ resultado = ejecutor.execute("raise ValueError('test error')")
+ assert resultado.exito is False
+ assert resultado.datos["returncode"] != 0
+
+ def test_returncode_cero_en_exito(self, ejecutor: EjecutorCodigo):
+ """Un script exitoso debe tener returncode=0."""
+ resultado = ejecutor.execute("x = 1 + 1")
+ assert resultado.exito is True
+ assert resultado.datos["returncode"] == 0
+
+ def test_timeout_corto(self, ejecutor: EjecutorCodigo):
+ """Código que excede el timeout debe retornar exito=False con indicación de TIMEOUT."""
+ # Un sleep muy largo para forzar timeout con timeout=1
+ resultado = ejecutor.execute("import time; time.sleep(60)", timeout=1)
+ assert resultado.exito is False
+ assert "TIMEOUT" in resultado.contenido or "timeout" in resultado.error.lower()
+
+ def test_operacion_bloqueada_detectada(self, ejecutor: EjecutorCodigo):
+ """Código que usa os.system debe rechazarse antes de ejecutar."""
+ resultado = ejecutor.execute("import os; os.system('echo hacked')")
+ assert resultado.exito is False
+ assert "no permitida" in resultado.error.lower() or "bloqueada" in resultado.error.lower()
+
+ def test_sin_output_reporta_sin_output(self, ejecutor: EjecutorCodigo):
+ """Código que no imprime nada debe reportar [Sin output]."""
+ resultado = ejecutor.execute("x = 1 + 1 # sin print")
+ assert resultado.exito is True
+ assert "Sin output" in resultado.contenido
+
+ def test_calculos_correctos(self, ejecutor: EjecutorCodigo):
+ """Verificar que el resultado de un cálculo es correcto."""
+ resultado = ejecutor.execute("print(2 ** 10)")
+ assert resultado.exito is True
+ assert "1024" in resultado.contenido
+
+ def test_codigo_con_imports(self, ejecutor: EjecutorCodigo):
+ """Código que importa stdlib debe funcionar correctamente."""
+ codigo = textwrap.dedent("""
+ import math
+ print(round(math.pi, 4))
+ """)
+ resultado = ejecutor.execute(codigo)
+ assert resultado.exito is True
+ assert "3.1416" in resultado.contenido
+
+ def test_resultado_es_resultadoskill(self, ejecutor: EjecutorCodigo):
+ """El return siempre debe ser una instancia de ResultadoSkill."""
+ resultado = ejecutor.execute("print('ok')")
+ assert isinstance(resultado, ResultadoSkill)
+
+ def test_ejecutar_tests_con_test_simple(self, ejecutor: EjecutorCodigo, tmp_path: Path):
+ """ejecutar_tests() con un test que pasa debe retornar exito=True."""
+ test_file = tmp_path / "test_simple.py"
+ test_file.write_text(
+ "def test_suma():\n assert 1 + 1 == 2\n",
+ encoding="utf-8",
+ )
+ resultado = ejecutor.ejecutar_tests(str(test_file))
+ assert resultado.exito is True
+ assert "passed" in resultado.contenido.lower()
+
+ def test_ejecutar_tests_con_test_fallido(self, ejecutor: EjecutorCodigo, tmp_path: Path):
+ """ejecutar_tests() con un test que falla debe retornar exito=False."""
+ test_file = tmp_path / "test_falla.py"
+ test_file.write_text(
+ "def test_que_falla():\n assert 1 == 2\n",
+ encoding="utf-8",
+ )
+ resultado = ejecutor.ejecutar_tests(str(test_file))
+ assert resultado.exito is False
diff --git a/tests/test_v3_arquitectura.py b/tests/test_v3_arquitectura.py
new file mode 100644
index 0000000000000000000000000000000000000000..8fa3b38c310253db5389893e7ef9da93f74dfe0f
--- /dev/null
+++ b/tests/test_v3_arquitectura.py
@@ -0,0 +1,253 @@
+# SPDX-License-Identifier: BUSL-1.1
+"""
+Tests de la arquitectura PamparV3.
+
+Cubren:
+ - Forward pass: shapes de logits, terr_acts, zona_acts
+ - Loss válida (~log(vocab) en init aleatoria)
+ - Weight tying entre tok_emb y lm_head
+ - GQA: n_kv_heads < n_heads
+ - Conteo de parámetros coherente
+ - generate(): output más largo que input, sin NaN
+ - Early exit: exit_nivel dentro de los límites
+ - Lateral gate scale inicializado en 0.1
+ - TalamoInicial: forma de salida correcta
+ - No leak de datos entre streams independientes (grad isolation básico)
+"""
+
+import math
+
+import pytest
+import torch
+
+from pampar.coder.v3.config import ConfigV3, PRESET_V3, PRESET_V3_SMALL, PRESET_V3_LARGE
+from pampar.coder.v3.modelo import PamparV3
+from pampar.coder.v3.bloques import NivelProfundo, LateralGate
+
+
+# ==============================================================================
+# TESTS DE CONFIGURACIÓN
+# ==============================================================================
+
+class TestConfigV3:
+ def test_head_dim_derivado(self, config_small: ConfigV3):
+ """head_dim = dim // n_heads debe ser entero sin resto."""
+ assert config_small.dim % config_small.n_heads == 0
+ assert config_small.head_dim == config_small.dim // config_small.n_heads
+
+ def test_gqa_ratio(self, config_small: ConfigV3):
+ """KV heads deben ser menores que Query heads (GQA activado)."""
+ assert config_small.n_kv_heads < config_small.n_heads
+ assert config_small.n_heads % config_small.n_kv_heads == 0
+
+ def test_n_rep_derivado(self, config_small: ConfigV3):
+ """n_rep = n_heads // n_kv_heads debe ser entero."""
+ assert config_small.n_rep == config_small.n_heads // config_small.n_kv_heads
+
+ def test_ffn_hidden_positivo(self, config_small: ConfigV3):
+ """El hidden de FFN debe ser > dim."""
+ assert config_small.ffn_hidden > 0
+
+ def test_presets_existen(self):
+ """Los tres presets exportados deben ser instancias de ConfigV3."""
+ assert isinstance(PRESET_V3, ConfigV3)
+ assert isinstance(PRESET_V3_SMALL, ConfigV3)
+ assert isinstance(PRESET_V3_LARGE, ConfigV3)
+
+ def test_preset_v3_grande_mayor_que_small(self):
+ """PRESET_V3_LARGE debe tener mayor dim que PRESET_V3_SMALL."""
+ assert PRESET_V3_LARGE.dim > PRESET_V3_SMALL.dim
+
+ def test_estimate_params_positivo(self, config_small: ConfigV3):
+ """estimate_params() debe retornar un dict con clave 'total' positiva."""
+ result = config_small.estimate_params()
+ assert isinstance(result, dict)
+ assert "total" in result
+ assert result["total"] > 0
+
+ def test_memory_estimate_positivo(self, config_small: ConfigV3):
+ """memory_estimate_mb() debe retornar un dict con claves esperadas."""
+ mem = config_small.memory_estimate_mb()
+ assert "model_fp16_mb" in mem, f"Claves: {list(mem.keys())}"
+ assert "training_total_mb" in mem
+ assert mem["model_fp16_mb"] > 0
+
+
+# ==============================================================================
+# TESTS DEL FORWARD PASS
+# ==============================================================================
+
+class TestForwardPass:
+ def test_logits_shape(self, modelo: PamparV3, tokens_cortos: torch.Tensor, config_small: ConfigV3):
+ """Los logits deben tener shape [B, L, vocab_size]."""
+ with torch.no_grad():
+ logits, loss, info = modelo(tokens_cortos)
+
+ B, L = tokens_cortos.shape
+ assert logits.shape == (B, L, config_small.vocab_size), (
+ f"Esperado {(B, L, config_small.vocab_size)}, "
+ f"obtenido {logits.shape}"
+ )
+
+ def test_loss_none_sin_targets(self, modelo: PamparV3, tokens_cortos: torch.Tensor):
+ """Sin targets la loss debe ser None."""
+ with torch.no_grad():
+ _, loss, _ = modelo(tokens_cortos)
+ assert loss is None
+
+ def test_loss_valida_con_targets(self, modelo: PamparV3, tokens_cortos: torch.Tensor, config_small: ConfigV3):
+ """Con targets la loss debe ser escalar finito ~log(vocab) en init aleatoria."""
+ targets = tokens_cortos.clone()
+ with torch.no_grad():
+ _, loss, _ = modelo(tokens_cortos, targets=targets)
+
+ assert loss is not None
+ assert loss.ndim == 0, "La loss debe ser un escalar"
+ assert torch.isfinite(loss), "La loss no puede ser NaN/Inf"
+ # Log-probabilidad uniforme sobre el vocab como cota esperada
+ expected = math.log(config_small.vocab_size)
+ assert 0 < loss.item() < expected * 3, (
+ f"Loss {loss.item():.4f} fuera del rango esperado [0, {expected * 3:.2f}]"
+ )
+
+ def test_info_contiene_exit_nivel(self, modelo: PamparV3, tokens_cortos: torch.Tensor, config_small: ConfigV3):
+ """El dict info debe incluir 'exit_nivel' dentro de [1, n_levels]."""
+ with torch.no_grad():
+ _, _, info = modelo(tokens_cortos)
+
+ assert "exit_nivel" in info
+ nivel = info["exit_nivel"]
+ assert 1 <= nivel <= config_small.n_levels
+
+ def test_terr_acts_en_info(self, modelo: PamparV3, tokens_cortos: torch.Tensor, config_small: ConfigV3):
+ """info debe incluir 'terr_acts' con shape [B, L, n_streams]."""
+ with torch.no_grad():
+ _, _, info = modelo(tokens_cortos)
+
+ assert "terr_acts" in info
+ B, L = tokens_cortos.shape
+ ta = info["terr_acts"]
+ assert ta.shape == (B, L, config_small.n_streams)
+
+ def test_logits_finitos(self, modelo: PamparV3, tokens_cortos: torch.Tensor):
+ """Los logits no pueden contener NaN ni Inf."""
+ with torch.no_grad():
+ logits, _, _ = modelo(tokens_cortos)
+ assert torch.isfinite(logits).all(), "Logits contienen NaN o Inf"
+
+
+# ==============================================================================
+# TESTS DE PROPIEDADES ESTRUCTURALES
+# ==============================================================================
+
+class TestEstructura:
+ def test_weight_tying(self, modelo: PamparV3):
+ """tok_emb y lm_head deben compartir el MISMO tensor de pesos."""
+ assert modelo.lm_head.weight is modelo.tok_emb.weight, (
+ "Weight tying roto: lm_head.weight y tok_emb.weight son tensors distintos"
+ )
+
+ def test_num_niveles(self, modelo: PamparV3, config_small: ConfigV3):
+ """El número de NivelProfundo debe coincidir con n_levels."""
+ assert len(modelo.niveles) == config_small.n_levels
+
+ def test_lateral_gate_scale_inicial(self, modelo: PamparV3, config_small: ConfigV3):
+ """El parámetro 'scale' de LateralGate debe inicializarse cerca de 0.1."""
+ for nivel in modelo.niveles:
+ lg = nivel.lateral # atributo real en NivelProfundo
+ assert hasattr(lg, "scale"), "LateralGate no tiene atributo 'scale'"
+ # scale es tensor (n_streams,) inicializado con 0.1
+ scale = lg.scale # shape: (n_streams,)
+ assert scale.shape == (config_small.n_streams,), (
+ f"Esperado shape ({config_small.n_streams},), obtenido {scale.shape}"
+ )
+ # Permitir ±5% de tolerancia respecto a 0.1
+ scale_mean = scale.mean().item()
+ assert abs(scale_mean - 0.1) < 0.01, (
+ f"scale mean esperado ~0.1, obtenido {scale_mean:.4f}"
+ )
+
+ def test_count_params_positivo(self, modelo: PamparV3):
+ """count_params() debe retornar un dict con 'total' de parámetros positivo."""
+ result = modelo.count_params()
+ assert isinstance(result, dict)
+ assert "total" in result
+ assert result["total"] > 0
+
+ def test_describe_retorna_str(self, modelo: PamparV3):
+ """describe() debe retornar una cadena no vacía."""
+ desc = modelo.describe()
+ assert isinstance(desc, str)
+ assert len(desc) > 20
+
+ def test_no_parametros_inf_nan(self, modelo: PamparV3):
+ """Ningún parámetro debe contener NaN/Inf tras la inicialización."""
+ for name, param in modelo.named_parameters():
+ assert torch.isfinite(param).all(), f"Parámetro '{name}' contiene NaN o Inf"
+
+
+# ==============================================================================
+# TESTS DE GENERACIÓN
+# ==============================================================================
+
+class TestGeneracion:
+ def test_generate_amplía_secuencia(self, modelo: PamparV3, tokens_single: torch.Tensor):
+ """generate() debe producir más tokens que la entrada."""
+ n_new = 10
+ with torch.no_grad():
+ output = modelo.generate(tokens_single, max_tokens=n_new)
+
+ assert output.shape[1] > tokens_single.shape[1], (
+ "generate() no amplió la secuencia"
+ )
+
+ def test_generate_max_tokens_respetado(self, modelo: PamparV3, tokens_single: torch.Tensor):
+ """generate() no debe generar más de max_tokens tokens extra."""
+ n_new = 5
+ with torch.no_grad():
+ output = modelo.generate(tokens_single, max_tokens=n_new)
+
+ delta = output.shape[1] - tokens_single.shape[1]
+ assert delta <= n_new, (
+ f"Generados {delta} tokens, máximo esperado {n_new}"
+ )
+
+ def test_generate_sin_nan(self, modelo: PamparV3, tokens_single: torch.Tensor, config_small: ConfigV3):
+ """Los tokens generados deben estar dentro del vocab y sin NaN."""
+ with torch.no_grad():
+ output = modelo.generate(tokens_single, max_tokens=8)
+
+ assert output.dtype == torch.long
+ assert (output >= 0).all()
+ assert (output < config_small.vocab_size).all()
+
+ def test_generate_top_k(self, modelo: PamparV3, tokens_single: torch.Tensor):
+ """generate() con top_k debe funcionar sin errores."""
+ with torch.no_grad():
+ output = modelo.generate(tokens_single, max_tokens=5, top_k=10)
+ assert output.shape[1] > tokens_single.shape[1]
+
+ def test_generate_temperature(self, modelo: PamparV3, tokens_single: torch.Tensor):
+ """generate() con temperature muy baja debe ser determinista."""
+ with torch.no_grad():
+ out1 = modelo.generate(tokens_single, max_tokens=5, temperature=1e-8)
+ out2 = modelo.generate(tokens_single, max_tokens=5, temperature=1e-8)
+ assert torch.equal(out1, out2), "generate() con temperature≈0 no es determinista"
+
+
+# ==============================================================================
+# TEST DE EARLY EXIT
+# ==============================================================================
+
+class TestEarlyExit:
+ def test_early_exit_activo(self, modelo: PamparV3, tokens_cortos: torch.Tensor, config_small: ConfigV3):
+ """Con use_early_exit=True el nivel de salida debe ser <= n_levels."""
+ with torch.no_grad():
+ _, _, info = modelo(tokens_cortos, use_early_exit=True)
+ assert 1 <= info["exit_nivel"] <= config_small.n_levels
+
+ def test_early_exit_inactivo(self, modelo: PamparV3, tokens_cortos: torch.Tensor, config_small: ConfigV3):
+ """Con use_early_exit=False siempre se recorren todos los niveles."""
+ with torch.no_grad():
+ _, _, info = modelo(tokens_cortos, use_early_exit=False)
+ assert info["exit_nivel"] == config_small.n_levels