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