|
|
|
|
| """
|
| 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,
|
| )
|
|
|
|
|
| _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())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
| 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)}
|
|
|
|
|
| self.student_profile = StudentProfile()
|
|
|
|
|
| self.session_log: list[LessonResult] = []
|
|
|
|
|
| self.event_queue: queue.Queue = queue.Queue()
|
|
|
|
|
| self.bio: Optional[BioOrchestrator] = None
|
| self._last_terr_acts: Optional[list[torch.Tensor]] = None
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
| 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...")
|
|
|
|
|
| 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))
|
|
|
|
|
| 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}")
|
|
|
|
|
| self._setup_optimizer()
|
|
|
|
|
| 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})",
|
| )
|
|
|
|
|
| self._compute_ewc_baseline()
|
|
|
|
|
| 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")
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
| 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"):
|
|
|
| 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
|
| temperature = 0.2
|
| else:
|
|
|
| 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()
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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:
|
|
|
| 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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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"]),
|
| },
|
| )
|
|
|
|
|
| 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"],
|
| },
|
| )
|
|
|
|
|
|
|
|
|
| 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})")
|
|
|
|
|
| if lesson.get("clave"):
|
| clave_ids, clave_labels = self._tokenize_teaching(lesson["clave"])
|
| self._train_step([(clave_ids, clave_labels)])
|
|
|
|
|
| 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},
|
| )
|
|
|
|
|
| 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},
|
| )
|
|
|
|
|
| if teacher_solution:
|
| if concept_type in ("conceptual", "bridge"):
|
|
|
| full_text = (
|
| f"### Pregunta:\n{exercise}\n### Respuesta:\n{teacher_solution}"
|
| )
|
| ex_ids, ex_labels = self._tokenize_teaching(full_text)
|
| else:
|
|
|
| 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)
|
|
|
|
|
|
|
| if correct:
|
| self.replay.add(
|
| exercise,
|
| teacher_solution,
|
| ex_ids,
|
| ex_labels,
|
| level,
|
| )
|
| else:
|
| correct = True
|
| feedback = "Lección absorbida (sin ejercicio)"
|
|
|
|
|
| error_desc = feedback if not correct else ""
|
| self.student_profile.record(concept_id, correct, error_desc)
|
|
|
|
|
| brain_score = self._quick_brain_check()
|
|
|
|
|
| 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),
|
| },
|
| )
|
|
|
|
|
| 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),
|
| },
|
| )
|
|
|
|
|
| if self.lesson_count % self.config.guardar_cada == 0:
|
| self._save_checkpoint()
|
|
|
| return result
|
|
|
|
|
|
|
| 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})
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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})
|
|
|
|
|
| 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),
|
| }
|
| )
|
|
|
|
|
| format_event_to_console(event_type, data)
|
|
|