|
|
| """
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
|
| 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
|
|
|
| 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"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 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
|
|
|