PAMPAr-Coder / tests /test_memoria.py
lucas-mella's picture
feat: upload PAMPAr-Coder code and documentation
a2d6a0d verified
Raw
History Blame Contribute Delete
14.6 kB
# 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