Spaces:
Running
Running
File size: 38,577 Bytes
8efb1a5 380a7fc 310ea56 380a7fc 310ea56 380a7fc 8a4176f 380a7fc 8a4176f 380a7fc 8a4176f 380a7fc 310ea56 | 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 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 | """Apprentissage automatique : journalisation, format d'invite partagé, job LoRA (sur un Qwen2 minuscule, CPU)."""
import importlib.util
import json
from pathlib import Path
import pytest
from mouh.jev.types import JevMessageType as T, TaskPayload
from mouh.learning import TrajectoryRecorder, build_record, chat_messages, render_files, split_for, task_hash
from mouh.learning import prompts as P
from mouh.verification import CallableGenerator, TestTimeVerificationLoop
from mouh.verification.generators import parse_files
ROOT = Path(__file__).resolve().parents[1]
TESTS = {"test_solution.py": "from solution import add\n\ndef test_add():\n assert add(2, 3) == 5\n"}
def load_job():
spec = importlib.util.spec_from_file_location("train_unique", ROOT / "jobs" / "train_unique.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
JOB = load_job()
# ---------------------------------------------------------------- format partagé Space <-> job
@pytest.mark.parametrize("prev,diags", [(None, None), ({"solution.py": "x=1\n"}, ["boom", " autre "])])
def test_job_prompt_copy_is_identical(prev, diags):
assert JOB.SYSTEM_PROMPT == P.SYSTEM_PROMPT
assert JOB.chat_messages("t", TESTS, prev, diags) == P.chat_messages("t", TESTS, prev, diags)
files = {"solution.py": "def add(a, b):\n return a + b\n", "pkg/util.py": "X = 1\n"}
assert JOB.render_files(files) == P.render_files(files)
def test_rendered_target_parses_back():
files = {"solution.py": "def add(a, b):\n return a + b\n", "pkg/util.py": "X = 1\n"}
assert parse_files(render_files(files)) == files
assert JOB.parse_files(render_files(files)) == files
# ---------------------------------------------------------------- bus et enregistrement
def run_with_repair():
calls = []
def gen(req):
calls.append(req)
return {"solution.py": "def add(a, b):\n return a + b\n"}
loop = TestTimeVerificationLoop(CallableGenerator(gen), max_attempts=3)
task = TaskPayload(instruction="Écrire add", candidate_files={"solution.py": "def add(a, b):\n return a - b\n"},
test_files=TESTS)
return task, loop.run(task), calls
def test_router_decision_precedes_code_proposal():
_, out, calls = run_with_repair()
types = [m.type for m in out.messages]
for i, t in enumerate(types):
if t == T.CODE_PROPOSAL:
assert types[i - 1] == T.ROUTE_DECISION, types
assert calls and calls[0].experts # le générateur reçoit les experts décidés par le routeur
def test_build_record_keeps_attempts_and_repair():
task, out, _ = run_with_repair()
rec = build_record(task, out, "test")
assert rec["certified"] and rec["status"] == "certified"
assert [a["certified"] for a in rec["attempts"]] == [False, True]
assert rec["attempts"][0]["diagnostics"] and "a - b" in rec["attempts"][0]["files"]["solution.py"]
assert rec["final_files"]["solution.py"].endswith("a + b\n")
assert rec["split"] == split_for(rec["task_hash"]) and rec["task_hash"] == task_hash(task.instruction, TESTS)
json.dumps(rec) # sérialisable
def test_task_hash_ignores_whitespace_and_split_is_deterministic():
assert task_hash("a b", {"t.py": "x = 1"}) == task_hash("a b", {"t.py": "x = 1\n"})
splits = {split_for(task_hash(f"t{i}", TESTS)) for i in range(200)}
assert splits == {"train", "heldout"}
class FakeScheduler:
def __init__(self, **kw):
import threading
self.kw, self.lock = kw, threading.Lock()
def test_recorder_local_and_remote(tmp_path):
local = TrajectoryRecorder(tmp_path / "a")
assert not local.remote
local.record({"x": 1})
assert json.loads(local.path.read_text()) == {"x": 1}
remote = TrajectoryRecorder(tmp_path / "b", "me/ds", "tok", every_min=3, scheduler_factory=FakeScheduler)
assert remote.remote and remote._scheduler.kw["private"] and remote._scheduler.kw["repo_type"] == "dataset"
assert remote._scheduler.kw["allow_patterns"] == ["data/*.jsonl"]
def test_service_records_session(tmp_path, monkeypatch):
import mouh.learning.recorder as R
from mouh.service import stream_session
monkeypatch.setattr(R, "_RECORDER", TrajectoryRecorder(tmp_path))
snaps = list(stream_session("add", "def add(a, b):\n return a + b\n", TESTS["test_solution.py"]))
rep = json.loads(snaps[-1]["report"])
assert rep["journalisation"].startswith("oui")
rec = json.loads(R._RECORDER.path.read_text().splitlines()[-1])
assert rec["certified"] and rec["schema"] == "mouh-session/1"
snaps = list(stream_session("add", "def add(a, b):\n return a + b\n", TESTS["test_solution.py"], record=False))
assert json.loads(snaps[-1]["report"])["journalisation"].startswith("non")
assert len(R._RECORDER.path.read_text().splitlines()) == 1
# ---------------------------------------------------------------- données du job
def session(i, *, certified=True, tests=None, split=None, failed_first=True):
tests = tests or {"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"}
attempts = []
if failed_first:
attempts.append({"attempt": 1, "origin": "user", "files": {"solution.py": "def f():\n return -1\n"},
"score": 0.7, "certified": False, "diagnostics": [f"assert -1 == {i}"]})
attempts.append({"attempt": len(attempts) + 1, "origin": "model", "files": final, "score": 1.0,
"certified": certified, "diagnostics": []})
return {"schema": "mouh-session/1", "session_id": f"s{i}", "task_hash": th, "split": split or split_for(th),
"instruction": f"tâche {i}", "tests": tests, "proofs": [], "generator": "x",
"status": "certified" if certified else "exhausted", "certified": certified,
"final_files": final if certified else None, "attempts": attempts}
def test_build_examples_filters_and_separates_heldout():
sessions = [session(1, split="train"), session(2, split="heldout"), session(3, certified=False, split="train"),
session(4, split="train", tests={"test_solution.py": "def test_x():\n assert True\n"}),
session(1, split="train")] # doublon exact
train, heldout, stats = JOB.build_examples(sessions)
assert stats["kept"] == 3 and [t["task_hash"] for t in heldout] == [sessions[1]["task_hash"]]
assert sorted(e["kind"] for e in train) == ["fix", "gen"] # doublon retiré, tâche 4 rejetée (n'importe pas solution)
assert all(sessions[1]["instruction"] not in json.dumps(e) for e in train)
def test_run_tests_really_executes():
t = {"test_solution.py": "from solution import f\n\ndef test_f():\n assert f() == 3\n"}
assert JOB.run_tests({"solution.py": "def f():\n return 3\n"}, t)
assert not JOB.run_tests({"solution.py": "def f():\n return 4\n"}, t)
assert not JOB.run_tests({"solution.py": "import time\ndef f():\n time.sleep(5)\n"}, t, timeout_s=2)
# ---------------------------------------------------------------- job complet sur un modèle minuscule
@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
d = tmp_path_factory.mktemp("tiny_qwen")
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 write_sessions(path: Path, n: int, start: int = 0):
path.mkdir(parents=True, exist_ok=True)
with open(path / f"s{start}.jsonl", "w") as fh:
for i in range(start, start + n):
fh.write(json.dumps(session(i)) + "\n")
def test_unique_generator_runs_in_the_loop(tiny_qwen, monkeypatch):
import mouh.service as S
monkeypatch.setenv("UNIQUE_BASE_MODEL", tiny_qwen)
monkeypatch.setenv("UNIQUE_MAX_NEW_TOKENS", "8")
monkeypatch.delenv("UNIQUE_ADAPTER_REPO", raising=False)
S._MODEL_CACHE.pop("unique", None)
snaps = list(S.stream_session("add", "def add(a, b):\n return a - b\n", TESTS["test_solution.py"], max_attempts=2,
generator_choice=S.GENERATOR_CHOICES[2], record=False))
assert snaps[-1]["status"].startswith("AUCUNE SORTIE") # modèle aléatoire : rien de non prouvé n'est émis
S._MODEL_CACHE.pop("unique", None)
# ---------------------------------------------------------------- pilote automatique (tout sur HF)
def test_app_builds():
import importlib
import app # noqa: F401 — l'interface se construit sans réseau ni jeton
importlib.reload(app)
# ---------------------------------------------------------------- alimentation en masse
# ---------------------------------------------------------------- boucle continue (TRL SFT + GRPO)
FAST = ["--min-heldout", "1", "--sft-chunk", "8", "--batch-size", "2", "--grad-accum", "1", "--max-len", "1024",
"--grpo-steps", "2", "--grpo-batch", "4", "--grpo-generations", "2", "--grpo-max-completion", "16",
"--max-new-tokens", "8", "--progress-every", "0", "--benchmark", "0", "--memory-ab", "0",
"--promotion-margin", "1", "--measure-best-of", "0"]
def test_pytest_reward_scores_real_execution():
r = JOB.make_pytest_reward(workers=2)
tests = json.dumps({"test_solution.py": "from solution import f\n\ndef test_f():\n assert f() == 3\n"})
good = [{"role": "assistant", "content": render_files({"solution.py": "def f():\n return 3\n"})}]
bad = render_files({"solution.py": "def f():\n return 4\n"})
assert r(completions=[good, bad], tests=[tests] * 2) == [1.0, 0.1]
assert r(completions=["### FILE: solution.py\n```python\ndef (\n```\n### END"], tests=[tests]) == [0.0]
def test_continuous_loop_runs_sft_and_grpo_with_trl(tiny_qwen, tmp_path):
pytest.importorskip("trl")
data, out = tmp_path / "data", tmp_path / "out"
write_sessions(data, 60)
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())
assert [h["phase"] for h in st["history"]] == ["sft", "grpo"]
assert st["history"][0]["train"]["steps"] > 0 and st["history"][1]["train"]["steps"] == 2
assert not any(h["promoted"] for h in st["history"]) # modèle aléatoire : rien n'est promu
prog = json.loads((out / "progress.json").read_text())
assert prog["phase"] == "fin" and prog["cycle"] == 1 and len(prog["history"]) == 2
assert prog["base"]["n"] == len(st["heldout"]) and "rewards/pytest_reward/mean" in prog["metrics"]
def test_gate_promotes_only_strict_improvement_and_heldout_is_frozen(tiny_qwen, tmp_path, monkeypatch):
pytest.importorskip("trl")
data, out = tmp_path / "data", tmp_path / "out"
write_sessions(data, 60)
scores = iter([1, 3, 2, 4, 4]) # passage 1 : base 1, SFT 3 (promu), GRPO 2 ; passage 2 : base en cache, SFT 4 (promu), GRPO 4
monkeypatch.setattr(JOB, "pass_at_1", lambda *a, **k: {"passed": next(scores), "n": 5, "rate": 0, "ci95": [0, 1]})
args = ["--local-data", str(data), "--local-out", str(out), "--base-model", tiny_qwen, "--cycles", "1",
"--max-heldout", "3", *FAST]
assert JOB.main(args) == 0
st1 = json.loads((out / "state.json").read_text())
assert [h["promoted"] for h in st1["history"]] == [True, False] and (out / "adapter" / "adapter_config.json").exists()
write_sessions(data, 20, start=500) # nouvelles données : le heldout figé ne change pas
assert JOB.main(args) == 0
st2 = json.loads((out / "state.json").read_text())
assert st2["heldout"] == st1["heldout"] and st2["base"]["passed"] == 1
assert [h["promoted"] for h in st2["history"]] == [True, False, True, False] and st2["best"]["passed"] == 4
assert st2["sft_cursor"] == 16 # la 2e phase SFT a pris la tranche suivante
def test_daily_gpu_budget_pauses_the_loop(tiny_qwen, tmp_path):
data, out = tmp_path / "data", tmp_path / "out"
write_sessions(data, 30)
assert JOB.main(["--local-data", str(data), "--local-out", str(out), "--base-model", tiny_qwen,
"--daily-gpu-hours", "0", *FAST]) == 0
prog = json.loads((out / "progress.json").read_text())
assert prog["phase"] == "pause" and "budget" in prog["status"] and not (out / "state.json").exists()
def test_waits_when_not_enough_heldout(tiny_qwen, tmp_path):
data, out = tmp_path / "data", tmp_path / "out"
write_sessions(data, 30)
args = ["--local-data", str(data), "--local-out", str(out), "--base-model", tiny_qwen, *FAST]
args[args.index("--min-heldout") + 1] = "1000"
assert JOB.main(args) == 0
assert json.loads((out / "progress.json").read_text())["phase"] == "attente"
def test_heldout_extension_remeasures_current_adapter(tiny_qwen, tmp_path, monkeypatch):
pytest.importorskip("trl")
data, out = tmp_path / "data", tmp_path / "out"
write_sessions(data, 40)
scores = iter([1, 3, 0, 2, 5, 4, 4]) # passage 2 (heldout agrandi) : base 2, actuel 5, SFT 4 et GRPO 4 -> rien promu
monkeypatch.setattr(JOB, "pass_at_1", lambda *a, **k: {"passed": next(scores), "n": 9, "rate": 0, "ci95": [0, 1]})
args = ["--local-data", str(data), "--local-out", str(out), "--base-model", tiny_qwen, "--cycles", "1", *FAST]
assert JOB.main(args) == 0
write_sessions(data, 60, start=300)
assert JOB.main(args) == 0
st = json.loads((out / "state.json").read_text())
assert st["best"]["passed"] == 5 and st["best"]["source"] == "actuel"
assert [h["promoted"] for h in st["history"]] == [True, False, False, False]
# ---------------------------------------------------------------- mémoire à long terme
from mouh.learning.memory import TaskMemory, items_from_records # noqa: E402
def mem_items():
base = [("Écrire une fonction qui inverse une chaîne de caractères", "def f(s):\n return s[::-1]\n"),
("Compter les voyelles dans une chaîne", "def f(s):\n return sum(c in 'aeiou' for c in s)\n"),
("Calculer la somme des éléments pairs d'une liste", "def f(l):\n return sum(x for x in l if x % 2 == 0)\n")]
return [{"task_hash": f"h{i}", "instruction": t, "files": {"solution.py": c}} for i, (t, c) in enumerate(base)]
def test_memory_finds_similar_but_never_the_task_itself_or_near_duplicates():
m = TaskMemory(mem_items())
hits = m.search("Compter le nombre de voyelles d'un texte", k=1)
assert hits and hits[0]["task_hash"] == "h1"
assert all(h["task_hash"] != "h1" for h in m.search("Compter les voyelles dans une chaîne", k=3, exclude_hash="h1"))
assert all(h["task_hash"] != "h0" for h in m.search("Écrire une fonction qui inverse une chaîne de caractères", k=3))
jm = JOB.TaskMemory(mem_items())
for q in ("voyelles texte", "somme pairs liste", "inverse chaîne"):
assert [h["task_hash"] for h in jm.search(q, 2)] == [h["task_hash"] for h in m.search(q, 2)]
def test_memory_only_uses_certified_train_records():
recs = [session(i) for i in range(30)] + [session(99, certified=False)]
items = items_from_records(recs)
assert items and all(r["split"] == "train" for r in recs if r["task_hash"] in {i["task_hash"] for i in items})
assert session(99)["task_hash"] not in {i["task_hash"] for i in items}
def test_prompt_with_examples_identical_in_job():
ex = mem_items()[:2]
assert JOB.chat_messages("t", TESTS, examples=ex) == P.chat_messages("t", TESTS, examples=ex)
assert "Exemples validés similaires" in P.user_prompt("t", TESTS, examples=ex)
assert P.user_prompt("t", TESTS) == P.user_prompt("t", TESTS, examples=[])
# ---------------------------------------------------------------- curriculum + vrais échecs
def test_curriculum_prefers_informative_tasks():
import random as R
tasks = [{"task_hash": f"t{i}"} for i in range(300)]
stats = {f"t{i}": ([4, 4] if i < 100 else [0, 10] if i < 200 else [2, 4]) for i in range(300)}
picked = JOB.curriculum_select(tasks, stats, 60, R.Random(0))
zone = sum(1 for t in picked if int(t["task_hash"][1:]) >= 200)
assert len(picked) == 60 and zone >= 45 # surtout des tâches réussies « parfois »
def test_reward_logs_outcomes_and_real_failures():
log_ = JOB.RolloutLog()
r = JOB.make_pytest_reward(workers=2, log_to=log_)
tests = json.dumps({"test_solution.py": "from solution import f\n\ndef test_f():\n assert f() == 3\n"})
good = render_files({"solution.py": "def f():\n return 3\n"})
bad = render_files({"solution.py": "def f():\n return 4\n"})
assert r(completions=[good, bad], tests=[tests] * 2, task_hash=["h", "h"], instruction=["énoncé", "énoncé"]) == [1.0, 0.1]
assert log_.outcomes["h"] == [1, 2]
(f,) = log_.failures
assert "assert 4 == 3" in f["pytest_output"] and f["files"]["solution.py"].strip().endswith("return 4")
def test_decontamination_drops_benchmark_lookalikes():
bench = [{"instruction": "Complète : def has_close_elements(numbers, threshold): Check if in given list of numbers, "
"are any two numbers closer to each other than given threshold."}]
s1 = session(1)
s2 = {**session(2), "instruction": "Check if in given list of numbers, are any two numbers closer to each other "
"than given threshold. Return bool."}
kept, removed = JOB.decontaminate([s1, s2], bench)
assert removed == 1 and kept == [s1]
def humaneval_rows():
return [{"task_id": f"HE/{i}", "entry_point": f"plus{i}",
"prompt": f"def plus{i}(x):\n \"\"\"Ajoute {i}.\"\"\"\n",
"test": f"def check(candidate):\n assert candidate(1) == {1 + i}\n"} for i in range(3)]
def test_humaneval_conversion_runs_real_checks(tmp_path):
p = tmp_path / "he.jsonl"
p.write_text("\n".join(json.dumps(r) for r in humaneval_rows()))
t = JOB.load_humaneval(str(p), None)[1]
assert JOB.run_tests({"solution.py": "def plus1(x):\n return x + 1\n"}, t["tests"])
assert not JOB.run_tests({"solution.py": "def plus1(x):\n return x\n"}, t["tests"])
def test_loop_with_benchmark_memory_failures_and_repairs(tiny_qwen, tmp_path, monkeypatch):
pytest.importorskip("trl")
data, out = tmp_path / "data", tmp_path / "out"
write_sessions(data, 60)
he = tmp_path / "he.jsonl"
he.write_text("\n".join(json.dumps(r) for r in humaneval_rows()))
# le modèle minuscule ne réussit rien : on simule des échecs réels pour vérifier le circuit complet
real = JOB.make_pytest_reward
def fake_reward(workers=8, timeout_s=20.0, log_to=None):
def rw(prompts=None, completions=None, tests=None, task_hash=None, instruction=None, **kw):
for th, ins, t in zip(task_hash, instruction, tests):
if ins:
log_to.add(th, False, {"task_hash": th, "instruction": ins, "tests": json.loads(t),
"files": {"solution.py": "def f():\n return -1\n"},
"pytest_output": "E assert -1 == 1"})
return [0.0] * len(completions)
return rw
monkeypatch.setattr(JOB, "make_pytest_reward", fake_reward)
args = ["--local-data", str(data), "--local-out", str(out), "--base-model", tiny_qwen, "--cycles", "1", *FAST]
args[args.index("--benchmark") + 1] = "1"
args[args.index("--memory-ab") + 1] = "1"
args += ["--local-benchmark", str(he)]
assert JOB.main(args) == 0
st = json.loads((out / "state.json").read_text())
m = st["measures"]
assert m["humaneval"]["n"] == 3 and m["humaneval_base"]["n"] == 3 and m["memory"]["with"]["n"] == len(st["heldout"])
assert st["history"][1]["train"]["curriculum"]["failures"] > 0 and st["task_stats"]
assert list((out / "failures").glob("*.jsonl"))
assert JOB.main(args) == 0 # 2e passage : les échecs réels deviennent des prompts de réparation GRPO
st2 = json.loads((out / "state.json").read_text())
assert st2["history"][-1]["train"]["repairs"] > 0
assert json.loads((out / "progress.json").read_text())["measures"]["humaneval"]["n"] == 3
monkeypatch.setattr(JOB, "make_pytest_reward", real)
# ---------------------------------------------------------------- modèle maître
def test_sft_order_puts_the_teachers_very_hard_tasks_first_and_keeps_the_old_order_without_levels():
import hashlib
def rec(i, generator, level):
attempt = {"attempt": 1, "origin": "dataset", "files": {"solution.py": "x"}, "score": 1.0,
"certified": True, "diagnostics": []}
if level:
attempt["level"] = level
return {"schema": "mouh-session/1", "certified": True, "split": "train", "task_hash": f"h{i}",
"instruction": f"tâche {i}", "generator": generator,
"tests": {"test_solution.py": "from solution import f\n\n\ndef test_a():\n assert f(1) == 1\n"},
"final_files": {"solution.py": f"def f(x):\n return {i}\n"}, "attempts": [attempt]}
recs = [rec(i, "ingest:kodcode", None) for i in range(30)] + [
rec(100, "teacher:m", "extrême"), rec(101, "teacher:m", "difficile"), rec(102, "teacher:m", "facile")]
train, _, stats = JOB.build_examples(recs)
order = JOB.sft_order(train, 0)
tiers = [train[i]["tier"] for i in order]
assert tiers[0] == 0 and tiers[1] == 1 and tiers.count(0) == 1 and stats["top_teacher"] == 1
assert [train[i]["tier"] for i in order[2:]] == [2] * (len(train) - 2)
# sans niveaux, l'ordre est exactement celui d'avant v3.11
plain = [{k: v for k, v in e.items() if k != "tier"} for e in train[:30]]
old = sorted(range(len(plain)), key=lambda i: hashlib.sha256(
f"0:{JOB._sha(plain[i]['target'])}:{plain[i]['kind']}:{i}".encode()).hexdigest())
assert JOB.sft_order(plain, 0) == old
# ---------------------------------------------------------------- interrupteurs (retour arrière)
def test_phase_error_is_reported_and_loop_continues(tiny_qwen, tmp_path, monkeypatch):
pytest.importorskip("trl")
data, out = tmp_path / "data", tmp_path / "out"
write_sessions(data, 60)
def boom(*a, **k):
raise ValueError("bf16 non pris en charge")
monkeypatch.setattr(JOB, "run_sft", boom)
args = ["--local-data", str(data), "--local-out", str(out), "--base-model", tiny_qwen, "--cycles", "1", *FAST]
assert JOB.main(args) == 0
st = json.loads((out / "state.json").read_text())
assert st["last_error"]["phase"] == "sft" and "bf16" in st["last_error"]["error"]
assert [h["phase"] for h in st["history"]] == ["grpo"] # GRPO a quand même tourné
monkeypatch.setattr(JOB, "run_grpo", boom)
assert JOB.main(args) == 0 # 2 échecs d'affilée (SFT puis GRPO) : on continue
assert JOB.main(args) == 1 # 3e échec d'affilée : arrêt plutôt que de payer le GPU pour rien
assert json.loads((out / "progress.json").read_text())["phase"] == "erreur"
def test_precision_never_requests_bf16_on_t4(monkeypatch):
import torch
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(torch.cuda, "is_bf16_supported", lambda *a, **k: True) # ce que PyTorch répond sur T4
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda *a: (7, 5))
assert JOB._precision() == {"fp16": True, "bf16": False}
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda *a: (8, 9))
assert JOB._precision() == {"bf16": True, "fp16": False}
def test_gpu_compat_disables_16_to_32_gemm_on_t4(monkeypatch):
import torch
from trl.kernels import chunked_logprob
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda *a: (7, 5))
monkeypatch.setattr(chunked_logprob, "_MM_OUT_DTYPE", True)
JOB._gpu_compat()
assert chunked_logprob._MM_OUT_DTYPE is False
acc = torch.zeros(2, 2)
chunked_logprob._addmm_fp32(acc, torch.ones(2, 3, dtype=torch.float16), torch.ones(3, 2, dtype=torch.float16))
assert torch.equal(acc, torch.full((2, 2), 3.0)) # chemin de repli fp32
# ---------------------------------------------------------------- v3.5
def test_no_signal_stopper_stops_after_patience():
import types
info = {}
cb = JOB._no_signal_stopper(0.9, 3, 2, info)
state, control = types.SimpleNamespace(global_step=0), types.SimpleNamespace(should_training_stop=False)
for step, z in enumerate([1.0, 0.5, 1.0, 1.0, 0.95], 1):
state.global_step = step
cb.on_log(None, state, control, logs={"frac_reward_zero_std": z})
assert control.should_training_stop and info["early_stop"]["step"] == 5
def test_curriculum_prefers_hard_tasks_and_drops_solved_ones():
import random as R
tasks = [{"task_hash": f"t{i}", "level": "hard" if i < 50 else "easy"} for i in range(100)]
picked = JOB.curriculum_select(tasks, {}, 30, R.Random(1))
assert sum(t["level"] == "hard" for t in picked) >= 24
solved = {f"t{i}": [2, 2] for i in range(50)} # les difficiles déjà réussies 2 fois sur 2
picked = JOB.curriculum_select(tasks, solved, 30, R.Random(1))
assert sum(t["level"] == "hard" for t in picked) <= 12
def test_grpo_stops_early_when_every_attempt_has_the_same_score(tiny_qwen, tmp_path, monkeypatch):
pytest.importorskip("trl")
data, out = tmp_path / "data", tmp_path / "out"
write_sessions(data, 60)
def always_one(workers=8, timeout_s=20.0, log_to=None):
return lambda completions=None, **kw: [1.0] * len(completions)
monkeypatch.setattr(JOB, "make_pytest_reward", always_one)
args = ["--local-data", str(data), "--local-out", str(out), "--base-model", tiny_qwen, "--cycles", "1", *FAST,
"--grpo-stop-patience", "2", "--grpo-stop-min-steps", "2"]
args[args.index("--grpo-steps") + 1] = "8"
assert JOB.main(args) == 0
grpo = json.loads((out / "state.json").read_text())["history"][-1]["train"]
assert grpo["steps"] < 8 and grpo["early_stop"]["step"] == grpo["steps"]
# ---------------------------------------------------------------- v3.6 : snapshot partagé data/ + failures/
def shared_snapshot(tmp_path):
root = tmp_path / "snapshot"
(root / "data").mkdir(parents=True)
(root / "failures").mkdir()
(root / "state").mkdir()
(root / "data" / "ingest-x.jsonl").write_text("\n".join(json.dumps(session(i)) for i in range(5)) + "\n")
fail = {"task_hash": "h", "instruction": "i", "tests": TESTS, "files": {"solution.py": "x=1\n"},
"pytest_output": "E assert"}
(root / "failures" / "r-c1.jsonl").write_text(json.dumps(fail) + "\n" + json.dumps({"incomplet": 1}) + "\n")
(root / "state" / "teacher.json").write_text("{}")
return root
def test_loaders_read_only_their_own_folder_of_a_shared_snapshot(tmp_path, monkeypatch):
import huggingface_hub
root = shared_snapshot(tmp_path)
monkeypatch.setattr(huggingface_hub, "snapshot_download", lambda *a, **k: str(root))
class ListApi:
def __init__(self, token=None):
pass
def list_repo_files(self, repo, repo_type=None):
return [str(f.relative_to(root)) for f in root.rglob("*") if f.is_file()]
monkeypatch.setattr(huggingface_hub, "HfApi", ListApi)
monkeypatch.setattr(huggingface_hub, "hf_hub_download", lambda repo, name, **k: str(root / name))
sessions = JOB.load_sessions(None, "me/ds", "tok")
assert len(sessions) == 5 and all(r["schema"] == "mouh-session/1" for r in sessions)
store = types_store = type("S", (), {"local": None})()
fails = JOB.load_failures(store, "me/ds", "tok", 100)
assert len(fails) == 1 and fails[0]["files"] # ni les sessions de data/, ni l'enregistrement incomplet
# ---------------------------------------------------------------- v3.7
def test_select_data_files_keeps_recent_ingest_slices_only():
names = [f"data/ingest-opencodeinstruct-{i * 4000:09d}.jsonl" for i in range(50)] + \
[f"data/ingest-kodcode-{i * 2000:09d}.jsonl" for i in range(3)] + \
["data/teacher-20261104T090000Z.jsonl", "data/sessions-abc.jsonl"]
chosen = JOB.select_data_files(names, 30)
oci = [n for n in chosen if "opencodeinstruct" in n]
assert len(oci) == 30 and oci[0].endswith(f"{20 * 4000:09d}.jsonl") # les 30 plus récentes
assert sum("kodcode" in n for n in chosen) == 3
assert "data/teacher-20261104T090000Z.jsonl" in chosen and "data/sessions-abc.jsonl" in chosen
assert len(JOB.select_data_files(names, 0)) == len(names)
def test_frozen_heldout_survives_when_its_files_are_no_longer_loaded(tiny_qwen, tmp_path, monkeypatch):
pytest.importorskip("trl")
data, out = tmp_path / "data", tmp_path / "out"
data.mkdir()
def slice_(start, n, off):
(data / f"ingest-opencodeinstruct-{off:09d}.jsonl").write_text(
"\n".join(json.dumps(session(i)) for i in range(start, start + n)) + "\n")
slice_(0, 60, 0)
scores = iter([5, 6, 6, 7, 7]) # base 5 ; puis seulement des candidats : jamais de re-mesure de la base
monkeypatch.setattr(JOB, "pass_at_1", lambda *a, **k: {"passed": next(scores), "n": 6, "rate": 0, "ci95": [0, 1]})
args = ["--local-data", str(data), "--local-out", str(out), "--base-model", tiny_qwen, "--cycles", "1", *FAST,
"--max-ingest-files", "1", "--max-heldout", "3"]
assert JOB.main(args) == 0
st1 = json.loads((out / "state.json").read_text())
slice_(500, 60, 4000) # nouvelle tranche : avec --max-ingest-files 1, la première n'est plus chargée
assert JOB.main(args) == 0
st2 = json.loads((out / "state.json").read_text())
assert st2["heldout"] == st1["heldout"] and st2["heldout_sig"] == st1["heldout_sig"]
assert set(st2["heldout_tasks"]) == set(st1["heldout"]) and st2["base"]["passed"] == 5
def test_promotion_needs_the_margin(tiny_qwen, tmp_path, monkeypatch):
pytest.importorskip("trl")
data, out = tmp_path / "data", tmp_path / "out"
write_sessions(data, 60)
scores = iter([60, 62, 63]) # base 60 ; SFT 62 (+2) écarté ; GRPO 63 (+3) mis en service
monkeypatch.setattr(JOB, "pass_at_1", lambda *a, **k: {"passed": next(scores), "n": 100, "rate": 0, "ci95": [0, 1]})
args = ["--local-data", str(data), "--local-out", str(out), "--base-model", tiny_qwen, "--cycles", "1", *FAST]
args[args.index("--promotion-margin") + 1] = "3"
assert JOB.main(args) == 0
assert [h["promoted"] for h in json.loads((out / "state.json").read_text())["history"]] == [False, True]
# ---------------------------------------------------------------- v3.8
def test_shrink_batches_keeps_effective_batch():
import types
a = types.SimpleNamespace(batch_size=2, grad_accum=8, grpo_batch=8, grpo_generations=4, grpo_max_completion=384,
eval_batch=16)
t = JOB.shrink_batches(a, "sft")
assert (t["batch_size"], t["grad_accum"]) == (1, 16)
t = JOB.shrink_batches(a, "grpo")
assert t["grpo_batch"] == 4 and t["eval_batch"] == 4
t = JOB.shrink_batches(a, "grpo")
assert t["grpo_batch"] == 4 and t["grpo_max_completion"] == 192
def test_out_of_memory_shrinks_batches_for_next_runs(tiny_qwen, tmp_path, monkeypatch):
pytest.importorskip("trl")
import torch
data, out = tmp_path / "data", tmp_path / "out"
write_sessions(data, 60)
real = JOB.run_sft
def oom(*a, **k):
raise torch.cuda.OutOfMemoryError("CUDA out of memory. Tried to allocate 2.00 GiB")
monkeypatch.setattr(JOB, "run_sft", oom)
args = ["--local-data", str(data), "--local-out", str(out), "--base-model", tiny_qwen, "--cycles", "1", *FAST]
assert JOB.main(args) == 0
st = json.loads((out / "state.json").read_text())
assert st["tuning"]["batch_size"] == 1 and st["tuning"]["grad_accum"] == 2
seen = {}
monkeypatch.setattr(JOB, "run_sft", lambda a, *r, **k: seen.update(bs=a.batch_size) or real(a, *r, **k))
assert JOB.main(args) == 0 and seen["bs"] == 1 # la réduction est reprise au passage suivant
def test_phase_that_cannot_fit_is_left_for_next_run(tiny_qwen, tmp_path):
data, out = tmp_path / "data", tmp_path / "out"
write_sessions(data, 40)
out.mkdir()
(out / "state.json").write_text(json.dumps({"phase_seconds": {"sft": 10_000_000}}))
assert JOB.main(["--local-data", str(data), "--local-out", str(out), "--base-model", tiny_qwen, *FAST]) == 0
assert json.loads((out / "state.json").read_text()).get("history", []) == []
def test_progress_history_is_per_model(tmp_path):
store = JOB.Store(local_out=str(tmp_path), adapter_repo="me/a", bucket="me/b", token=None)
store.write_progress({"adapter": "me/ancien", "day": JOB._now().strftime("%Y-%m-%d"), "gpu_hours_today": 3.0,
"history": [{"x": 1}], "best": {"passed": 9}}, True)
p = JOB.Progress(store, run_id="r", max_hours=1, daily_budget_h=24, min_interval_s=0, adapter="me/nouveau")
assert p.state["history"] == [] and p.state["best"] is None and p.state["gpu_hours_before_run"] == 3.0
def test_best_of_measure_runs_and_is_reported(tiny_qwen, tmp_path):
pytest.importorskip("trl")
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", "1", *FAST,
"--measure-best-of", "2"]
assert JOB.main(args) == 0
m = json.loads((out / "state.json").read_text())["measures"]
assert m["best_of"]["k"] == 2 and m["best_of"]["n"] > 0
def test_prompt_notes_identical_in_job():
notes = ["doc : utiliser heapq", "autre"]
assert JOB.chat_messages("t", TESTS, notes=notes) == P.chat_messages("t", TESTS, notes=notes)
assert "Notes de recherche web" in P.user_prompt("t", TESTS, notes=notes)
def test_complexity_estimate_orders_tasks():
from mouh.learning.unique_generator import estimate_complexity
easy = estimate_complexity("Écrire add(a, b).", TESTS)
hard_tests = {"test_solution.py": "from solution import shortest, Graph\n" +
"".join(f"def test_{i}():\n assert shortest(Graph(), {i}) == {i}\n" for i in range(14))}
hard = estimate_complexity("Implémenter Dijkstra sur un graphe orienté pondéré, complexité O(n log n), "
"avec un tas (heap) et une classe Graph. " * 6, hard_tests)
assert easy["level"] == "simple" and hard["level"] == "complexe" and hard["score"] > easy["score"]
def test_adaptive_session_picks_the_candidate_that_passes(monkeypatch):
from mouh.learning import unique_generator as U
from mouh.verification.generators import GenerationRequest
from mouh.jev.types import JevMessageType as MT
class Gen:
def build_messages(self, r):
return [{"role": "user", "content": "x"}]
def candidates(self, messages, n):
Gen.n = n
return [{"solution.py": "def add(a, b):\n return a - b\n"},
{"solution.py": "def add(a, b):\n return a + b\n"}][:max(n, 1)]
monkeypatch.setattr(U, "estimate_complexity", lambda i, t: {"level": "complexe", "score": 0.9})
task = TaskPayload(instruction="add", test_files=TESTS)
sess = U.UniqueSession(Gen(), adaptive=True)
out = sess.generate(GenerationRequest(task=task, attempt=1, message_type=MT.TASK_SUBMISSION, experts=(1,)))
assert out["solution.py"].endswith("a + b\n") and Gen.n == 6
assert sess.info["essais"][0]["retenue"] == 2 and "recherche_web" not in sess.info # v4.1 : aucune recherche web
assert not hasattr(U, "web_notes")
calm = U.UniqueSession(Gen(), adaptive=False)
calm.generate(GenerationRequest(task=task, attempt=1, message_type=MT.TASK_SUBMISSION, experts=(1,)))
assert Gen.n == 1
def test_reflective_generator_end_to_end(tiny_qwen, monkeypatch):
import mouh.service as S
monkeypatch.setenv("UNIQUE_BASE_MODEL", tiny_qwen)
monkeypatch.setenv("UNIQUE_MAX_NEW_TOKENS", "8")
monkeypatch.setenv("UNIQUE_MAX_SAMPLES", "2")
monkeypatch.delenv("UNIQUE_ADAPTER_REPO", raising=False)
S._MODEL_CACHE.pop("unique", None)
snaps = list(S.stream_session("add", "def add(a, b):\n return a - b\n", TESTS["test_solution.py"], max_attempts=2,
generator_choice=S.GENERATOR_CHOICES[3], record=False))
rep = json.loads(snaps[-1]["report"])
assert snaps[-1]["status"].startswith("AUCUNE SORTIE") and rep["reflexion"]["complexite"]["level"] == "simple"
S._MODEL_CACHE.pop("unique", None)
def test_v313_sft_extras_are_off_by_default_and_set_by_env(monkeypatch):
monkeypatch.delenv("NEFTUNE_ALPHA", raising=False)
monkeypatch.delenv("GRADIENT_CHECKPOINTING", raising=False)
assert JOB._sft_extras() == {} and JOB._gradient_checkpointing() is True
monkeypatch.setenv("NEFTUNE_ALPHA", "5")
assert JOB._sft_extras() == {"neftune_noise_alpha": 5.0}
monkeypatch.setenv("NEFTUNE_ALPHA", "abc")
assert JOB._sft_extras() == {}
monkeypatch.setenv("GRADIENT_CHECKPOINTING", "0")
assert JOB._gradient_checkpointing() is False
|