File size: 7,330 Bytes
850e745 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | """Phase 11 — tests mémoire : RAG (mode A) et apprentissage permanent (mode B)."""
from __future__ import annotations
import numpy as np
import pytest
import torch
from src.memory.permanent import PermanentLearner, ReplayBuffer, _encode_chunks
from src.memory.rag import (
LessonMemory,
VectorIndex,
chunk_text,
compute_idf,
hashed_embed,
)
from src.model.text_model import CortexTextLM, TextConfig
from src.tokenizer import load_tokenizer
@pytest.fixture(scope="module")
def tokenizer():
tok = load_tokenizer()
if tok.vocab_size != 32000:
pytest.skip("tokenizer final 32k requis")
return tok
# ---------------------------------------------------------------------------
# Embeddings / index (mode A — briques)
# ---------------------------------------------------------------------------
def test_hashed_embed_is_normalized_and_deterministic():
v = hashed_embed("le chat dort sur le tapis", dim=128)
assert v.shape == (128,)
assert abs(float(np.linalg.norm(v)) - 1.0) < 1e-5
assert np.allclose(v, hashed_embed("le chat dort sur le tapis", dim=128))
def test_similar_texts_are_closer():
a = hashed_embed("la pomme est un fruit rouge")
b = hashed_embed("la pomme est un fruit rouge sucré")
c = hashed_embed("le moteur de la voiture est bruyant")
assert float(a @ b) > float(a @ c)
def test_chunking_respects_max_words():
text = " ".join(["Phrase numéro un très courte."] * 20)
passages = chunk_text(text, max_words=30)
assert len(passages) >= 2
assert all(len(p.text.split()) <= 40 for p in passages)
def test_vector_index_search_orders_by_similarity():
idx = VectorIndex(dim=64)
idx.add(np.stack([
hashed_embed("le chien court dans le jardin", 64),
hashed_embed("la voiture roule sur la route", 64),
hashed_embed("le soleil brille dans le ciel", 64),
]))
hits = idx.search(hashed_embed("un chien dans le jardin", 64), k=3)
assert hits[0][0] == 0 # le passage sur le chien est le plus proche
assert hits[0][1] >= hits[1][1] >= hits[2][1]
def test_idf_gives_rare_words_more_weight():
texts = ["chat chien", "chat chien", "chat chien", "ornithorynque"]
idf = compute_idf(texts)
assert idf["ornithorynque"] > idf["chat"]
# ---------------------------------------------------------------------------
# Mode A — RAG complet sur un « cours »
# ---------------------------------------------------------------------------
def _course():
return (
"Le cycle de l'eau décrit le mouvement de l'eau sur Terre. "
"L'eau s'évapore des océans sous l'effet du soleil. "
"La vapeur monte, se refroidit et forme des nuages. "
"La pluie retombe ensuite sur les continents. "
"Enfin l'eau ruisselle vers les rivières puis la mer. "
"La photosynthèse permet aux plantes de fabriquer du sucre. "
"Les plantes absorbent le dioxyde de carbone et rejettent de l'oxygène. "
"La chlorophylle donne leur couleur verte aux feuilles."
)
def test_rag_retrieves_relevant_passage():
mem = LessonMemory(dim=512, max_words=20)
mem.add_text(_course(), source="cours_svt")
ans = mem.answer("Comment se forment les nuages ?", k=2)
assert ans["sources"] == ["cours_svt"]
assert ans["confidence"] > 0.1
# Le meilleur passage doit parler de vapeur / nuages.
assert "nuage" in ans["answer"].lower() or "vapeur" in ans["answer"].lower()
def test_rag_answer_is_extractive_verbatim():
mem = LessonMemory(max_words=20)
mem.add_text(_course())
ans = mem.answer("Que rejettent les plantes ?", k=1)
assert ans["answer"] in [p.text for p in mem.passages] # verbatim, pas d'invention
def test_rag_video_ingestion_keeps_sources():
mem = LessonMemory(max_words=15)
mem.add_video(
transcript=[(0.0, "Bienvenue dans ce cours sur les volcans."),
(12.0, "Un volcan crache de la lave très chaude."),
(30.0, "La lave refroidit et devient de la roche.")],
frame_captions=[(12.0, "une coulée de lave orange descend la pente")],
source="volcans.mp4",
)
ans = mem.answer("De quoi est faite la lave refroidie ?", k=3)
assert "volcans.mp4" in ans["sources"]
assert any("roche" in r["text"].lower() for r in ans["retrieved"])
def test_rag_empty_memory_returns_empty():
mem = LessonMemory()
ans = mem.answer("une question sans cours")
assert ans["answer"] == "" and ans["confidence"] == 0.0
# ---------------------------------------------------------------------------
# Mode B — apprentissage permanent (mécanique + honnêteté)
# ---------------------------------------------------------------------------
def _quick_model(tokenizer) -> CortexTextLM:
torch.manual_seed(0)
return CortexTextLM(TextConfig(vocab_size=tokenizer.vocab_size, n_layer=2, n_head=2,
n_embd=64, ctx_len=48, tie_embeddings=True))
def test_replay_buffer_capacity_and_sampling():
buf = ReplayBuffer(capacity=3)
for i in range(5):
buf.add([i, i + 1])
assert len(buf) == 3 # capacity respected
assert buf.items[0] == [2, 3] # oldest evicted
rng = np.random.default_rng(0)
assert len(buf.sample(2, rng)) == 2
def test_encode_chunks_wraps_bos_eos(tokenizer):
chunks = _encode_chunks(tokenizer, "Bonjour le monde.", ctx_len=64, max_chunks=4)
assert chunks and chunks[0][0] == __import__("src.tokenizer.special_tokens",
fromlist=["BOS_ID"]).BOS_ID
assert chunks[0][-1] == __import__("src.tokenizer.special_tokens",
fromlist=["EOS_ID"]).EOS_ID
def test_permanent_learning_memorizes_new_lesson(tokenizer):
"""Mode B : après ingestion, la perte sur la leçon a chuté (mémorisation)."""
model = _quick_model(tokenizer)
learner = PermanentLearner(model=model, tokenizer=tokenizer, lr=5e-3,
steps_per_lesson=40, ctx_len=48, device="cpu")
lesson = "La photosynthèse transforme la lumière en sucre dans les feuilles vertes."
metrics = learner.ingest(lesson, seed=0)
assert metrics["steps"] == 40
assert metrics["loss_after"] < metrics["loss_before"] # elle mémorise
assert metrics["replay_size"] >= 1
def test_permanent_learning_can_forget_without_replay(tokenizer):
"""Sans rejeu, ingérer une leçon fait *monter* la perte sur une autre.
C'est la démonstration honnête de l'oubli catastrophique : le mode B est instable.
"""
model = _quick_model(tokenizer)
learner = PermanentLearner(model=model, tokenizer=tokenizer, lr=5e-3,
steps_per_lesson=40, replay_ratio=0.0, device="cpu")
kept = "Le chat noir dort sur le tapis rouge du salon."
new = "La lave brûlante coule le long de la pente du volcan actif."
x = torch.tensor([learner.tokenizer.encode(kept)], dtype=torch.long)
y = torch.tensor([learner.tokenizer.encode(kept)], dtype=torch.long)
loss_before = float(model(x, y)[1].detach())
learner.ingest(new, seed=0)
loss_after = float(model(x, y)[1].detach())
assert loss_after > loss_before # oubli : la perte sur l'ancienne phrase augmente
|