TARGET_SPACE / tests /test_ext.py
RDS777's picture
Upload 45 files
8a4176f verified
Raw History Blame Contribute Delete
35.6 kB
"""Extensions unique_ext (v3.9, ADR-002) : non-régression stricte, briques d'inférence, phases d'entraînement, jobs.
Règle vérifiée en premier : sans aucune variable UNIQUE_EXT_* / UNIQUE_PHASES, rien ne change (menu, job, panneau).
"""
from __future__ import annotations
import importlib
import importlib.util
import json
import os
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from mouh.learning.prompts import chat_messages, render_files
from mouh.learning.records import split_for, task_hash
ROOT = Path(__file__).resolve().parents[1]
FENCE = "`" * 3
TESTS = {"test_solution.py": "from solution import add\n\n\ndef test_add():\n assert add(2, 3) == 5\n\n\n"
"def test_zero():\n assert add(0, 0) == 0\n"}
V38_CHOICES = ("Vérification seule (code fourni)", "Modèle M.O.U.H.", "UNIQUE (modèle entraîné)",
"UNIQUE réfléchi (effort adapté à la difficulté)")
@pytest.fixture(autouse=True)
def no_ext_env(monkeypatch):
"""Chaque test part d'un Space sans aucune extension activée."""
for k in list(os.environ):
if k.startswith("UNIQUE_EXT_") or k == "UNIQUE_PHASES":
monkeypatch.delenv(k)
def load(path: str, name: str):
spec = importlib.util.spec_from_file_location(name, ROOT / path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def files(body: str) -> str:
return f"### FILE: solution.py\n{FENCE}python\n{body}\n{FENCE}\n### END"
def request(tests=TESTS, attempt=1, instruction="additionner a et b"):
return SimpleNamespace(task=SimpleNamespace(instruction=instruction, test_files=tests), attempt=attempt,
previous_files=None, diagnostics=())
def build_for(instruction, tests):
def build(previous=None, diagnostics=None):
return chat_messages(instruction, tests, previous, diagnostics if previous else None)
return build
class ScriptedBackend:
"""Propositions fausses tant qu'on ne lui montre pas le code qui échoue ; la réparation est juste."""
name = "scripted"
def __init__(self, first="return a - b", repair="return a + b"):
self.first, self.repair, self.calls, self.generations = first, repair, [], 0
def complete(self, messages, n=1, *, greedy_first=True, max_new_tokens=None):
self.calls.append(messages[-1]["content"])
self.generations += n
fixing = "Code actuel (échoue)" in messages[-1]["content"]
out = []
for i in range(n):
body = self.repair if fixing else self.first
out.append(files(f"def add(a, b):\n {body} # {len(self.calls)}.{i}"))
return out
# ================================================================= non-régression
def test_nothing_changes_without_variables():
from unique_ext import registry
assert registry.generator_choices() == ()
assert registry.phases() == []
assert registry.job_setup(lambda m: None) == []
def test_menu_v38_intact_and_extension_appended(monkeypatch):
import mouh.service as S
assert S.GENERATOR_CHOICES[:4] == V38_CHOICES and S.EXT_CHOICES == ()
monkeypatch.setenv("UNIQUE_EXT_TREE", "1")
S2 = importlib.reload(S)
try:
assert S2.GENERATOR_CHOICES[:4] == V38_CHOICES
assert S2.EXT_CHOICES == ("UNIQUE · recherche arborescente (MCTS + pytest)",)
assert S2.GENERATOR_CHOICES[4:] == S2.EXT_CHOICES
finally:
monkeypatch.delenv("UNIQUE_EXT_TREE")
importlib.reload(S)
def test_broken_package_falls_back_to_v38(monkeypatch):
import mouh.service as S
monkeypatch.setitem(sys.modules, "unique_ext.registry", None) # import impossible
S2 = importlib.reload(S)
try:
assert S2.EXT_CHOICES == () and S2.GENERATOR_CHOICES == V38_CHOICES
finally:
monkeypatch.delitem(sys.modules, "unique_ext.registry")
importlib.reload(S)
def test_phases_parsing_ignores_unknown_names():
from unique_ext.registry import phases
assert phases("dpo:200, inconnue, KTO, dpo:x") == [("dpo", 200), ("kto", 100)]
assert phases("") == []
def test_training_job_ignores_extensions_without_variables(monkeypatch):
job = load("jobs/train_unique.py", "train_unique_ext_t1")
assert job.load_extensions(None) is None
monkeypatch.setenv("UNIQUE_PHASES", "dpo:16")
monkeypatch.setenv("UNIQUE_EXT_PATH", str(ROOT))
reg = job.load_extensions(None)
assert reg is not None and reg.phases() == [("dpo", 16)]
import unique_ext
monkeypatch.setattr(unique_ext, "API_VERSION", 99) # paquet d'une autre version : le job l'ignore
assert job.load_extensions(None) is None
# ================================================================= bus JEV : compilation JIT + cache
def test_compile_gate_rejects_syntax_errors_without_running_pytest():
from unique_ext.jev.compile_gate import VerificationCache, compile_files
from unique_ext.reasoning.verify import PytestVerifier
rep = compile_files({"solution.py": "def add(a, b)\n return a + b\n"})
assert not rep.ok and "solution.py:1" in rep.errors[0] and "SyntaxError" in rep.errors[0]
assert compile_files({"solution.py": "def add(a, b):\n return a + b\n"}).bytecode_bytes > 0
v = PytestVerifier(cache=VerificationCache())
c = v.score({"solution.py": "def add(a, b)\n return a + b\n"}, TESTS)
assert not c.ok and v.runs == 0 and v.compile_rejects == 1 and "Compilation refusée" in c.output
good = {"solution.py": "def add(a, b):\n return a + b\n"}
assert v.score(good, TESTS).ok and v.runs == 1
again = v.score(good, TESTS)
assert again.ok and again.cached and v.runs == 1 # même couple (fichiers, tests) : pas de second pytest
def test_verifier_counts_partial_success():
from unique_ext.reasoning.verify import PytestVerifier
c = PytestVerifier(cache=None).score({"solution.py": "def add(a, b):\n return a * b\n"}, TESTS)
assert not c.ok and c.passed == 1 and c.failed == 1 and c.value == 0.5
# ================================================================= TTC : best-of-N, faisceau, MCTS
def test_mcts_repairs_with_the_real_pytest_trace_and_respects_budget():
from unique_ext.contracts import Plan
from unique_ext.reasoning.tree import RepairMCTS
from unique_ext.reasoning.verify import PytestVerifier
be = ScriptedBackend()
cands = RepairMCTS().solve(request(), Plan("complexe", "mcts", budget=5), be, PytestVerifier(cache=None),
build_for("additionner a et b", TESTS))
assert cands[0].ok and cands[0].origin == "reparation" and cands[0].depth == 1
assert be.generations <= 5
assert any("assert" in c for c in be.calls if "Code actuel" in c) # la trace pytest est montrée au modèle
def test_beam_repairs_and_best_of_n_spends_whole_budget():
from unique_ext.contracts import Plan
from unique_ext.reasoning.tree import BestOfN, RepairBeam
from unique_ext.reasoning.verify import PytestVerifier
be = ScriptedBackend()
best = RepairBeam(width=1, depth=1).solve(request(), Plan("complexe", "arbre", budget=3), be,
PytestVerifier(cache=None), build_for("x", TESTS))[0]
assert best.ok and best.origin == "reparation"
be2 = ScriptedBackend()
out = BestOfN().solve(request(), Plan("moyenne", "direct", budget=3), be2, PytestVerifier(cache=None),
build_for("x", TESTS))
assert be2.generations == 3 and not out[0].ok # sans réparation, le modèle scripté ne trouve pas
def test_mcts_stops_at_budget_when_nothing_parses():
from unique_ext.contracts import Plan
from unique_ext.reasoning.tree import RepairMCTS
from unique_ext.reasoning.verify import PytestVerifier
class Garbage:
name, generations = "garbage", 0
def complete(self, messages, n=1, **kw):
self.generations += n
return ["pas de code"] * n
g, v = Garbage(), PytestVerifier(cache=None)
out = RepairMCTS().solve(request(), Plan("complexe", "mcts", budget=4), g, v, build_for("x", TESTS))
# le texte brut devient solution.py (règle de parse_files) : la porte JEV le refuse sans lancer pytest
assert len(out) == 1 and not out[0].ok and v.runs == 0 and v.compile_rejects == 1
assert g.generations == 4
def test_router_is_hybrid_and_escalates_after_a_failed_attempt():
from unique_ext.reasoning.router import ModeRouter
simple = ModeRouter(max_budget=6).plan("additionner", {"t.py": "def test_a():\n assert 1\n"}, 1)
assert (simple.level, simple.mode, simple.budget) == ("simple", "direct", 1)
retry = ModeRouter(max_budget=6).plan("additionner", {"t.py": "def test_a():\n assert 1\n"}, 2)
assert retry.level == "moyenne" and retry.budget == 3
hard = ModeRouter(max_budget=4).plan("arbre récursif, graphe, dijkstra et programmation dynamique " * 20,
{"t.py": "def test_a():\n assert 1\n" * 12}, 1)
assert hard.mode == "mcts" and hard.budget == 4
# ================================================================= ancrage
def test_api_check_flags_invented_names_only():
from unique_ext.grounding.api_check import ApiCheck
code = "from math import sqrtt, sqrt\nimport json\nfrom concurrent import futures\nimport helpers\n"
assert ApiCheck().check({"solution.py": code, "helpers.py": "X = 1\n"}) == ["API introuvable : math.sqrtt"]
def test_citations_must_come_from_web_notes():
from unique_ext.grounding.citations import CitationCheck
code = "# source : https://docs.python.org/3/library/heapq.html.\n# voir https://exemple.invente/page\nURL = 'https://x.y'\n"
notes = ["heapq : file de priorité (https://docs.python.org/3/library/heapq.html)"]
issues = CitationCheck().check({"solution.py": code}, notes)
assert issues == ["citation non sourcée dans solution.py : https://exemple.invente/page"]
# ================================================================= mémoire filtrée
def test_gated_memory_shows_only_close_but_not_identical_examples():
from mouh.learning.memory import TaskMemory
from unique_ext.memory.gated import GatedMemory
items = [{"task_hash": "a", "instruction": "trier une liste d'entiers par ordre croissant", "files": {}},
{"task_hash": "b", "instruction": "trier une liste d'entiers par ordre croissant sans doublons",
"files": {}},
{"task_hash": "d", "instruction": "trier une liste de nombres par ordre croissant", "files": {}},
{"task_hash": "e", "instruction": "trier des fichiers", "files": {}},
{"task_hash": "c", "instruction": "calculer la météo", "files": {}}]
loads = []
mem = GatedMemory(lambda: loads.append(1) or TaskMemory(items), low=0.3, high=0.8, k=3)
q = "trier une liste de nombres par ordre croissant"
assert mem.examples(q, {}, "simple") == [] # tâche simple : aucun exemple
got = [g["task_hash"] for g in mem.examples(q, {}, "moyenne")]
# d : quasi-doublon (souffle la réponse) ; e et c : trop éloignés pour aider
assert sorted(got) == ["a", "b"] and loads == [1]
# ================================================================= session côté Space
def test_extension_session_runs_inside_the_mouh_loop(monkeypatch):
import mouh.service as S
from unique_ext.reasoning.session import ExtSession
label = "UNIQUE · test"
session = ExtSession(None, ScriptedBackend(), grounders=())
monkeypatch.setattr(S, "EXT_CHOICES", (label,))
monkeypatch.setattr(S, "_ext_build", lambda choice, load_unique, web_search=False: (session, "scripté"))
snaps = list(S.stream_session("additionner a et b", "", TESTS["test_solution.py"], max_attempts=2,
generator_choice=label, record=False))
final = snaps[-1]
assert "return a + b" in final["final_code"]
assert session.info["essais"] and session.info["essais"][-1]["tests_ok"]
assert json.loads(final["report"])["reflexion"]["extension"] == "adaptatif"
def test_extension_session_falls_back_when_a_brick_fails():
from unique_ext.reasoning.session import ExtSession
class Boom:
name = "boom"
def solve(self, *a, **k):
raise RuntimeError("panne simulée")
class V38:
def generate(self, request):
return {"solution.py": "def add(a, b):\n return a + b\n"}
s = ExtSession(V38(), ScriptedBackend(), strategy=Boom(), grounders=())
assert s.generate(request()) == {"solution.py": "def add(a, b):\n return a + b\n"}
assert "panne simulée" in s.info["repli"]
assert ExtSession(None, ScriptedBackend(), strategy=Boom(), grounders=()).generate(request()) is None
# ================================================================= backends
@pytest.fixture(scope="module")
def tiny_qwen(tmp_path_factory):
pytest.importorskip("peft")
from tokenizers import Tokenizer, decoders, models, pre_tokenizers, trainers
from transformers import PreTrainedTokenizerFast, Qwen2Config, Qwen2ForCausalLM
from mouh.learning import prompts as P
d = tmp_path_factory.mktemp("tiny_qwen_ext")
tk = Tokenizer(models.BPE())
tk.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
tk.decoder = decoders.ByteLevel()
corpus = [P.SYSTEM_PROMPT, render_files({"solution.py": "def f():\n return 1\n"}), "## Tâche ## Tests assert"] * 20
tk.train_from_iterator(corpus, trainers.BpeTrainer(vocab_size=400, special_tokens=["<|endoftext|>", "<|im_start|>",
"<|im_end|>"],
initial_alphabet=pre_tokenizers.ByteLevel.alphabet()))
tok = PreTrainedTokenizerFast(tokenizer_object=tk, eos_token="<|im_end|>", pad_token="<|endoftext|>")
tok.chat_template = ("{% for m in messages %}<|im_start|>{{ m['role'] }}\n{{ m['content'] }}<|im_end|>\n{% endfor %}"
"{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}")
tok.save_pretrained(d)
cfg = Qwen2Config(vocab_size=len(tok), hidden_size=32, intermediate_size=64, num_hidden_layers=2,
num_attention_heads=4, num_key_value_heads=2, max_position_embeddings=4096,
eos_token_id=tok.eos_token_id, pad_token_id=tok.pad_token_id, tie_word_embeddings=True)
Qwen2ForCausalLM(cfg).save_pretrained(d)
return str(d)
def test_prompt_lookup_and_draft_keep_the_greedy_output(tiny_qwen):
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from unique_ext.backends.hf import HFBackend
torch.manual_seed(0)
gen = SimpleNamespace(model=AutoModelForCausalLM.from_pretrained(tiny_qwen).eval(),
tokenizer=AutoTokenizer.from_pretrained(tiny_qwen), max_new_tokens=24)
msgs = chat_messages("additionner", TESTS, {"solution.py": "def add(a, b):\n return a - b\n"}, ["assert -1 == 5"])
plain = HFBackend(gen, lookup=0, draft="").complete(msgs, 1)
fast = HFBackend(gen, lookup=10, draft="").complete(msgs, 1)
draft = HFBackend(gen, lookup=0, draft=tiny_qwen).complete(msgs, 1) # le modèle comme son propre brouillon
assert plain == fast == draft
three = HFBackend(gen, lookup=10, draft="").complete(msgs, 3)
assert len(three) == 3 and three[0] == plain[0]
# ================================================================= jobs : export, banc d'essai, pilote
def test_liger_is_skipped_cleanly_without_gpu(monkeypatch):
import torch
from unique_ext import registry
monkeypatch.setenv("UNIQUE_EXT_JOB_LIGER", "1")
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
logs = []
assert registry.job_setup(logs.append) == [] and "pas de GPU" in logs[0]
# ================================================================= phases DPO et KTO dans le vrai job
def session(i):
tests = {"test_solution.py": f"from solution import f\n\ndef test_f():\n assert f() == {i}\n"}
th = task_hash(f"tâche {i}", tests)
final = {"solution.py": f"def f():\n return {i}\n"}
return {"schema": "mouh-session/1", "session_id": f"s{i}", "task_hash": th, "split": split_for(th),
"instruction": f"tâche {i}", "tests": tests, "proofs": [], "generator": "x", "status": "certified",
"certified": True, "final_files": final,
"attempts": [{"attempt": 1, "origin": "model", "files": final, "score": 1.0, "certified": True,
"diagnostics": []}]}
def test_dpo_and_kto_phases_run_through_the_same_gate(tiny_qwen, tmp_path, monkeypatch):
pytest.importorskip("trl")
job = load("jobs/train_unique.py", "train_unique_ext_t2")
data, out = tmp_path / "data", tmp_path / "out"
data.mkdir()
sessions = [session(i) for i in range(60)]
(data / "s.jsonl").write_text("\n".join(json.dumps(s) for s in sessions))
fails = [{"task_hash": s["task_hash"], "instruction": s["instruction"], "tests": s["tests"],
"files": {"solution.py": "def f():\n return -1\n"}, "pytest_output": "E assert -1 == 1"}
for s in sessions if s["split"] == "train"][:24]
(out / "failures").mkdir(parents=True)
(out / "failures" / "f.jsonl").write_text("\n".join(json.dumps(f) for f in fails))
monkeypatch.setenv("UNIQUE_PHASES", "dpo:20,kto:20")
monkeypatch.setenv("UNIQUE_EXT_PATH", str(ROOT))
monkeypatch.setenv("UNIQUE_EXT_PREF_BATCH", "2")
monkeypatch.setenv("UNIQUE_EXT_PREF_ACCUM", "1")
fast = ["--min-heldout", "1", "--sft-chunk", "4", "--batch-size", "2", "--grad-accum", "1", "--max-len", "512",
"--grpo-steps", "0", "--max-new-tokens", "8", "--progress-every", "0", "--benchmark", "0",
"--memory-ab", "0", "--promotion-margin", "1", "--measure-best-of", "0", "--save-failures", "0"]
assert job.main(["--local-data", str(data), "--local-out", str(out), "--base-model", tiny_qwen, "--cycles", "1",
*fast]) == 0
st = json.loads((out / "state.json").read_text())
hist = {h["phase"]: h for h in st["history"]}
assert list(hist) == ["sft", "dpo", "kto"], [h.get("phase") for h in st["history"]]
assert hist["dpo"]["train"]["pairs"] == 20 and hist["dpo"]["train"]["steps"] > 0
assert hist["kto"]["train"]["examples"] >= 16 and hist["kto"]["train"]["steps"] > 0
assert all("candidate" in h for h in hist.values()) # chaque phase est examinée par la porte
def test_dpo_uses_the_promoted_adapter_as_reference_and_saves_only_the_policy(tiny_qwen, tmp_path):
pytest.importorskip("trl")
from peft import LoraConfig, PeftModel, get_peft_model
from transformers import AutoModelForCausalLM, AutoTokenizer
from unique_ext.contracts import PhaseContext
from unique_ext.training.dpo import DPOPhase
tok = AutoTokenizer.from_pretrained(tiny_qwen)
promoted = tmp_path / "promoted"
get_peft_model(AutoModelForCausalLM.from_pretrained(tiny_qwen),
LoraConfig(r=4, lora_alpha=8, task_type="CAUSAL_LM", target_modules=["q_proj", "v_proj"])
).save_pretrained(promoted)
policy = PeftModel.from_pretrained(AutoModelForCausalLM.from_pretrained(tiny_qwen), str(promoted), is_trainable=True)
sessions = [session(i) for i in range(40)]
train = [s for s in sessions if s["split"] == "train"]
fails = [{"task_hash": s["task_hash"], "instruction": s["instruction"], "tests": s["tests"],
"files": {"solution.py": "def f():\n return -1\n"}, "pytest_output": "E"} for s in train]
ctx = PhaseContext(args=SimpleNamespace(max_len=512, seed=0), model=policy, tok=tok, sessions=sessions,
frozen=set(), out_dir=tmp_path / "dpo", cycle=1, run_id="t", token=None, progress=None,
chat_messages=chat_messages, render_files=render_files, run_tests=None,
load_failures=lambda n: fails[:n], make_pytest_reward=None, adapter=str(promoted))
info = DPOPhase().run(ctx, 16)
assert info["pairs"] == 16 and info["steps"] > 0 and info["reference"] == "copie de l'adaptateur promu"
assert list(policy.peft_config) == ["default"] # « ref » retiré : seul l'adaptateur entraîné est enregistré
policy.save_pretrained(tmp_path / "saved")
assert not (tmp_path / "saved" / "ref").exists()
def test_app_menu_lists_enabled_extensions(monkeypatch):
import app
import mouh.service as S
monkeypatch.setenv("UNIQUE_EXT_TREE", "1")
monkeypatch.setenv("UNIQUE_EXT_GGUF", "1") # v4.1 : retiré, sans effet
try:
importlib.reload(S)
importlib.reload(app) # l'interface se construit, le générateur étendu ajouté en fin de menu
assert app.GENERATOR_CHOICES[:4] == V38_CHOICES and len(app.GENERATOR_CHOICES) == 5
finally:
monkeypatch.delenv("UNIQUE_EXT_TREE")
monkeypatch.delenv("UNIQUE_EXT_GGUF")
importlib.reload(S)
importlib.reload(app)
# ================================================================= mise à jour par un seul zip (téléphone)
class FakeHub:
"""Dépôt du Space versionné en mémoire + dataset d'état : ce que l'updater lit et écrit."""
def __init__(self, tmp: Path, files: dict[str, bytes]):
self.tmp, self.revs, self.head, self.commits, self.dataset = tmp, {"r0": dict(files)}, "r0", [], {}
def repo_info(self, repo, repo_type=None):
return SimpleNamespace(sha=self.head)
def hf_hub_download(self, repo, path, repo_type=None, revision=None):
store = self.dataset if repo_type == "dataset" else self.revs[revision or self.head]
if path not in store:
raise FileNotFoundError(path)
out = self.tmp / f"dl_{len(list(self.tmp.iterdir()))}"
out.write_bytes(store[path])
return str(out)
def create_commit(self, repo_id, repo_type, operations, commit_message, parent_commit=None):
assert parent_commit in (None, self.head)
files = dict(self.revs[self.head])
for op in operations:
if hasattr(op, "path_or_fileobj"):
files[op.path_in_repo] = op.path_or_fileobj
else:
files.pop(op.path_in_repo, None)
self.head = f"r{len(self.revs)}"
self.revs[self.head] = files
self.commits.append((commit_message, [op.path_in_repo for op in operations]))
return SimpleNamespace(oid=self.head)
def upload_file(self, path_or_fileobj, path_in_repo, repo_id, repo_type, commit_message):
self.dataset[path_in_repo] = path_or_fileobj
def make_zip(entries: dict[str, str], prefix: str = "") -> bytes:
import io
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
for name, text in entries.items():
z.writestr(prefix + name, text)
return buf.getvalue()
def manifest_for(files: dict[str, str], version: str) -> str:
from unique_ext.updater import normalized_hash
return json.dumps({"version": version, "files": {p: normalized_hash(t) for p, t in files.items()}})
@pytest.fixture
def space(tmp_path):
root = tmp_path / "app"
current = {"app.py": "print('v1')\n", "unique_ext/a.py": "A = 1\n", "requirements.txt": "gradio\n"}
for p, t in current.items():
(root / p).parent.mkdir(parents=True, exist_ok=True)
(root / p).write_text(t)
(tmp_path / "dl").mkdir()
hub = FakeHub(tmp_path / "dl", {p: t.encode() for p, t in current.items()})
return root, hub
def test_update_installs_in_one_commit_is_idempotent_and_rolls_back(space, monkeypatch):
from unique_ext import updater as U
root, hub = space
new = {"app.py": "print('v2')\n", "unique_ext/a.py": "A = 1\n", "unique_ext/b.py": "B = 2\n"}
tracked = {**new, "requirements.txt": "gradio\n"}
zbytes = make_zip({**new, "mouh/learning/manifest.json": manifest_for(tracked, "v9.9")}, prefix="unique-v9.9/")
hub.revs["r0"]["updates/u.zip"] = zbytes
state = {}
rs, ws = (lambda: state.get("s")), (lambda obj: state.update(s=obj))
msg = U.install(hub, "RDS777/TARGET_SPACE", "updates/u.zip", root=root, read_state=rs, write_state=ws)
assert "v9.9 installée" in msg and len(hub.commits) == 1
assert sorted(hub.commits[0][1]) == ["app.py", "mouh/learning/manifest.json", "unique_ext/b.py"] # a.py inchangé
assert hub.revs[hub.head]["app.py"] == b"print('v2')\n" and state["s"]["new_files"] == [
"mouh/learning/manifest.json", "unique_ext/b.py"]
for p, t in new.items(): # le Space s'est reconstruit avec les nouveaux fichiers
(root / p).write_text(t)
(root / "mouh/learning").mkdir(parents=True)
(root / "mouh/learning/manifest.json").write_text(manifest_for(tracked, "v9.9"))
again = U.install(hub, "RDS777/TARGET_SPACE", "updates/u.zip", root=root, read_state=rs, write_state=ws)
assert "déjà en place" in again and len(hub.commits) == 1 # pas de boucle de redémarrages
back = U.rollback(hub, "RDS777/TARGET_SPACE", read_state=rs, write_state=ws)
files = hub.revs[hub.head]
assert "retour arrière fait" in back and files["app.py"] == b"print('v1')\n"
assert "unique_ext/b.py" not in files and "mouh/learning/manifest.json" not in files
assert "déjà fait" in U.rollback(hub, "RDS777/TARGET_SPACE", read_state=rs, write_state=ws)
def test_update_removes_retired_files_and_rollback_restores_them(space):
from unique_ext import updater as U
root, hub = space
(root / "unique_ext/old.py").write_text("OLD = 1\n")
hub.revs["r0"]["unique_ext/old.py"] = b"OLD = 1\n"
new = {"app.py": "print('v2')\n", "unique_ext/a.py": "A = 1\n"}
man = json.loads(manifest_for({**new, "requirements.txt": "gradio\n"}, "v4.1"))
man["removed"] = ["unique_ext/old.py", "unique_ext/absent.py"]
hub.revs["r0"]["updates/u.zip"] = make_zip({**new, "mouh/learning/manifest.json": json.dumps(man)})
state = {}
rs, ws = (lambda: state.get("s")), (lambda obj: state.update(s=obj))
msg = U.install(hub, "RDS777/TARGET_SPACE", "updates/u.zip", root=root, read_state=rs, write_state=ws)
assert "1 retiré(s)" in msg and "unique_ext/old.py" not in hub.revs[hub.head]
assert state["s"]["removed"] == ["unique_ext/old.py"] and len(hub.commits) == 1
(root / "unique_ext/old.py").unlink() # le Space s'est reconstruit sans le fichier retiré
for p, t in new.items():
(root / p).write_text(t)
(root / "mouh/learning").mkdir(parents=True, exist_ok=True)
(root / "mouh/learning/manifest.json").write_text(json.dumps(man))
again = U.install(hub, "RDS777/TARGET_SPACE", "updates/u.zip", root=root, read_state=rs, write_state=ws)
assert "déjà en place" in again and len(hub.commits) == 1
assert "retour arrière fait" in U.rollback(hub, "RDS777/TARGET_SPACE", read_state=rs, write_state=ws)
assert hub.revs[hub.head]["unique_ext/old.py"] == b"OLD = 1\n"
bad = make_zip({"mouh/learning/manifest.json": json.dumps(
{"version": "x", "files": {}, "removed": ["../etc/passwd", "Dockerfile", "unique_ext/../app.py"]})})
b = U.read_bundle(bad, root)
assert b.removed == [] and sum("suppression refusée" in p for p in b.problems) == 3
@pytest.mark.parametrize("entries, reason", [
({"../evil.py": "x = 1\n"}, "chemin refusé"),
({"README.md": "---\nsdk: gradio\n---\n"}, "hors du périmètre"),
({"Dockerfile": "FROM scratch\n"}, "hors du périmètre"),
({"unique_ext/c.py": "def f(:\n"}, "compilation"),
({"requirements.txt": "torch\n"}, "sans gradio"),
({"app.py": "print('v2')\n", "mouh/learning/manifest.json": json.dumps(
{"version": "v9", "files": {"app.py": "0" * 16, "unique_ext/zz.py": "1" * 16}})}, "absent du paquet ou différent"),
])
def test_update_refuses_unsafe_or_incomplete_bundles(space, entries, reason):
from unique_ext import updater as U
root, hub = space
hub.revs["r0"]["updates/bad.zip"] = make_zip(entries)
state = {}
msg = U.install(hub, "RDS777/TARGET_SPACE", "updates/bad.zip", root=root, read_state=lambda: None,
write_state=lambda o: state.update(s=o))
assert "refusée" in msg and reason in msg and not hub.commits and state["s"]["ok"] is False
def test_update_does_nothing_unless_asked(monkeypatch):
from unique_ext.updater import ensure_update
monkeypatch.delenv("UNIQUE_EXT_INSTALL", raising=False)
monkeypatch.delenv("UNIQUE_EXT_ROLLBACK", raising=False)
assert ensure_update(None, {"token": "tok"})[0] == "mise à jour : aucune demandée"
# ================================================================= livraison
def test_release_manifest_is_current_and_release_zip_passes_the_updater(tmp_path):
import io
import zipfile
br = load("scripts/build_release.py", "build_release_t")
assert br.check() == [] # le manifest livré correspond aux fichiers (sinon : python scripts/build_release.py)
from unique_ext.updater import read_bundle
man = br.build_manifest("vtest")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
for rel in br.runtime_files():
z.write(ROOT / rel, rel)
z.writestr(br.MANIFEST, json.dumps(man))
b = read_bundle(buf.getvalue(), root=tmp_path) # Space vide : tout doit venir du zip
assert b.problems == [] and b.version == "vtest" and len(b.files) == len(man["files"]) + 1
# ================================================================= v3.10 : passage au 7B
def test_space_refuses_to_load_a_7b_in_float32_on_cpu(monkeypatch):
import torch
from mouh.learning.unique_generator import UniqueGenerator, model_size_b
assert model_size_b("Qwen/Qwen2.5-Coder-7B-Instruct") == 7.0 and model_size_b("/tmp/tiny") is None
monkeypatch.setenv("UNIQUE_BASE_MODEL", "Qwen/Qwen2.5-Coder-7B-Instruct")
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
with pytest.raises(RuntimeError, match="GPU"):
UniqueGenerator.from_env()
def test_training_job_loads_large_models_in_bf16_only_on_a_suitable_gpu(monkeypatch):
import torch
job = load("jobs/train_unique.py", "train_unique_ext_t3")
big, small = SimpleNamespace(base_model="Qwen/Qwen2.5-Coder-7B-Instruct"), SimpleNamespace(base_model="/tmp/tiny")
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda *a: (8, 9))
monkeypatch.setattr(torch.cuda, "get_device_properties", lambda *a: SimpleNamespace(total_memory=24 * 2**30))
assert job._load_dtype(big) == torch.bfloat16 and job._load_dtype(small) == torch.float32
assert job.gpu_problem(7.0) is None # L4 : 24 Go, bf16
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda *a: (7, 5))
assert "UNIQUE_FLAVOR=l4x1" in job.gpu_problem(7.0) # T4 : refusé avant tout chargement
assert job._load_dtype(big) == torch.float32
# ================================================================= v3.10.1 : A10G small, coût réel, état des jobs
def test_large_models_load_straight_onto_the_gpu(monkeypatch):
import torch
import transformers
job = load("jobs/train_unique.py", "train_unique_ext_t4")
seen = {}
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda *a: (8, 6))
monkeypatch.setattr(transformers.AutoModelForCausalLM, "from_pretrained",
lambda name, **kw: seen.update(kw) or "modèle")
assert job._load_base(SimpleNamespace(base_model="Qwen/Qwen2.5-Coder-7B-Instruct"), None) == "modèle"
assert seen["device_map"] == {"": 0} and seen["dtype"] == torch.bfloat16 # jamais 15 Go en RAM d'un coup
def test_progress_tracks_the_real_cost_of_each_run(tmp_path, monkeypatch):
job = load("jobs/train_unique.py", "train_unique_ext_t5")
store = job.Store(local_out=str(tmp_path), adapter_repo="x", bucket="b", token=None)
today = job._now().strftime("%Y-%m-%d")
store.write_progress({"day": today, "gpu_hours_today": 16.0, "adapter": "x"}, True) # 16 h d'avant, sur T4
monkeypatch.setenv("UNIQUE_GPU_PRICE_PER_H", "1.00")
pr = job.Progress(store, run_id="r", max_hours=1, daily_budget_h=24, min_interval_s=0, adapter="x")
pr.t0 -= 1800 # une demi-heure sur A10G small
pr.update(important=True)
st = store.read_progress()
assert st["gpu_cost_before_run"] == 6.4 and abs(st["gpu_cost_today"] - 6.9) < 0.01 and st["gpu_price_per_h"] == 1.0
# ================================================================= v3.10.2 : professeur, faux refus de jeton
# ================================================================= v3.10.3 : jeton renouvelé, doublons, droits Jobs
# ================================================================= v3.10.4 : mesures annexes après l'examen
def test_measures_free_the_gpu_before_loading_another_7b(monkeypatch):
job = load("jobs/train_unique.py", "train_unique_ext_measures")
events = []
monkeypatch.setattr(job, "_free_gpu", lambda: events.append("libère"))
monkeypatch.setattr(job, "_load_base", lambda a, token: events.append("charge base") or "base")
monkeypatch.setattr(job, "load_policy", lambda a, tok, token, src: events.append("charge meilleur") or "meilleur")
monkeypatch.setattr(job, "evaluate", lambda model, *r, **k: {"passed": 1, "n": 2, "rate": 0.5, "ci95": [0, 1]})
store = SimpleNamespace(adapter_source=lambda: "me/a", write_json=lambda *r, **k: None)
progress = SimpleNamespace(update=lambda **k: None, phase=lambda *r, **k: None)
a = SimpleNamespace(measure_best_of=1, memory_ab=0)
state = {"best": {"passed": 85, "n": 100, "source": "sft r c1"}, "base": {"passed": 80, "n": 100}, "heldout_sig": "s"}
job.measure_best(a, None, None, store, state, [], [], [{"task_hash": "humaneval:0"}], progress, "r")
# vu le 05/11 : le 7B de GRPO occupait encore le GPU quand le 7B de base a été chargé pour HumanEval
assert events == ["libère", "charge base", "libère", "charge meilleur", "libère"]
assert state["measures"]["humaneval"]["passed"] == 1
def test_a_failing_measure_never_stops_the_run(tiny_qwen, tmp_path, monkeypatch):
pytest.importorskip("trl")
from test_learning import FAST, write_sessions
job = load("jobs/train_unique.py", "train_unique_ext_measures_fail")
calls = []
def oom(*a, **k):
calls.append(1)
raise RuntimeError("CUDA out of memory. Tried to allocate 1.02 GiB")
monkeypatch.setattr(job, "measure_best", oom)
data, out = tmp_path / "data", tmp_path / "out"
write_sessions(data, 40)
args = ["--local-data", str(data), "--local-out", str(out), "--base-model", tiny_qwen, "--cycles", "2", *FAST]
args[args.index("--grpo-steps") + 1] = "0"
assert job.main(args) == 0 and len(calls) == 2 # le passage continue : 2 cycles faits
st = json.loads((out / "state.json").read_text())
assert st["last_error"]["phase"] == "mesures" and "out of memory" in st["measures"]["error"]
assert [h["phase"] for h in st["history"]] == ["sft", "sft"]
assert json.loads((out / "progress.json").read_text())["phase"] == "fin"
# ================================================================= v3.10.5 : maître fiable, 409 lisible
# ================================================================= v3.10.6 : révision du maître, énoncé vide