Spaces:
Running
Running
Download tests/test_learning.py from RDS777/TARGET_SPACE: direct link, hf CLI and curl.
- Browser
- Download file 38.6 kB
-
https://huggingface.co/spaces/RDS777/TARGET_SPACE/resolve/main/tests/test_learning.py
- Command line
-
hf download hf://spaces/RDS777/TARGET_SPACE/tests/test_learning.py
-
curl -L -o test_learning.py https://huggingface.co/spaces/RDS777/TARGET_SPACE/resolve/main/tests/test_learning.py
38.6 kB
| """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 | |
| 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 | |
| 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 | |