File size: 14,321 Bytes
8efb1a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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_job_full_cycle_offline(tiny_qwen, tmp_path):
    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, "--min-new-examples", "10",
            "--min-heldout", "1", "--epochs", "1", "--max-new-tokens", "8", "--grad-accum", "4", "--max-len", "1024"]
    assert JOB.main(args) == 0
    state = json.loads((out / "state.json").read_text())
    run = json.loads(next((out / "runs").glob("*.json")).read_text())
    assert state["trained_on"] == run["data"]["train_examples"] > 0
    assert run["train"]["optimizer_steps"] > 0 and "base" in run and "candidate" in run
    assert run["promoted"] is False  # un modèle aléatoire ne fait passer aucun test : rien n'est promu
    assert (out / "adapter_candidate" / "adapter_config.json").exists() and not (out / "adapter").exists()
    # second passage sans nouvelles données : le job s'arrête avant de charger quoi que ce soit
    assert JOB.main(args) == 0
    assert len(list((out / "runs").glob("*.json"))) == 1


def test_job_promotes_only_when_strictly_better(tiny_qwen, tmp_path, monkeypatch):
    data, out = tmp_path / "data", tmp_path / "out"
    write_sessions(data, 40)
    scores = iter([1, 3, 3, 3, 2])  # cycle 1 : base 1, candidat 3 -> promu ; cycle 2 : base 3, actuel 3, candidat 2 -> non
    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, "--min-new-examples", "1",
            "--min-heldout", "1", "--epochs", "1", "--max-len", "1024"]
    assert JOB.main(args) == 0
    assert (out / "adapter" / "adapter_config.json").exists()
    assert json.loads((out / "state.json").read_text())["promoted"]["candidate"]["passed"] == 3
    write_sessions(data, 10, start=100)
    assert JOB.main(args) == 0
    hist = json.loads((out / "state.json").read_text())["history"]
    assert [h["promoted"] for h in hist] == [True, False]


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)
class FakeJobsApi:
    def __init__(self):
        import types

        self.jobs, self.created, self.triggered = [], [], []
        self._ns = types.SimpleNamespace

    def list_scheduled_jobs(self, labels=None):
        return [j for j in self.jobs if all(j.labels.get(k) == v for k, v in (labels or {}).items())]

    def create_scheduled_uv_job(self, script, **kw):
        assert Path(script).exists() and kw["secrets"]["HF_TOKEN"] == "tok"
        job = self._ns(id=f"j{len(self.jobs)}", schedule=kw["schedule"], labels=kw["labels"],
                       status=self._ns(next_job_run_at="lundi", last_job=None))
        self.jobs.append(job)
        self.created.append(kw)
        return job

    def trigger_scheduled_job(self, scheduled_job_id):
        self.triggered.append(scheduled_job_id)
        return self._ns(url=f"https://hf.co/jobs/{scheduled_job_id}")

    def hf_hub_download(self, repo, name):
        raise FileNotFoundError


def test_autopilot_is_opt_in_and_idempotent(monkeypatch):
    from mouh.learning import autopilot as A

    api = FakeJobsApi()
    monkeypatch.setenv("HF_TOKEN", "tok")
    monkeypatch.delenv("UNIQUE_AUTOTRAIN", raising=False)
    msg, job = A.ensure_schedule(api, A.settings())
    assert job is None and "désactivé" in msg and not api.created  # aucune dépense sans accord explicite
    monkeypatch.setenv("UNIQUE_AUTOTRAIN", "1")
    _, j1 = A.ensure_schedule(api, A.settings())
    _, j2 = A.ensure_schedule(api, A.settings())
    assert j1.id == j2.id and len(api.created) == 1
    assert api.created[0]["flavor"] == "t4-small" and api.created[0]["env"]["MOUH_DATASET_REPO"]
    assert "j0" in A.status_markdown(api) and "Aucun cycle" in A.status_markdown(api)


def test_trigger_requires_admin_key(monkeypatch):
    from mouh.learning import autopilot as A

    api = FakeJobsApi()
    monkeypatch.setenv("HF_TOKEN", "tok")
    monkeypatch.setenv("UNIQUE_AUTOTRAIN", "1")
    monkeypatch.delenv("UNIQUE_ADMIN_KEY", raising=False)
    assert "désactivé" in A.trigger_now("x", api) and not api.triggered
    monkeypatch.setenv("UNIQUE_ADMIN_KEY", "secret")
    assert "incorrecte" in A.trigger_now("mauvaise", api) and not api.triggered
    assert "Cycle lancé" in A.trigger_now("secret", api) and api.triggered == ["j0"]


def test_app_builds():
    import importlib

    import app  # noqa: F401 — l'interface se construit sans réseau ni jeton

    importlib.reload(app)