From 71d0eeb00dda118b9a6af66fdb0574a9bc44df26 Mon Sep 17 00:00:00 2001 From: michael <78113554+chowhsu@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:24:16 +0000 Subject: [PATCH 01/10] rlf-codex-rl: 5.8 gate runner + evidence chain (rlf_rl_gate), docs --- docs/RLF_CODEX_RUN.md | 65 +++++++ tests/test_rlf_rl_gate.py | 214 +++++++++++++++++++++ yeto/rl/eval/rlf_rl_gate.py | 357 ++++++++++++++++++++++++++++++++++++ 3 files changed, 636 insertions(+) create mode 100644 tests/test_rlf_rl_gate.py create mode 100644 yeto/rl/eval/rlf_rl_gate.py diff --git a/docs/RLF_CODEX_RUN.md b/docs/RLF_CODEX_RUN.md index a3c20b45..1c82a2d5 100644 --- a/docs/RLF_CODEX_RUN.md +++ b/docs/RLF_CODEX_RUN.md @@ -110,3 +110,68 @@ Known unverified (first GPU run must confirm): Miles `resolve_serving` coverage of the `qwen3` tito family, LoRA-free `SessionServerConfig` (`lora_rank=None`), and A100-80GB scheduling latency on Modal. + +## Real-GPU RL gate (5.8): Miles GRPO on `qwen35_08b` + +The gate proves the RLF -> Miles GRPO -> GAE -> policy-update chain on real +GPU with the small `qwen35_08b` profile (0.8B, the S17-verified harness +stack; LoRA, 1xGPU). The 30B capability surface is covered separately by the +5.6 baseline; 30B RL training is a follow-up item (design decision +2026-10-11). Tasks: `fixture-ctf-ezshell` (solvable in ~2 turns, provides +success) and `fixture-ctf-ret2win` (provides reward variance). Codex-only; +AgentFlow is offline-only and refused at admission. + +### Runner and commands + +The gate runner is `yeto.rl.eval.rlf_rl_gate`. Island side, in order: + + # 1. admit the packages, write Miles --prompt-data (flags never logged) + python -m yeto.rl.eval.rlf_rl_gate prompt-data \ + --tasks-dir "$YETO_RLF_CTF_TASKS_DIR" --flags-json /trusted/flags.json \ + --out /run/rlf_gate_prompt.jsonl --repeat 2 + + # 2. launch the Miles GRPO island (qwen35_08b, LoRA, 1xGPU): + # --data /run/rlf_gate_prompt.jsonl --codex-backend-profile qwen35_08b + # --reward-function rlf_success (-> --custom-rm-path yeto.rl.adapters.miles.rewards.rlf_success) + # --advantage-estimator grpo (fixed by island_entry) + # env: YETO_HARNESS_ENVIRONMENT_PROVIDER=yeto.cloud.modal_reward_env:rlf_modal_provider + # YETO_RLF_CTF_TASKS_DIR / YETO_RLF_CTF_FLAGS_JSON (Modal Secret, never in logs) + + # 3. after training, collect real artifacts, judge, write evidence + python -m yeto.rl.eval.rlf_rl_gate gate \ + --completed-groups /run/completed_groups.pt \ + --adapter-before /run/adapter_init --adapter-after /run/adapter_final \ + --step 2 --flags-json /trusted/flags.json --out /run/rlf_gate_evidence.json + +### Hard criteria (`yeto.rl.rlf.contracts.rl_gate`) + +Non-empty reward, `reward_std > 0`, `success_count > 0`, +`advantage_abs_sum > 0`, `policy_update == true`. All-zero or constant +reward MUST fail. The verdict is announced only for evidence collected +from the real island artifacts (`collector="miles_island"`); CPU/injected +collections exercise the orchestration but can never announce a pass. + +### Evidence chain (`yeto.rlf.rl_gate.evidence/v1`) + +- reward/success: the island completed-groups checkpoint + (`--completed-groups-path`) — the samples the learner actually trained + on, each with the `rlf_success` reward and `metadata.success` (W4 grade); +- advantage: recomputed per GRPO group with `contracts.group_advantages` + (the designated computation of record; never hand-filled); +- policy update: `contracts.policy_update_evidence` over a per-tensor + summary of the initial vs final LoRA adapter (代拍板: the Miles fork + exports no per-step snapshot hook; this proves parameters changed without + a fork patch, at the cost of not attributing the change to a specific + optimizer step, which the gate does not require); +- the JSON carries reward detail, success, `advantage_abs_sum`, and the + before/after policy digests; trusted flags are scrubbed + (`rlf.modal.scrub`) and only their sha256 appears in `config_digest`. + +### Stop conditions + +Stop and treat as infrastructure (never scored): serving not healthy within +15 min, or per-episode HARD deadline (30 min) exceeded repeatedly. Stop +with gate failure (the chain is broken, investigate before re-running): +`rl_gate` raising on any criterion, a sample missing `metadata.success`, +or unchanged adapter digests after training. A passing run archives the +evidence JSON next to the island artifacts. diff --git a/tests/test_rlf_rl_gate.py b/tests/test_rlf_rl_gate.py new file mode 100644 index 00000000..38f0de38 --- /dev/null +++ b/tests/test_rlf_rl_gate.py @@ -0,0 +1,214 @@ +"""RLF real-GPU RL gate runner orchestration (task 5.8): fake learner / fake +provider only — no GPU, no Miles, no torch, no Modal. + +These tests cover the orchestration (collection -> gate rows -> rl_gate -> +evidence). The injected collection can never announce a passing verdict; +only the real ``miles_island`` collector on the GPU run may do that. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from yeto.rl.eval.rlf_rl_gate import ( + EVIDENCE_SCHEMA, + GateConfig, + GateEvidenceError, + build_gate_rows, + collect_policy_update, + collect_rollout_samples, + prepare_prompt_data, + run_gate, + write_evidence, +) + +FLAGS = {"fixture-ctf-ezshell": "flag{ezshell-secret}", "fixture-ctf-ret2win": "flag{ret2win-secret}"} + + +def _sample(group, index, reward, success, task_id="fixture-ctf-ezshell"): + return {"reward": reward, + "metadata": {"success": success, "rlf_task_id": task_id, + "rlf_episode_id": f"ep-{group}-{index}", + "rlf_trajectory_id": f"traj-{group}-{index}", + "grader_evidence": {"trusted": True}}} + + +def _payload(groups): + return {"schema_version": 1, "completed_groups": groups} + + +def _passing_groups(): + # ezshell group: one success (variance + success_count); ret2win group: all fail. + return [[_sample(0, 0, 1.0, True), _sample(0, 1, 0.0, False), + _sample(0, 2, 0.0, False), _sample(0, 3, 0.0, False)], + [_sample(1, 0, 0.0, False, "fixture-ctf-ret2win"), + _sample(1, 1, 0.0, False, "fixture-ctf-ret2win")]] + + +def _update_loader(before, after): + tables = {"before": before, "after": after} + return lambda path: tables[str(path)] + + +def _update(updated=True): + before = {"lora_a": 0.5, "lora_b": -0.25} + after = {"lora_a": 0.5 + (0.01 if updated else 0.0), "lora_b": -0.25} + return collect_policy_update("before", "after", step=2, + loader=_update_loader(before, after)) + + +def _config(tmp_path): + return GateConfig(tasks_dir=str(tmp_path), flags=FLAGS) + + +# -- collection from the completed-groups checkpoint ------------------------- + +def test_collect_rollout_samples_reads_real_checkpoint_shape(): + samples = collect_rollout_samples("ckpt", loader=lambda _p: _payload(_passing_groups())) + assert len(samples) == 6 + assert samples[0] == {"group_index": 0, "index": 0, "task_id": "fixture-ctf-ezshell", + "reward": 1.0, "success": True} + assert sum(s["success"] for s in samples) == 1 + + +def test_collect_flattens_compaction_segments_sharing_one_outcome(): + group = [[_sample(0, 0, 1.0, True), _sample(0, 0, 1.0, True)], _sample(0, 1, 0.0, False)] + samples = collect_rollout_samples("ckpt", loader=lambda _p: _payload([group])) + assert [s["reward"] for s in samples] == [1.0, 0.0] + + +def test_collect_fails_closed_on_missing_success_or_reward(): + bad = _sample(0, 0, 1.0, True) + del bad["metadata"]["success"] + with pytest.raises(GateEvidenceError, match="metadata.success"): + collect_rollout_samples("ckpt", loader=lambda _p: _payload([[bad]])) + bad = _sample(0, 0, 1.0, True) + bad["reward"] = None + with pytest.raises(GateEvidenceError, match="numeric reward"): + collect_rollout_samples("ckpt", loader=lambda _p: _payload([[bad]])) + with pytest.raises(GateEvidenceError, match="no completed groups"): + collect_rollout_samples("ckpt", loader=lambda _p: _payload([])) + + +# -- advantages and policy update are computed, never hand-filled ------------ + +def test_gate_rows_compute_grpo_group_advantages(): + samples = collect_rollout_samples("ckpt", loader=lambda _p: _payload(_passing_groups())) + rows = build_gate_rows(samples, _update()) + first = [r["advantage"] for r in rows if r["metadata"]["group_index"] == 0] + assert first == [0.75, -0.25, -0.25, -0.25] # reward minus group mean + assert all(r["policy_update"] for r in rows) + + +def test_policy_update_unchanged_params_marks_rows_not_updated(): + samples = collect_rollout_samples("ckpt", loader=lambda _p: _payload(_passing_groups())) + rows = build_gate_rows(samples, _update(updated=False)) + assert not any(r["policy_update"] for r in rows) + + +# -- the five gate criteria --------------------------------------------------- + +def test_all_five_criteria_pass_but_injected_collection_never_announces(tmp_path): + samples = collect_rollout_samples("ckpt", loader=lambda _p: _payload(_passing_groups())) + update = _update() + rows = build_gate_rows(samples, update) + evidence = run_gate(rows, collector="injected", config=_config(tmp_path), update=update) + gate = evidence["gate"] + assert gate["passed"] and gate["result"]["reward_std"] > 0 + assert gate["result"]["success_count"] == 1 + assert gate["result"]["advantage_abs_sum"] > 0 + # CPU orchestration only: the 5.8 gate is never announced off the real run. + assert evidence["verdict"] is False + assert "never announced" in evidence["verdict_note"] + + +def test_all_zero_rewards_fail(): + groups = [[_sample(0, i, 0.0, False) for i in range(4)]] + samples = collect_rollout_samples("ckpt", loader=lambda _p: _payload(groups)) + evidence = run_gate(build_gate_rows(samples, _update()), collector="miles_island") + assert evidence["verdict"] is False and evidence["gate"]["passed"] is False + assert "reward_std_positive" in evidence["gate"]["error"] + assert "success_count_positive" in evidence["gate"]["error"] + assert "advantage_abs_sum_positive" in evidence["gate"]["error"] + + +def test_constant_nonzero_rewards_fail(): + groups = [[_sample(0, i, 1.0, True) for i in range(4)]] + samples = collect_rollout_samples("ckpt", loader=lambda _p: _payload(groups)) + evidence = run_gate(build_gate_rows(samples, _update()), collector="miles_island") + assert evidence["verdict"] is False + assert "reward_std_positive" in evidence["gate"]["error"] + + +def test_no_policy_update_fails_even_with_real_collector(): + samples = collect_rollout_samples("ckpt", loader=lambda _p: _payload(_passing_groups())) + update = _update(updated=False) + rows = build_gate_rows(samples, update) + evidence = run_gate(rows, collector="miles_island", update=update) + assert evidence["verdict"] is False + assert "policy_update" in evidence["gate"]["error"] + pu = evidence["policy_update_evidence"] + assert pu["updated"] is False and pu["before_digest"] == pu["after_digest"] + + +def test_real_collector_passing_run_announces_verdict(tmp_path): + samples = collect_rollout_samples("ckpt", loader=lambda _p: _payload(_passing_groups())) + update = _update() + rows = build_gate_rows(samples, update) + evidence = run_gate(rows, collector="miles_island", config=_config(tmp_path), update=update) + assert evidence["verdict"] is True and evidence["schema"] == EVIDENCE_SCHEMA + assert evidence["policy_update_evidence"]["before_digest"] != \ + evidence["policy_update_evidence"]["after_digest"] + + +# -- evidence on disk: content and flag hygiene -------------------------------- + +def test_write_evidence_scrubs_flags_and_keeps_detail(tmp_path): + config = _config(tmp_path) + samples = collect_rollout_samples("ckpt", loader=lambda _p: _payload(_passing_groups())) + rows = build_gate_rows(samples, _update()) + rows[0]["metadata"]["note"] = f"grader saw {FLAGS['fixture-ctf-ezshell']} then submitted" + evidence = run_gate(rows, collector="injected", config=config, update=_update()) + out = write_evidence(evidence, tmp_path / "evidence.json", secrets=config.flags.values()) + text = out.read_text() + for flag in FLAGS.values(): + assert flag not in text + assert "***" in text + loaded = json.loads(text) + assert loaded["schema"] == EVIDENCE_SCHEMA + assert {r["reward"] for r in loaded["rows"]} == {0.0, 1.0} + assert "flag{" not in json.dumps(loaded["config_digest"]) # digest only + + +def test_config_requires_flags_for_gate_tasks(tmp_path): + with pytest.raises(GateEvidenceError, match="no trusted flag"): + GateConfig(tasks_dir=str(tmp_path), flags={"fixture-ctf-ezshell": "flag{x}"}) + + +# -- prompt data preparation ---------------------------------------------------- + +def _package(root, task_id): + (root / "public").mkdir(parents=True) + (root / "public" / "README.md").write_text(f"# {task_id}\nObtain the flag.") + (root / "task.yaml").write_text(f"task_id: {task_id}\ngrader_mode: flag\n") + (root / "MANIFEST.json").write_text(json.dumps({"task_id": task_id, "grader_mode": "flag"})) + + +def test_prepare_prompt_data_admits_gate_tasks(tmp_path): + for task_id in FLAGS: + _package(tmp_path / task_id, task_id) + out = prepare_prompt_data(_config(tmp_path), tmp_path / "prompt.jsonl") + rows = [json.loads(line) for line in Path(out).read_text().splitlines() if line.strip()] + assert len(rows) == 2 * len(FLAGS) # repeat=2 per task + assert {r["metadata"]["rlf_task_id"] for r in rows} == set(FLAGS) + assert all(r["metadata"]["benchmark"] == "rlf" for r in rows) + + +def test_prepare_prompt_data_rejects_unadmitted_package(tmp_path): + _package(tmp_path / "fixture-ctf-ezshell", "fixture-ctf-ezshell") + from yeto.rl.rlf.contracts import AdmissionError + with pytest.raises(AdmissionError): + prepare_prompt_data(_config(tmp_path), tmp_path / "prompt.jsonl") diff --git a/yeto/rl/eval/rlf_rl_gate.py b/yeto/rl/eval/rlf_rl_gate.py new file mode 100644 index 00000000..c33bd604 --- /dev/null +++ b/yeto/rl/eval/rlf_rl_gate.py @@ -0,0 +1,357 @@ +"""Real-GPU RLF RL gate runner and evidence chain (rlf-codex-rl task 5.8). + +One gate run, in this order: + +1. admit the CTF task packages (``fixture-ctf-ezshell``: solvable in ~2 + turns, provides success; ``fixture-ctf-ret2win``: provides reward + variance) into the RLF catalog and render Miles ``--prompt-data`` rows + (``yeto.rl.rlf.catalog.write_prompt_rows`` via :func:`write_prompt_data`); +2. launch one Miles GRPO island on the small ``qwen35_08b`` profile (0.8B, + the S17-verified harness stack; LoRA, 1xGPU) with + ``--custom-rm-path yeto.rl.adapters.miles.rewards.rlf_success`` and + ``--advantage-estimator grpo`` (both already fixed by + ``island_entry.build_miles_argv``) and the W4 RLF environment provider + (``YETO_HARNESS_ENVIRONMENT_PROVIDER=yeto.cloud.modal_reward_env:rlf_modal_provider`` + + ``YETO_RLF_CTF_TASKS_DIR`` / ``YETO_RLF_CTF_FLAGS_JSON``). The 30B + capability surface is covered separately by the 5.6 baseline; 30B RL + training is a follow-up item (design decision 2026-10-11); +3. after training, collect the gate evidence from real learner/rollout + artifacts — never hand-filled: + - rewards/success: the island's completed-groups checkpoint + (``--completed-groups-path``), i.e. the serialized Miles samples the + learner actually trained on, each carrying the ``rlf_success`` + reward value and ``metadata.success``; + - advantages: recomputed per GRPO group with + ``yeto.rl.rlf.contracts.group_advantages`` (the computation of record + designated by W3 for paths where the learner does not export + per-sample advantages); + - policy update: ``contracts.policy_update_evidence`` over a parameter + summary of the initial adapter vs the final trained adapter + (see "代拍板" note below); +4. judge with ``contracts.rl_gate`` (hard criteria: non-empty reward, + reward_std>0, success_count>0, advantage_abs_sum>0, policy_update) and + write the evidence JSON (reward detail, success, advantage_abs_sum, + policy-update digest before/after). All-zero or constant reward MUST + fail the gate. + +代拍板 (policy-update collection, pending main-agent sign-off): the Miles +fork exports no per-step parameter snapshot hook, so the minimal viable +collection is a parameter summary (per-tensor ``float(sum)``) of the +initial LoRA adapter (``--initial-adapter`` snapshot, or the base snapshot +at t0) versus the final saved adapter, fed to +``contracts.policy_update_evidence``. Rationale: it reuses W3's digest +contract unchanged, proves the optimizer step changed policy parameters, +and needs no fork patch; it cannot attribute the change to a specific +optimizer step, which the gate does not require. + +Flag hygiene: trusted flags live only in ``YETO_RLF_CTF_FLAGS_JSON`` (the +Modal Secret). Every persisted evidence string is scrubbed through +``yeto.rl.rlf.modal.scrub`` with the flag values; flags are never logged. + +The verdict is announced only for evidence collected from the real island +artifacts (``collector="miles_island"``). CPU tests inject fake learners +and only exercise the orchestration: an injected collection can never +produce a passing verdict, so the gate cannot be declared green off GPU. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +from yeto.rl.rlf.catalog import import_catalog, write_prompt_data +from yeto.rl.rlf.contracts import ( + AdmissionError, + PolicyUpdateEvidence, + group_advantages, + policy_update_evidence, + rl_gate, +) +from yeto.rl.rlf.modal import scrub + +EVIDENCE_SCHEMA = "yeto.rlf.rl_gate.evidence/v1" +DEFAULT_PROFILE = "qwen35_08b" +DEFAULT_TASK_IDS = ("fixture-ctf-ezshell", "fixture-ctf-ret2win") +GATE_TASKS_DIR_ENV = "YETO_RLF_CTF_TASKS_DIR" +GATE_FLAGS_ENV = "YETO_RLF_CTF_FLAGS_JSON" + + +class GateEvidenceError(RuntimeError): + """Collected artifacts cannot support a gate judgment (fail closed).""" + + +@dataclass +class GateConfig: + """One 5.8 gate run. Flags never leave the trusted side (never logged).""" + + tasks_dir: str + flags: Mapping[str, str] # task_id -> trusted flag + profile: str = DEFAULT_PROFILE + task_ids: tuple[str, ...] = DEFAULT_TASK_IDS + repeat: int = 2 # prompt rows per task + rounds: int = 2 # Miles --num-rollout + groups_per_round: int = 4 # --rollout-batch-size + samples_per_group: int = 4 # --n-samples-per-prompt + lora_rank: int = 16 + evidence: Mapping[str, Any] = field( + default_factory=lambda: {"runtime_quality_gate": {"healthy": True}}) + + def __post_init__(self) -> None: + missing = [t for t in self.task_ids if t not in self.flags] + if missing: + raise GateEvidenceError(f"no trusted flag for tasks: {missing}") + + def digest(self) -> str: + """Config digest WITHOUT the flags (safe to persist).""" + blob = json.dumps( + {"profile": self.profile, "task_ids": sorted(self.task_ids), + "repeat": self.repeat, "rounds": self.rounds, + "groups_per_round": self.groups_per_round, + "samples_per_group": self.samples_per_group, + "lora_rank": self.lora_rank, + "flag_sha256": {t: hashlib.sha256(self.flags[t].encode()).hexdigest() + for t in sorted(self.flags)}}, + sort_keys=True, separators=(",", ":")) + return hashlib.sha256(blob.encode()).hexdigest() + + +# ---------------------------------------------------------------- prompt data + +def prepare_prompt_data(config: GateConfig, out_path: str | Path) -> Path: + """Admit the gate task packages and write Miles ``--prompt-data`` JSONL.""" + roots = [Path(config.tasks_dir) / task_id for task_id in config.task_ids] + catalog = import_catalog(roots, evidence=dict(config.evidence)) + return write_prompt_data(catalog, out_path, repeat=config.repeat) + + +# ---------------------------------------------------- rollout sample collection + +def _load_island_checkpoint(path: str | Path) -> Mapping[str, Any]: + """Real loader: the island's completed-groups checkpoint (lazy torch).""" + import torch # GPU/learner side only; CPU tests inject ``loader`` + + payload = torch.load(Path(path).expanduser(), map_location="cpu", weights_only=True) + if not isinstance(payload, Mapping) or "completed_groups" not in payload: + raise GateEvidenceError(f"island checkpoint {path} lacks completed_groups") + return payload + + +def collect_rollout_samples( + checkpoint_path: str | Path, + *, + loader: Callable[[str | Path], Mapping[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """Real reward/success collection from the learner's completed groups. + + Returns one record per sample: ``group_index``/``index`` (position in the + checkpoint), ``task_id``, ``reward`` and ``success``. A sample without a + graded ``reward`` or ``metadata.success`` aborts the gate (fail closed) — + silent zeros are exactly what this gate exists to reject. + """ + payload = (loader or _load_island_checkpoint)(checkpoint_path) + groups = payload.get("completed_groups") + if not isinstance(groups, list) or not groups: + raise GateEvidenceError("island checkpoint has no completed groups") + records: list[dict[str, Any]] = [] + for group_index, group in enumerate(groups): + if not isinstance(group, list): + raise GateEvidenceError(f"completed group {group_index} is not a sample list") + for index, sample in enumerate(group): + if isinstance(sample, list): # compaction segments share one outcome + if not sample: + raise GateEvidenceError("empty segment list in completed group") + sample = sample[0] + if not isinstance(sample, Mapping): + raise GateEvidenceError(f"sample {group_index}/{index} is not a mapping") + reward = sample.get("reward") + metadata = sample.get("metadata") or {} + if not isinstance(reward, (int, float)) or isinstance(reward, bool): + raise GateEvidenceError(f"sample {group_index}/{index} lacks a numeric reward") + if "success" not in metadata: + raise GateEvidenceError( + f"sample {group_index}/{index} lacks metadata.success " + "(rlf_success reward must write it)") + records.append({"group_index": group_index, "index": index, + "task_id": metadata.get("rlf_task_id") or metadata.get("task_id"), + "reward": float(reward), + "success": bool(metadata["success"])}) + return records + + +# ------------------------------------------------------- policy update evidence + +def _summarize_params_real(path: str | Path) -> dict[str, float]: + """Real loader: per-tensor ``float(sum)`` over a safetensors/torch file or dir.""" + tensors: dict[str, Any] = {} + root = Path(path).expanduser() + files = sorted(root.rglob("*.safetensors")) if root.is_dir() else [root] + if files and all(f.suffix == ".safetensors" for f in files): + from safetensors.torch import load_file # lazy, learner side only + + for file in files: + tensors.update(load_file(str(file))) + else: + import torch # lazy, learner side only + + payload = torch.load(root, map_location="cpu", weights_only=True) + state = payload.get("state_dict", payload) if isinstance(payload, Mapping) else payload + if not isinstance(state, Mapping): + raise GateEvidenceError(f"cannot read parameter tensors from {path}") + tensors = dict(state) + summary: dict[str, float] = {} + for name, tensor in tensors.items(): + try: + summary[str(name)] = float(tensor.float().sum()) + except (TypeError, AttributeError, RuntimeError) as error: + raise GateEvidenceError(f"non-tensor parameter {name!r} in {path}") from error + if not summary: + raise GateEvidenceError(f"no parameter tensors found in {path}") + return summary + + +def collect_policy_update( + before_path: str | Path, + after_path: str | Path, + *, + step: int, + loader: Callable[[str | Path], Mapping[str, float]] | None = None, +) -> PolicyUpdateEvidence: + """Digest the initial vs final adapter parameters (see module 代拍板 note).""" + load = loader or _summarize_params_real + return policy_update_evidence(dict(load(before_path)), dict(load(after_path)), step=step) + + +# ------------------------------------------------------------------ gate rows + +def build_gate_rows( + samples: Sequence[Mapping[str, Any]], + update: PolicyUpdateEvidence, +) -> list[dict[str, Any]]: + """Gate rows: real rewards/success, GRPO group advantages, real update flag. + + Advantages come from ``contracts.group_advantages`` per rollout group + (reward minus group mean) — the designated computation of record; the + ``policy_update`` flag comes from the collected PolicyUpdateEvidence, + never from a literal. + """ + by_group: dict[Any, list[Mapping[str, Any]]] = {} + for record in samples: + by_group.setdefault(record["group_index"], []).append(record) + rows: list[dict[str, Any]] = [] + for group_index in sorted(by_group): + group = by_group[group_index] + advantages = group_advantages([r["reward"] for r in group]) + for record, advantage in zip(group, advantages): + rows.append({"reward": float(record["reward"]), + "advantage": float(advantage), + "metadata": {"success": bool(record["success"]), + "task_id": record.get("task_id"), + "group_index": group_index}, + "policy_update": update.updated}) + return rows + + +# ---------------------------------------------------------------------- run + +def run_gate( + rows: Sequence[Mapping[str, Any]], + *, + collector: str, + config: GateConfig | None = None, + update: PolicyUpdateEvidence | None = None, +) -> dict[str, Any]: + """Judge the rows with ``rl_gate`` and assemble the evidence record. + + ``collector`` names the evidence source; only ``"miles_island"`` (the real + learner artifacts) may produce ``verdict=True``. Anything else (CPU fake + learners in tests, manual drills) exercises the orchestration but the + verdict stays False — the gate is never announced off the real run. + """ + try: + result = rl_gate(list(rows)) + gate: dict[str, Any] = {"passed": True, "result": result} + except AdmissionError as error: + gate = {"passed": False, "error": str(error)} + real = collector == "miles_island" + verdict = bool(gate["passed"]) and real + evidence: dict[str, Any] = { + "schema": EVIDENCE_SCHEMA, + "collected_at": time.time(), + "collector": collector, + "verdict": verdict, + "verdict_note": ( + "gate criteria evaluated on real Miles island artifacts" + if real else + "orchestration-only collection (injected/fake learner): " + "the 5.8 gate is never announced off the real GPU run"), + "gate": gate, + "rows": [dict(row) for row in rows], + } + if update is not None: + evidence["policy_update_evidence"] = { + "step": update.step, "updated": update.updated, + "before_digest": update.before_digest, "after_digest": update.after_digest, + "changed": list(update.changed)} + if config is not None: + evidence["config_digest"] = config.digest() + evidence["profile"] = config.profile + evidence["task_ids"] = sorted(config.task_ids) + return evidence + + +def write_evidence(evidence: Mapping[str, Any], out_path: str | Path, + *, secrets: Sequence[str] = ()) -> Path: + """Persist the evidence JSON with every trusted flag scrubbed.""" + text = json.dumps(evidence, ensure_ascii=False, indent=2, sort_keys=True, default=str) + path = Path(out_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(scrub(text, *secrets) + "\n") + return path + + +def main(argv: list[str] | None = None) -> None: + """``python -m yeto.rl.eval.rlf_rl_gate {prompt-data|gate} ...`` (island side).""" + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + pd = sub.add_parser("prompt-data", help="admit packages, write Miles --prompt-data") + pd.add_argument("--tasks-dir", required=True) + pd.add_argument("--out", required=True) + pd.add_argument("--repeat", type=int, default=2) + pd.add_argument("--flags-json", default=None, + help="path to the trusted flags JSON (never logged)") + gate = sub.add_parser("gate", help="collect island artifacts, judge, write evidence") + gate.add_argument("--completed-groups", required=True) + gate.add_argument("--adapter-before", required=True) + gate.add_argument("--adapter-after", required=True) + gate.add_argument("--step", type=int, required=True) + gate.add_argument("--out", required=True) + gate.add_argument("--flags-json", default=None) + args = parser.parse_args(argv) + + secrets: list[str] = [] + flags_path = getattr(args, "flags_json", None) + if flags_path: + flags = json.loads(Path(flags_path).read_text()) + secrets = [str(v) for v in flags.values()] + if args.command == "prompt-data": + if not flags_path: + raise SystemExit("--flags-json is required (fail closed)") + config = GateConfig(tasks_dir=args.tasks_dir, flags=flags, repeat=args.repeat) + prepare_prompt_data(config, args.out) + return + samples = collect_rollout_samples(args.completed_groups) + update = collect_policy_update(args.adapter_before, args.adapter_after, step=args.step) + rows = build_gate_rows(samples, update) + evidence = run_gate(rows, collector="miles_island", update=update) + write_evidence(evidence, args.out, secrets=secrets) + if not evidence["verdict"]: + raise SystemExit(f"RL gate failed: {evidence['gate']}") + + +if __name__ == "__main__": + main() From 51fd927b65fcd9013dd8f8c16af7c19c7f6f8c68 Mon Sep 17 00:00:00 2001 From: michael <78113554+chowhsu@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:35:21 +0000 Subject: [PATCH 02/10] rlf-codex-rl: 5.8 gate attempt 1 failed at trainer cutlass compile; evidence + lessons --- evidence/rlf/acceptance-status.json | 5 +++-- evidence/rlf/decision-log.md | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/evidence/rlf/acceptance-status.json b/evidence/rlf/acceptance-status.json index d4331689..c858cd9c 100644 --- a/evidence/rlf/acceptance-status.json +++ b/evidence/rlf/acceptance-status.json @@ -6,7 +6,7 @@ "trusted_reference_flag": "pass", "codex_trajectory_metadata": "pass_cpu_tests", "cpu_reward_gae_gate": "pass", - "modal_gpu_rl_gate": "pending", + "modal_gpu_rl_gate": "attempted_failed_trainer_cutlass", "qwen3_baseline": "pass", "failure_class": "resolved_transport_single_recv", "swe": "deferred", @@ -16,5 +16,6 @@ "ctf_lease_evidence": "evidence/rlf/modal-ctf-lease-e2e.log (vm sb-01M4MEKE66M30GA31EZBGEBRR0, destroyed, describe=gone)", "qwen3_baseline_evidence": "run10 (s1-runs/s21-rlf-baseline-20261011a/run10-out): ezshell success reward=1.0 (2 turns), ret2win failed reward=0.0 (9 turns, wrong win addr); success_count=1, reward mean=0.5 std>0; run8: ret2win 36-turn 31K-token max_seq_len truncation (5.7 trigger evidence)", "compaction_real_firing": "pass", - "compaction_evidence": "run18 (s1-runs/.../run18-out): 12K window/4K reserve, compaction fired at 10320 tokens, summary_ok, context 8210->2176, episode continued to 12 turns + submit; run14: qwen3_cyber 24K profile active with correct no-fire gating" + "compaction_evidence": "run18 (s1-runs/.../run18-out): 12K window/4K reserve, compaction fired at 10320 tokens, summary_ok, context 8210->2176, episode continued to 12 turns + submit; run14: qwen3_cyber 24K profile active with correct no-fire gating", + "modal_gpu_rl_gate_evidence": "s1-runs/s21-rlf-baseline-20261011a/gate-launch7.log (rlf-gate-20261011-3): rollout+RLF reward path live; Megatron train step cutlass crd2idx compile error; artifacts lost with island (pull-before-stop lesson recorded)" } \ No newline at end of file diff --git a/evidence/rlf/decision-log.md b/evidence/rlf/decision-log.md index 84fdfda1..af0593ae 100644 --- a/evidence/rlf/decision-log.md +++ b/evidence/rlf/decision-log.md @@ -40,3 +40,12 @@ - 机制验证另用 12K 窗口/4K reserve/单轮 4096(触发线 8192):run18 第 8 轮 10320 tokens 触发,summary_ok=true,segment 0 kept_steps=1,context 8210→2176 切换后继续 4 轮并以 submit 正常结束。segment identity 与共享 reward 合并由 CPU 测试锁定(test_codex_bridge_compaction / test_rlf_rollout_entry)。 - 途中修复:baseline runner 未预创建 segment sessions(已补,含 teardown 清理);for_profile 与覆盖参数冲突(kwargs 优先);budget==max_seq_len 桥约束(profile 24576 需配 context_length 24576)。 - 观察:该模型 thinking 轮常超 2K tokens,单轮截断(harness TITO 完整性规则)是长 episode 的主要死因;8K 窗口对该模型不可行。 + +## 2026-10-11 5.8 GPU RL gate 首跑(失败:训练端 cutlass 编译,非 RLF 链路) + +- 配置:L40S×1,qwen35_08b,GRPO 2 轮×4 组×4 样本,LoRA r16,rlf_success reward,rlf_modal_provider,任务 ezshell+ret2win。 +- 进展:岛启动、RLF provider 环境装配、rollout 正常产样本(prefill batch 4 并发)——RLF→Codex rollout→reward 链路在训练路径上真实工作。 +- 失败点:Megatron 训练步 cutlass MLIR 编译错误 `unable to compute crd2idx ... cute.layout "(?):(?{i64 div=8})"`,island rank 0 exit 1,launcher 按固定名单 0s 恢复超时停跑(exit 4)。 +- 教训:岛停止前 tape/checkpoint 未拉回("no pulled Modal tape file"),本跑 rollout 的 reward 证据随岛销毁。下次开卡:跑挂时先拉回 ~/yeto-output/ 与 ~/yeto-rl/ 再停。 +- failure class:trainer_kernel_compile(与 RLF 无关;与模型/LoRA shape 相关)。 +- 途中修复(已提交 3ff8e93e):launcher 透传 YETO_RLF_* 前缀、rlf_modal_provider 入 MODAL_SANDBOX_PROVIDERS、岛侧任务目录 /root/sky_workdir 路径、sandbox token 走 Modal Secret。 From 45ecee8055a3270bb6f305fb7dbe36bae153ea54 Mon Sep 17 00:00:00 2001 From: michael <78113554+chowhsu@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:05:33 +0000 Subject: [PATCH 03/10] launcher: passthrough YETO_RLF_* envs + rlf_modal_provider as Modal sandbox provider --- yeto/launcher.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/yeto/launcher.py b/yeto/launcher.py index 00ca4369..ddf73ed7 100644 --- a/yeto/launcher.py +++ b/yeto/launcher.py @@ -3491,7 +3491,7 @@ def checkout( "OPENENV_RUN_ID", "SECRLENV_MAX_TURNS", ) -HARNESS_PASSTHROUGH_ENV_PREFIXES = ("YETO_HARNESS_TB2_",) +HARNESS_PASSTHROUGH_ENV_PREFIXES = ("YETO_HARNESS_TB2_", "YETO_RLF_") # tb2_provider's Modal Sandbox backend runs inside the island (rollout # workers create the task sandboxes), so the island python needs the Modal # client. The ports image does not ship it; install it in setup without @@ -3500,7 +3500,8 @@ def checkout( MODAL_SANDBOX_PROVIDER = "yeto.rl.harness.codex.tb2_provider:modal_provider" # rl-agentic-reward-env: same TB2 provider, sandboxes from prebaked images. PREBAKED_MODAL_SANDBOX_PROVIDER = "yeto.cloud.modal_reward_env:modal_provider" -MODAL_SANDBOX_PROVIDERS = frozenset({MODAL_SANDBOX_PROVIDER, PREBAKED_MODAL_SANDBOX_PROVIDER}) +RLF_MODAL_SANDBOX_PROVIDER = "yeto.cloud.modal_reward_env:rlf_modal_provider" +MODAL_SANDBOX_PROVIDERS = frozenset({MODAL_SANDBOX_PROVIDER, PREBAKED_MODAL_SANDBOX_PROVIDER, RLF_MODAL_SANDBOX_PROVIDER}) SANDBOX_MODAL_TOKEN_ENVS = ("YETO_SANDBOX_MODAL_TOKEN_ID", "YETO_SANDBOX_MODAL_TOKEN_SECRET") From a0c5f306b9769990689da2bf180f85ffe8ed4810 Mon Sep 17 00:00:00 2001 From: michael <78113554+chowhsu@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:29:16 +0000 Subject: [PATCH 04/10] rlf(modal): sandbox idle_timeout follows ttl_s Gate attempt 5: a CTF VM was reclaimed mid-episode by Modal's 300s idle kill while the model was in a long generation turn (no exec running), the next exec raised NotFoundError, the episode aborted without trusted finish and the fail-closed preflight discarded the group. Default idle_timeout now follows ttl_s (hard cap still bounds leaks); overridable via the new RlfModalVmConfig.idle_timeout_s field. Co-authored-by: Cursor --- tests/test_rlf_env_lease.py | 31 +++++++++++++++++++++++++++++++ yeto/rl/rlf/modal.py | 8 +++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/tests/test_rlf_env_lease.py b/tests/test_rlf_env_lease.py index 29571911..58ae9009 100644 --- a/tests/test_rlf_env_lease.py +++ b/tests/test_rlf_env_lease.py @@ -416,3 +416,34 @@ def test_finish_trusted_tb2_fallback_byte_unchanged(): task_id="tb2-task", sample_id="s-1", key=HMAC_KEY)) assert md_timeout[tbench_outcome.OUTCOME_KEY]["verifier"] == tbench_outcome.TIMEOUT_VERIFIER assert not any(k.startswith("rlf_") for k in md_timeout) + + +def test_acquire_vm_idle_timeout_follows_ttl(monkeypatch): + """Sandbox idle kill must not reclaim a VM mid-generation (S21 gate-5).""" + import sys + created: dict[str, Any] = {} + + class _Sb: + object_id = "sb-x" + + def poll(self): + return None + + def terminate(self): + pass + + fake_modal = SimpleNamespace( + App=SimpleNamespace(lookup=lambda *a, **k: object()), + Image=SimpleNamespace(from_registry=lambda *a, **k: object()), + Sandbox=SimpleNamespace(create=lambda *a, **k: (created.update(k) or _Sb())), + ) + monkeypatch.setitem(sys.modules, "modal", fake_modal) + + rlf_modal.acquire_vm("t", "ep") + assert created["timeout"] == 1800 + assert created["idle_timeout"] == 1800 # not the old min(ttl, 300) + + created.clear() + cfg = rlf_modal.RlfModalVmConfig(ttl_s=600, idle_timeout_s=900) + rlf_modal.acquire_vm("t", "ep", config=cfg) + assert created["timeout"] == 600 and created["idle_timeout"] == 900 diff --git a/yeto/rl/rlf/modal.py b/yeto/rl/rlf/modal.py index 84a35f8f..07124bb3 100644 --- a/yeto/rl/rlf/modal.py +++ b/yeto/rl/rlf/modal.py @@ -55,6 +55,11 @@ class RlfModalVmConfig: cpu: float = 2.0 memory_mb: int = 4096 ttl_s: int = 1800 + # Idle kill must not fire below ttl_s: agent turns can spend minutes in model + # generation with no exec running; a shorter idle timeout reclaims the VM + # mid-episode (Modal NotFoundError) and the fail-closed preflight then drops + # the whole group. Default: follow ttl_s (hard cap still bounds leaks). + idle_timeout_s: int | None = None image: str = 'debian:bookworm-slim' runtime: str = 'vm' image_run_commands: tuple = () @@ -106,8 +111,9 @@ def acquire_vm(task_id: str, episode_id: str, *, config: RlfModalVmConfig | None image = modal.Image.from_registry(cfg.image) if cfg.image_run_commands: image = image.run_commands(*cfg.image_run_commands) + idle_timeout_s = cfg.idle_timeout_s if cfg.idle_timeout_s is not None else cfg.ttl_s sb = modal.Sandbox.create('sleep', 'infinity', app=app, image=image, runtime='vm', cpu=cfg.cpu, - memory=cfg.memory_mb, timeout=cfg.ttl_s, idle_timeout=min(cfg.ttl_s, 300), + memory=cfg.memory_mb, timeout=cfg.ttl_s, idle_timeout=idle_timeout_s, tags={'yeto-purpose': 'rlf-environment', 'yeto-task': task_id[:63], 'yeto-episode': episode_id[:63]}) return RlfVmLease(sb, task_id=task_id, episode_id=episode_id, trajectory_id=trajectory_id) From a98e267fe467ca758b167aa139ea2240e2d4b434 Mon Sep 17 00:00:00 2001 From: michael <78113554+chowhsu@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:52:20 +0000 Subject: [PATCH 05/10] rlf-codex-rl 5.8 attempt 2: training attention backend override + tape-surviving gate evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --rl-training-attention-backend {auto,flash,fused,unfused}: recipe default overridable (flash-only recipes refuse); gate run uses unfused after the L40S flash-attn cute JIT crd2idx compile failure (failure class trainer_kernel_compile, attention fwd, LoRA-independent) - rlf_rl_gate: policy-update proof from the island event tape rl_publication payload hashes (代拍板 v2) — survives island teardown via --modal-tape-volume; adapter-file path kept; policy_update_source recorded in evidence - CPU tests: 4 new backend-override + 5 new tape-collection tests Co-authored-by: Cursor --- tests/test_rl_training_attention_backend.py | 144 ++++++++++++++++++++ tests/test_rlf_rl_gate.py | 56 ++++++++ yeto/cli.py | 8 ++ yeto/launcher.py | 7 +- yeto/rl/adapters/miles/island_entry.py | 5 + yeto/rl/engine/run_config.py | 13 ++ yeto/rl/eval/rlf_rl_gate.py | 82 ++++++++++- 7 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 tests/test_rl_training_attention_backend.py diff --git a/tests/test_rl_training_attention_backend.py b/tests/test_rl_training_attention_backend.py new file mode 100644 index 00000000..6ce0027a --- /dev/null +++ b/tests/test_rl_training_attention_backend.py @@ -0,0 +1,144 @@ +"""--rl-training-attention-backend override (rlf-codex-rl 5.8). + +The recipe picks the Megatron --attention-backend (qwen3_5 -> flash, +generic -> unfused). The 5.8 gate run hit a flash-attn cute JIT compile +failure on L40S, so the CLI gained an explicit override; flash-only +recipes refuse a non-flash override. +""" + +from __future__ import annotations + +import argparse +import sys +import types + +import pytest + +from yeto.rl.adapters.miles.island_entry import build_miles_argv + + +def _args(**extra): + base = dict( + seq_len=128, + groups_per_round=4, + samples_per_group=2, + optimizer_steps=1, + lora_targets="auto", + lora_r=4, + seed=1, + learner_id=0, + global_rounds=3, + inner_lr=1e-4, + reward_function="pkg.reward:score", + over_sampling_batch_size=6, + rollout_max_response_len=64, + custom_generate_function_path="pkg.agent.generate", + custom_agent_function_path="pkg.agent.run", + agent_max_seq_len=96, + use_session_server=True, + session_server_ip="127.0.0.1", + session_server_port=[31000, 31002], + tito_model="qwen3", + tito_allowed_append_roles=["tool", "user"], + actor_num_nodes=1, + actor_num_gpus_per_node=8, + expert_parallel=None, + apply_chat_template_kwargs={"enable_thinking": False}, + ) + base.update(extra) + return argparse.Namespace(**base) + + +def _provider(**extra): + base = dict( + hidden_size=16, + num_attention_heads=4, + num_layers=2, + ffn_hidden_size=32, + num_query_groups=2, + kv_channels=4, + seq_length=256, + vocab_size=64, + normalization="RMSNorm", + layernorm_epsilon=1e-6, + position_embedding_type="yarn", + rotary_base=10000, + gated_linear_unit=True, + share_embeddings_and_output_weights=False, + add_bias_linear=False, + add_qkv_bias=True, + qk_layernorm=True, + num_moe_experts=8, + moe_ffn_hidden_size=24, + moe_router_topk=2, + moe_layer_freq=[0, 1], + moe_shared_expert_intermediate_size=12, + multi_latent_attention=True, + q_lora_rank=8, + kv_lora_rank=4, + qk_head_dim=4, + qk_pos_emb_head_dim=2, + v_head_dim=4, + ) + base.update(extra) + return argparse.Namespace(**base) + + +@pytest.fixture(autouse=True) +def _chat_template_stub(monkeypatch): + monkeypatch.setitem( + sys.modules, + "miles.utils.chat_template_utils", + types.SimpleNamespace( + resolve_reasoning_and_tool_call_parser=lambda model: ("qwen3", "qwen25") + ), + ) + + +def _backend_of(argv): + return argv[argv.index("--attention-backend") + 1] + + +def test_gdn_recipe_default_stays_flash(): + argv = build_miles_argv( + _args(), + model_path="/model", + prompt_path="/prompts.jsonl", + provider=_provider(experimental_attention_variant="gated_delta_net"), + target_modules=["qkv_proj", "out_proj"], + ) + assert _backend_of(argv) == "flash" + + +def test_override_unfused_on_gdn_recipe(): + argv = build_miles_argv( + _args(rl_training_attention_backend="unfused"), + model_path="/model", + prompt_path="/prompts.jsonl", + provider=_provider(experimental_attention_variant="gated_delta_net"), + target_modules=["qkv_proj", "out_proj"], + ) + assert _backend_of(argv) == "unfused" + + +def test_override_fused_on_generic_recipe(): + argv = build_miles_argv( + _args(rl_training_attention_backend="fused"), + model_path="/model", + prompt_path="/prompts.jsonl", + provider=_provider(), + target_modules=["qkv_proj", "out_proj"], + ) + assert _backend_of(argv) == "fused" + + +def test_auto_default_when_attr_missing(): + # Old launch-side namespaces (and most tests) never set the attribute. + argv = build_miles_argv( + _args(), + model_path="/model", + prompt_path="/prompts.jsonl", + provider=_provider(), + target_modules=["qkv_proj", "out_proj"], + ) + assert _backend_of(argv) == "unfused" # generic recipe default diff --git a/tests/test_rlf_rl_gate.py b/tests/test_rlf_rl_gate.py index 38f0de38..a63c4929 100644 --- a/tests/test_rlf_rl_gate.py +++ b/tests/test_rlf_rl_gate.py @@ -19,6 +19,7 @@ GateEvidenceError, build_gate_rows, collect_policy_update, + collect_policy_update_from_tape, collect_rollout_samples, prepare_prompt_data, run_gate, @@ -164,6 +165,61 @@ def test_real_collector_passing_run_announces_verdict(tmp_path): evidence["policy_update_evidence"]["after_digest"] +# -- policy-update proof from the island event tape (代拍板 v2) ----------------- + +def _tape(tmp_path, events): + path = tmp_path / "rl-island-0.jsonl" + path.write_text("".join(json.dumps(e) + "\n" for e in events), encoding="utf-8") + return path + + +def _publication(version, digest): + return {"event": "rl_publication", "policy_version": version, + "sync/publication_payload_hash": digest} + + +def test_tape_policy_update_detects_changed_payload(tmp_path): + tape = _tape(tmp_path, [ + {"event": "rl_driver_start"}, + _publication(0, "aaa"), + "not json at all", + _publication(1, "bbb"), + _publication(2, "ccc"), + ]) + update = collect_policy_update_from_tape(tape, step=2) + assert update.updated is True + assert update.before_digest == "aaa" and update.after_digest == "ccc" + assert update.changed == ("policy_payload",) + + +def test_tape_policy_update_unchanged_payload_marks_not_updated(tmp_path): + tape = _tape(tmp_path, [_publication(0, "aaa"), _publication(1, "aaa")]) + update = collect_policy_update_from_tape(tape, step=1) + assert update.updated is False and update.changed == () + + +def test_tape_policy_update_fails_closed_on_single_version(tmp_path): + tape = _tape(tmp_path, [_publication(0, "aaa")]) + with pytest.raises(GateEvidenceError, match="published policy version"): + collect_policy_update_from_tape(tape, step=1) + + +def test_tape_policy_update_fails_closed_on_missing_hash(tmp_path): + tape = _tape(tmp_path, [_publication(0, "aaa"), {"event": "rl_publication", + "policy_version": 1}]) + with pytest.raises(GateEvidenceError, match="payload hash"): + collect_policy_update_from_tape(tape, step=1) + + +def test_tape_policy_update_passes_full_gate(tmp_path): + samples = collect_rollout_samples("ckpt", loader=lambda _p: _payload(_passing_groups())) + tape = _tape(tmp_path, [_publication(0, "aaa"), _publication(1, "bbb")]) + update = collect_policy_update_from_tape(tape, step=1) + rows = build_gate_rows(samples, update) + evidence = run_gate(rows, collector="miles_island", update=update) + assert evidence["verdict"] is True + + # -- evidence on disk: content and flag hygiene -------------------------------- def test_write_evidence_scrubs_flags_and_keeps_detail(tmp_path): diff --git a/yeto/cli.py b/yeto/cli.py index 42698b28..64d39beb 100644 --- a/yeto/cli.py +++ b/yeto/cli.py @@ -138,6 +138,14 @@ def _add_launch_args(p: argparse.ArgumentParser) -> None: default="generic", help="model-specific Miles memory/kernel contract", ) + rl.add_argument( + "--rl-training-attention-backend", + choices=["auto", "flash", "fused", "unfused"], + default="auto", + help="override the recipe's Megatron --attention-backend (auto = recipe " + "decides); use unfused when the flash-attn cute JIT cannot compile on " + "the target GPU. Recipes with flash-only kernels refuse non-flash.", + ) rl.add_argument( "--expert-full-count", type=int, diff --git a/yeto/launcher.py b/yeto/launcher.py index ddf73ed7..b57cfaba 100644 --- a/yeto/launcher.py +++ b/yeto/launcher.py @@ -3969,7 +3969,12 @@ def make_miles_island_task( flags = ( f" --model {shlex.quote(args.model)}" f" --rl-model-recipe {shlex.quote(args.rl_model_recipe)}" - f" --data {shlex.quote(learner_data_arg(args.data))}" + + ( + f" --rl-training-attention-backend {args.rl_training_attention_backend}" + if getattr(args, "rl_training_attention_backend", "auto") not in (None, "auto") + else "" + ) + + f" --data {shlex.quote(learner_data_arg(args.data))}" + ( " --rl-single-island-no-sync" if getattr(args, "rl_single_island_no_sync", False) diff --git a/yeto/rl/adapters/miles/island_entry.py b/yeto/rl/adapters/miles/island_entry.py index cd1a38b9..2122b946 100644 --- a/yeto/rl/adapters/miles/island_entry.py +++ b/yeto/rl/adapters/miles/island_entry.py @@ -35,6 +35,11 @@ def parse_args(argv=None): choices=["generic", "deepseek-v4-flash"], default="generic", ) + parser.add_argument( + "--rl-training-attention-backend", + choices=["auto", "flash", "fused", "unfused"], + default="auto", + ) parser.add_argument("--expert-full-count", type=int, default=0) parser.add_argument("--expert-full-lr", type=float, default=1e-6) parser.add_argument("--expert-selection-sha256", default=None) diff --git a/yeto/rl/engine/run_config.py b/yeto/rl/engine/run_config.py index baf08188..48e03d0a 100644 --- a/yeto/rl/engine/run_config.py +++ b/yeto/rl/engine/run_config.py @@ -738,6 +738,19 @@ def resolve_rl_run_config( else: recipe_name, attention_backend = RECIPE_GENERIC, "unfused" + # rlf-codex-rl 5.8: --rl-training-attention-backend overrides the recipe's + # default (e.g. flash -> unfused when the flash-attn cute JIT fails to + # compile on a given GPU). Recipes whose kernels hard-require flash + # attention refuse any other backend. + backend_override = getattr(args, "rl_training_attention_backend", "auto") or "auto" + if backend_override != "auto": + if recipe_name in {RECIPE_DEEPSEEK_V4_FLASH, RECIPE_QWEN3_8_NEXT} and backend_override != "flash": + raise ValueError( + f"the {recipe_name} recipe requires --rl-training-attention-backend flash " + "(its kernels have no unfused/fused path)" + ) + attention_backend = backend_override + _validate_callable_spec(args.reward_function) eval_config = _resolve_eval( diff --git a/yeto/rl/eval/rlf_rl_gate.py b/yeto/rl/eval/rlf_rl_gate.py index c33bd604..f1089a5b 100644 --- a/yeto/rl/eval/rlf_rl_gate.py +++ b/yeto/rl/eval/rlf_rl_gate.py @@ -44,6 +44,13 @@ and needs no fork patch; it cannot attribute the change to a specific optimizer step, which the gate does not require. +代拍板 v2 (2026-10-11): after gate attempt 1 lost every island-side file +on teardown, the collection of record is the island event tape's +``rl_publication`` payload hashes (initial vs final policy version) — see +:func:`collect_policy_update_from_tape`. The adapter-file path remains as +``--adapter-before/--adapter-after`` for runs that arrange adapter +snapshots themselves. + Flag hygiene: trusted flags live only in ``YETO_RLF_CTF_FLAGS_JSON`` (the Modal Secret). Every persisted evidence string is scrubbed through ``yeto.rl.rlf.modal.scrub`` with the flag values; flags are never logged. @@ -226,6 +233,60 @@ def collect_policy_update( return policy_update_evidence(dict(load(before_path)), dict(load(after_path)), step=step) +def collect_policy_update_from_tape( + tape_path: str | Path, + *, + step: int, +) -> PolicyUpdateEvidence: + """Policy-update proof from the island event tape's ``rl_publication`` records. + + 代拍板 v2 (2026-10-11, gate attempt 2): the first gate run lost every + island-side artifact when the island was torn down after the trainer + crash. The event tape is the one channel that is mirrored to a Modal + Volume (``--modal-tape-volume``) and pulled even on failure, and the + driver publishes one ``rl_publication`` event per policy version with a + learner-computed payload hash. Comparing the lowest- and + highest-version payload hashes proves an optimizer step changed policy + parameters without any island-side file surviving — the same + step-attribution limitation as the initial-vs-final adapter digest + (代拍板 v1), with strictly better survivability. + + Fail closed: fewer than two distinct published policy versions, or a + publication without a payload hash, aborts the gate. + """ + publications: dict[int, str] = {} + with Path(tape_path).expanduser().open(encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue # prefixed/garbled lines are not gate evidence + if not isinstance(event, Mapping) or event.get("event") != "rl_publication": + continue + version = event.get("policy_version") + digest = event.get("sync/publication_payload_hash") + if not isinstance(version, int) or not isinstance(digest, str) or not digest: + raise GateEvidenceError( + f"rl_publication event lacks policy_version/payload hash: {event!r}" + ) + publications.setdefault(version, digest) + if len(publications) < 2: + raise GateEvidenceError( + f"event tape has {len(publications)} published policy version(s); " + "the gate needs the initial and at least one post-training publication" + ) + first, last = min(publications), max(publications) + before_digest, after_digest = publications[first], publications[last] + updated = before_digest != after_digest + return PolicyUpdateEvidence( + int(step), updated, before_digest, after_digest, + ("policy_payload",) if updated else (), + ) + + # ------------------------------------------------------------------ gate rows def build_gate_rows( @@ -326,8 +387,12 @@ def main(argv: list[str] | None = None) -> None: help="path to the trusted flags JSON (never logged)") gate = sub.add_parser("gate", help="collect island artifacts, judge, write evidence") gate.add_argument("--completed-groups", required=True) - gate.add_argument("--adapter-before", required=True) - gate.add_argument("--adapter-after", required=True) + gate.add_argument("--adapter-before", default=None) + gate.add_argument("--adapter-after", default=None) + gate.add_argument("--tape-events", default=None, + help="island event tape (rl-island-*.jsonl): policy-update proof " + "from rl_publication payload hashes (代拍板 v2); mutually " + "exclusive with --adapter-before/--adapter-after") gate.add_argument("--step", type=int, required=True) gate.add_argument("--out", required=True) gate.add_argument("--flags-json", default=None) @@ -345,9 +410,20 @@ def main(argv: list[str] | None = None) -> None: prepare_prompt_data(config, args.out) return samples = collect_rollout_samples(args.completed_groups) - update = collect_policy_update(args.adapter_before, args.adapter_after, step=args.step) + adapter_pair = (args.adapter_before, args.adapter_after) + if args.tape_events and any(adapter_pair): + raise SystemExit("--tape-events is mutually exclusive with --adapter-before/--adapter-after") + if args.tape_events: + update = collect_policy_update_from_tape(args.tape_events, step=args.step) + update_source = "event_tape" + elif all(adapter_pair): + update = collect_policy_update(args.adapter_before, args.adapter_after, step=args.step) + update_source = "adapter_files" + else: + raise SystemExit("policy-update proof needs --tape-events or --adapter-before + --adapter-after") rows = build_gate_rows(samples, update) evidence = run_gate(rows, collector="miles_island", update=update) + evidence["policy_update_source"] = update_source write_evidence(evidence, args.out, secrets=secrets) if not evidence["verdict"]: raise SystemExit(f"RL gate failed: {evidence['gate']}") From bfab052264083be4931a773025d522aa41709493 Mon Sep 17 00:00:00 2001 From: michael <78113554+chowhsu@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:09:35 +0000 Subject: [PATCH 06/10] rlf-codex-rl 5.8 attempt 3: --session-server-workers flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gate attempt 2 (rlf-gate-20261011-4) passed the attention-backend override (AttnBackend.unfused on the island) but died on a session-server import storm: Miles defaults --session-server-workers to 32, and 32 parallel sglang imports on the 4-core L40S island missed the 300 s ready window (same failure class as compaction G3 run c). Yeti had no flag for the worker count; plumb it cli -> launcher -> island -> AgentConfig -> Miles argv, launch-side >=1 validation, translation allowlist. The gate run drops to 8 workers (rollout is 4 groups x 4 samples). Silver lining: --modal-tape-volume + completed-groups in ~/yeto-output worked — the island tape was pulled despite the failed run. Co-authored-by: Cursor --- tests/test_rl_training_attention_backend.py | 22 +++++++++++++++++++++ yeto/cli.py | 8 ++++++++ yeto/launcher.py | 9 +++++++-- yeto/rl/adapters/miles/config.py | 3 +++ yeto/rl/adapters/miles/island_entry.py | 3 +++ yeto/rl/engine/run_config.py | 8 ++++++++ 6 files changed, 51 insertions(+), 2 deletions(-) diff --git a/tests/test_rl_training_attention_backend.py b/tests/test_rl_training_attention_backend.py index 6ce0027a..fc75f529 100644 --- a/tests/test_rl_training_attention_backend.py +++ b/tests/test_rl_training_attention_backend.py @@ -142,3 +142,25 @@ def test_auto_default_when_attr_missing(): target_modules=["qkv_proj", "out_proj"], ) assert _backend_of(argv) == "unfused" # generic recipe default + + +def test_session_server_workers_forwarded_when_set(): + argv = build_miles_argv( + _args(session_server_workers=8), + model_path="/model", + prompt_path="/prompts.jsonl", + provider=_provider(), + target_modules=["qkv_proj", "out_proj"], + ) + assert argv[argv.index("--session-server-workers") + 1] == "8" + + +def test_session_server_workers_absent_by_default(): + argv = build_miles_argv( + _args(), + model_path="/model", + prompt_path="/prompts.jsonl", + provider=_provider(), + target_modules=["qkv_proj", "out_proj"], + ) + assert "--session-server-workers" not in argv diff --git a/yeto/cli.py b/yeto/cli.py index 64d39beb..a88f392a 100644 --- a/yeto/cli.py +++ b/yeto/cli.py @@ -706,6 +706,14 @@ def _add_launch_args(p: argparse.ArgumentParser) -> None: rl.add_argument("--use-session-server", action="store_true") rl.add_argument("--session-server-ip", default=None) rl.add_argument("--session-server-port", type=int, nargs="+", default=None) + rl.add_argument( + "--session-server-workers", + type=int, + default=None, + help="Miles session server instance count (default: Miles' 32); lower it " + "on few-core single-GPU islands to avoid the parallel-import storm " + "(32 sglang imports can miss the 300 s ready window)", + ) rl.add_argument("--tito-model", default=None) rl.add_argument( "--codex-backend-profile", diff --git a/yeto/launcher.py b/yeto/launcher.py index b57cfaba..ed1e6193 100644 --- a/yeto/launcher.py +++ b/yeto/launcher.py @@ -2627,13 +2627,14 @@ def _prepare_rl_args( if not args.use_session_server and ( args.session_server_ip is not None or args.session_server_port is not None + or getattr(args, "session_server_workers", None) is not None or args.tito_model is not None or getattr(args, "codex_backend_profile", None) is not None or getattr(args, "tito_allowed_append_roles", None) is not None ): raise ValueError( - "--session-server-ip/--session-server-port/--tito-model/" - "--codex-backend-profile/" + "--session-server-ip/--session-server-port/--session-server-workers/" + "--tito-model/--codex-backend-profile/" "--tito-allowed-append-roles requires --use-session-server" ) roles = getattr(args, "tito_allowed_append_roles", None) @@ -2650,6 +2651,8 @@ def _prepare_rl_args( raise ValueError( "--session-server-port requires one positive port or an increasing range" ) + if getattr(args, "session_server_workers", None) is not None and int(args.session_server_workers) < 1: + raise ValueError("--session-server-workers must be at least 1") _default_session_server_port(args) specs = [rl_island_spec(args, spec) for spec in parse_gpu_spec(args.gpu)] @@ -4107,6 +4110,8 @@ def make_miles_island_task( if args.session_server_port: ports = " ".join(str(port) for port in args.session_server_port) flags += f" --session-server-port {ports}" + if getattr(args, "session_server_workers", None) is not None: + flags += f" --session-server-workers {int(args.session_server_workers)}" if args.tito_model: flags += f" --tito-model {shlex.quote(args.tito_model)}" if getattr(args, "codex_backend_profile", None): diff --git a/yeto/rl/adapters/miles/config.py b/yeto/rl/adapters/miles/config.py index 89ab3f8d..a9ea68f1 100644 --- a/yeto/rl/adapters/miles/config.py +++ b/yeto/rl/adapters/miles/config.py @@ -449,6 +449,7 @@ def _check_sglang_tp(value, config): "agent.use_session_server": _ok, "agent.session_server_ip": _ok, "agent.session_server_port": _ok, + "agent.session_server_workers": _ok, "agent.tito_model": _ok, "agent.tito_allowed_append_roles": _stock_codex_append_roles_or_none( "upstream Miles has no --tito-allowed-append-roles; the append roles are " @@ -972,6 +973,8 @@ def translate_run_config( if agent.session_server_port: values.append("--session-server-port") values.extend(str(port) for port in agent.session_server_port) + if agent.session_server_workers is not None: + values.extend(("--session-server-workers", str(agent.session_server_workers))) if agent.tito_model: resolver = tito_parser_resolver or _resolve_tito_parsers values.extend(("--tito-model", agent.tito_model)) diff --git a/yeto/rl/adapters/miles/island_entry.py b/yeto/rl/adapters/miles/island_entry.py index 2122b946..32b63fad 100644 --- a/yeto/rl/adapters/miles/island_entry.py +++ b/yeto/rl/adapters/miles/island_entry.py @@ -131,6 +131,7 @@ def parse_args(argv=None): parser.add_argument("--use-session-server", action="store_true") parser.add_argument("--session-server-ip", default=None) parser.add_argument("--session-server-port", type=int, nargs="+", default=None) + parser.add_argument("--session-server-workers", type=int, default=None) parser.add_argument("--tito-model", default=None) parser.add_argument("--codex-backend-profile", default=None) parser.add_argument("--codex-exclude-reasoning-from-loss", action="store_true") @@ -1740,6 +1741,8 @@ def _legacy_miles_argv(config) -> list[str]: if agent.session_server_port: values.append("--session-server-port") values.extend(str(port) for port in agent.session_server_port) + if agent.session_server_workers is not None: + values.extend(("--session-server-workers", str(agent.session_server_workers))) if agent.tito_model: from miles.utils.chat_template_utils import ( resolve_reasoning_and_tool_call_parser, diff --git a/yeto/rl/engine/run_config.py b/yeto/rl/engine/run_config.py index 48e03d0a..365280ab 100644 --- a/yeto/rl/engine/run_config.py +++ b/yeto/rl/engine/run_config.py @@ -417,6 +417,9 @@ class AgentConfig: session_server_port: tuple[Any, ...] | None tito_model: str | None tito_allowed_append_roles: tuple[str, ...] | None + # last + defaulted: direct AgentConfig constructors in older tests/helpers + # (keyword-style, without this field) keep working + session_server_workers: int | None = None @dataclass(frozen=True) @@ -973,6 +976,11 @@ def resolve_rl_run_config( use_session_server=use_session_server, session_server_ip=session_ip, session_server_port=tuple(session_port) if session_port else None, + session_server_workers=( + int(args.session_server_workers) + if getattr(args, "session_server_workers", None) is not None + else None + ), tito_model=tito_model, tito_allowed_append_roles=tuple(append_roles) if append_roles else None, ), From 6f6d292397e269471f75ad9ff9a47db692bb7d18 Mon Sep 17 00:00:00 2001 From: michael <78113554+chowhsu@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:32:58 +0000 Subject: [PATCH 07/10] evidence: gate attempt 3 root causes (unfused OOM, sandbox idle reclaim) Co-authored-by: Cursor --- evidence/rlf/decision-log.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/evidence/rlf/decision-log.md b/evidence/rlf/decision-log.md index af0593ae..6ac2a8b2 100644 --- a/evidence/rlf/decision-log.md +++ b/evidence/rlf/decision-log.md @@ -49,3 +49,13 @@ - 教训:岛停止前 tape/checkpoint 未拉回("no pulled Modal tape file"),本跑 rollout 的 reward 证据随岛销毁。下次开卡:跑挂时先拉回 ~/yeto-output/ 与 ~/yeto-rl/ 再停。 - failure class:trainer_kernel_compile(与 RLF 无关;与模型/LoRA shape 相关)。 - 途中修复(已提交 3ff8e93e):launcher 透传 YETO_RLF_* 前缀、rlf_modal_provider 入 MODAL_SANDBOX_PROVIDERS、岛侧任务目录 /root/sky_workdir 路径、sandbox token 走 Modal Secret。 + +## 2026-10-11 gate 第三跑(rlf-gate-20261011-5,失败,双根因均已定位) + +代码 d15f6e1b(session-server-workers=8 生效:48s 就绪,远小于 300s 窗),unfused 后端,约 16 min ≈$0.55。 +进展链完整走到训练步:v0 rl_publication 落磁带(policy update 证据已可收集)、rollout 0 收齐 16 样本进训练转换。两个失败: + +1. **训练步 CUDA OOM**(unfused 后端,seq 8192:注意力分数矩阵全程驻留,与 LoRA 无关)。下一跑改 `--rl-training-attention-backend fused`(cuDNN,显存与 flash 同量级且完全绕开 flash-attn cute JIT 编译失败)。 +2. **RLF CTF 沙箱被 Modal 300s 空闲回收**:`RlfModalVmConfig` 写死 `idle_timeout=min(ttl_s,300)`,模型长生成轮(exec 空窗 >300s)后 VM 消失,下一 exec 抛 NotFoundError → episode 无 trusted finish → preflight fail-closed 丢弃该组(3 组被丢、由补位凑齐 16 样本;reward 全 0 与 0.8B 能力一致,非管线错误)。修复 de0a7b7a:idle_timeout 默认跟随 ttl_s,可显式覆盖。判据:fail-closed 行为本身正确,属环境 TTL 配置缺陷,非代码竞态。 + +待用户重启后第四跑:fused + 沙箱修复,PREFIX 递增,预算 ≤$3。 From e4dc2d7dcdd34aed104636119ddd521afe116dd1 Mon Sep 17 00:00:00 2001 From: michael <78113554+chowhsu@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:47:08 +0000 Subject: [PATCH 08/10] rl gate: sample outcomes ride the event tape (ports engine) Run rlf-gate-20261011-7 SUCCEEDED (2 rounds trained, reward 0.125) but the gate could not judge: on the ports engine the island checkpoint only ever holds an empty completed-group queue, so per-sample reward/success died with the island. queue_completed_groups now also emits one compact rl_sample_outcome event per sample (fail-closed: skips non-numeric reward, success=None is rejected by the judge), and the gate collects samples from --tape-events when --completed-groups is absent. 3 new tests. Co-authored-by: Cursor --- evidence/rlf/decision-log.md | 11 ++++ tests/test_rlf_rl_gate.py | 73 +++++++++++++++++++++++++ yeto/rl/adapters/miles/legacy/engine.py | 39 +++++++++++++ yeto/rl/eval/rlf_rl_gate.py | 55 ++++++++++++++++++- 4 files changed, 176 insertions(+), 2 deletions(-) diff --git a/evidence/rlf/decision-log.md b/evidence/rlf/decision-log.md index 6ac2a8b2..13e2814d 100644 --- a/evidence/rlf/decision-log.md +++ b/evidence/rlf/decision-log.md @@ -59,3 +59,14 @@ 2. **RLF CTF 沙箱被 Modal 300s 空闲回收**:`RlfModalVmConfig` 写死 `idle_timeout=min(ttl_s,300)`,模型长生成轮(exec 空窗 >300s)后 VM 消失,下一 exec 抛 NotFoundError → episode 无 trusted finish → preflight fail-closed 丢弃该组(3 组被丢、由补位凑齐 16 样本;reward 全 0 与 0.8B 能力一致,非管线错误)。修复 de0a7b7a:idle_timeout 默认跟随 ttl_s,可显式覆盖。判据:fail-closed 行为本身正确,属环境 TTL 配置缺陷,非代码竞态。 待用户重启后第四跑:fused + 沙箱修复,PREFIX 递增,预算 ≤$3。 + +## 2026-10-11 gate 第四跑(rlf-gate-20261011-6,失败)与第五跑决策(代拍板) + +第四跑(fused/L40S/0.8B,约 12 min ≈$0.4):沙箱修复生效(NotFoundError 归零)、fused 旗标生效(Megatron 打印 AttnBackend.fused)、rollout 0 收齐 16 样本。训练步失败:TE 报 "No dot product attention backend is available"——cuDNN fused 对 L40S/sm89 + 该输入组合不可用。三条后端全灭:flash(cute JIT 编译错)、unfused(seq8192 OOM:mbs 已为 1、recompute 未启用且无透传旗标、单样本分数块 7.34 GiB、总缺口 ~5.4 GiB)、fused(不支持)。trusted-fields 拒收 2 组为 ret2win 超时中止的 by-design 行为。 + +第五跑决策(代拍板,留痕): +1. 卡型换 H200×1(141 GiB)+ unfused:4B 在 seq 8192 mbs1 无 recompute 估算峰值 ~87 GiB(据 0.8B 实测 42.5+7.3 外推),141 GiB 有充足余量;不加 recompute 透传(无旗标、加管道有风险)。 +2. 模型换 Qwen3.5-4B(rev 851bf6e8,M1 生产钉版,profile qwen35):0.8B 连续两跑 16+ 样本 reward 全 0,gate 五判据中 std>0/success_count/advantage 三项都依赖至少一次 ezshell 成功,0.8B 不可行。4B 是 M1 agentic 跑验证过的形状(H200 + session server + TorchMemorySaver 共置)。 +3. 其余不变(LoRA r16 attention、seq 8192、GRPO 2×4×4、worker 8)。预算预登记 ≤$6。 + +第五跑(rlf-gate-20261011-7)head 形态(代拍板):本地 preflight 线程守卫把内核线程计入 root(~1000),真实用户线程仅 ~1841 仍卡 2800 线;按用户指示改用 `--controller head`(Nebius eu-north1 无卡 head)+ `--preflight-threads off`——理由:guard 的目的是保护跑 driver 的机器,head 模式下 driver 在 Nebius VM,本地只剩瘦客户端,本地计数是不适用的机器。Modal 令牌按 s19-critic-g3.sh 模式走环境变量进 head(不落盘)。跑完先从 head 拉回产物再 sky down。 diff --git a/tests/test_rlf_rl_gate.py b/tests/test_rlf_rl_gate.py index a63c4929..faa063dc 100644 --- a/tests/test_rlf_rl_gate.py +++ b/tests/test_rlf_rl_gate.py @@ -268,3 +268,76 @@ def test_prepare_prompt_data_rejects_unadmitted_package(tmp_path): from yeto.rl.rlf.contracts import AdmissionError with pytest.raises(AdmissionError): prepare_prompt_data(_config(tmp_path), tmp_path / "prompt.jsonl") + + +# --- rl_sample_outcome tape collection (ports engine, S21 gate-7) ----------- + +def _outcome(group, index, reward, success, task="fixture-ctf-ezshell"): + return {"event": "rl_sample_outcome", "policy_version": 0, + "group_index": group, "index": index, + "task_id": task, "reward": reward, "success": success} + + +def test_tape_sample_outcomes_collect(tmp_path): + from yeto.rl.eval.rlf_rl_gate import collect_rollout_samples_from_tape + tape = _tape(tmp_path, [ + {"event": "rl_driver_start"}, + _outcome(0, 0, 1.0, True), + _outcome(0, 1, 0.0, False), + "garbage", + _outcome(1, 0, 0.0, False, task="fixture-ctf-ret2win"), + ]) + records = collect_rollout_samples_from_tape(tape) + assert len(records) == 3 + assert records[0]["reward"] == 1.0 and records[0]["success"] is True + assert records[2]["task_id"] == "fixture-ctf-ret2win" + + +def test_tape_sample_outcomes_fail_closed(tmp_path): + from yeto.rl.eval.rlf_rl_gate import collect_rollout_samples_from_tape + from yeto.rl.eval.rlf_rl_gate import GateEvidenceError + # no outcome records at all + tape = _tape(tmp_path, [{"event": "rl_driver_start"}]) + with pytest.raises(GateEvidenceError, match="no rl_sample_outcome"): + collect_rollout_samples_from_tape(tape) + # missing success must abort, never default to False + tape = _tape(tmp_path, [_outcome(0, 0, 1.0, None)]) + with pytest.raises(GateEvidenceError, match="lacks success"): + collect_rollout_samples_from_tape(tape) + # non-numeric reward aborts + bad = _outcome(0, 0, "1.0", True) + tape = _tape(tmp_path, [bad]) + with pytest.raises(GateEvidenceError, match="numeric reward"): + collect_rollout_samples_from_tape(tape) + + +def test_emit_sample_outcomes_writes_tape(tmp_path, monkeypatch): + from types import SimpleNamespace + from yeto.rl.adapters.miles.legacy import engine as legacy_engine + tape = tmp_path / "rl-island-0.jsonl" + monkeypatch.setattr("yeto.rl.wandb_rl.tee", lambda *a, **k: None) + args = SimpleNamespace(yeto_rl_event_tape=str(tape), yeto_rl_learner_id=0, + yeto_rl_policy_version=0) + + class _Sample: + def __init__(self, reward, metadata): + self._d = {"reward": reward, "metadata": metadata} + + def to_dict(self): + return self._d + + groups = [ + [_Sample(1.0, {"success": True, "rlf_task_id": "fixture-ctf-ezshell"}), + _Sample(0.0, {"success": False})], + [_Sample(None, {"success": False})], # no numeric reward -> skipped + ] + legacy_engine._emit_sample_outcomes(args, groups) + events = [json.loads(line) for line in tape.read_text().splitlines()] + outcomes = [e for e in events if e["event"] == "rl_sample_outcome"] + assert len(outcomes) == 2 + assert outcomes[0]["reward"] == 1.0 and outcomes[0]["success"] is True + assert outcomes[0]["task_id"] == "fixture-ctf-ezshell" + assert outcomes[1]["success"] is False + # no tape configured: no-op, no crash + legacy_engine._emit_sample_outcomes( + SimpleNamespace(yeto_rl_event_tape=None, yeto_rl_policy_version=0), groups) diff --git a/yeto/rl/adapters/miles/legacy/engine.py b/yeto/rl/adapters/miles/legacy/engine.py index 3d41a019..555f9d28 100644 --- a/yeto/rl/adapters/miles/legacy/engine.py +++ b/yeto/rl/adapters/miles/legacy/engine.py @@ -815,9 +815,48 @@ def queue_completed_groups(args, all_samples, data_source) -> None: "dropped_groups": dropped, "drop_reasons": drop_reasons, } + _emit_sample_outcomes(args, complete) source.add_samples(kept) +def _emit_sample_outcomes(args, complete: list[list[Any]]) -> None: + """rlf-codex-rl 5.8: per-sample reward/success onto the event tape. + + The ports bridge can only persist an empty completed-group queue, so the + RL gate's sample-level evidence must ride the tape (which is mirrored to + the Modal volume and survives the island). Fail-closed: a sample without + a numeric reward is skipped, and the gate rejects a tape with gaps. + """ + if not getattr(args, "yeto_rl_event_tape", None): + return + from yeto.rl.engine.events import write_event + + policy_version = int(getattr(args, "yeto_rl_policy_version", -1)) + for group_index, group in enumerate(complete): + for index, sample in enumerate(group): + first = sample[0] if isinstance(sample, list) else sample + to_dict = getattr(first, "to_dict", None) + if not callable(to_dict): + continue + data = to_dict() + reward = data.get("reward") + if not isinstance(reward, (int, float)) or isinstance(reward, bool): + continue + metadata = data.get("metadata") or {} + write_event( + args, + { + "event": "rl_sample_outcome", + "policy_version": policy_version, + "group_index": group_index, + "index": index, + "task_id": metadata.get("rlf_task_id") or metadata.get("task_id"), + "reward": float(reward), + "success": bool(metadata["success"]) if "success" in metadata else None, + }, + ) + + def _serialize_completed_groups(groups: list[list[Any]]) -> list[list[dict[str, Any]]]: serialized = [] for group in groups: diff --git a/yeto/rl/eval/rlf_rl_gate.py b/yeto/rl/eval/rlf_rl_gate.py index f1089a5b..3df4c501 100644 --- a/yeto/rl/eval/rlf_rl_gate.py +++ b/yeto/rl/eval/rlf_rl_gate.py @@ -190,6 +190,49 @@ def collect_rollout_samples( return records +def collect_rollout_samples_from_tape( + tape_path: str | Path, +) -> list[dict[str, Any]]: + """Real reward/success collection from ``rl_sample_outcome`` tape events. + + rlf-codex-rl 5.8 代拍板: on the ports engine the island checkpoint only + ever holds an empty completed-group queue, so sample-level evidence rides + the event tape (mirrored to the Modal volume, survives the island). The + same fail-closed rules as the checkpoint path: any gap in reward/success + aborts the gate. + """ + outcomes: list[dict[str, Any]] = [] + for line in Path(tape_path).expanduser().read_text().splitlines(): + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(record, dict) or record.get("event") != "rl_sample_outcome": + continue + outcomes.append(record) + if not outcomes: + raise GateEvidenceError("event tape has no rl_sample_outcome records") + records: list[dict[str, Any]] = [] + for order, outcome in enumerate(outcomes): + reward = outcome.get("reward") + if not isinstance(reward, (int, float)) or isinstance(reward, bool): + raise GateEvidenceError(f"tape sample {order} lacks a numeric reward") + if outcome.get("success") is None: + raise GateEvidenceError( + f"tape sample {order} lacks success " + "(rlf_success reward must write it)" + ) + records.append({"group_index": int(outcome.get("group_index", order)), + "index": int(outcome.get("index", 0)), + "task_id": outcome.get("task_id"), + "reward": float(reward), + "success": bool(outcome["success"])}) + return records + + # ------------------------------------------------------- policy update evidence def _summarize_params_real(path: str | Path) -> dict[str, float]: @@ -386,7 +429,9 @@ def main(argv: list[str] | None = None) -> None: pd.add_argument("--flags-json", default=None, help="path to the trusted flags JSON (never logged)") gate = sub.add_parser("gate", help="collect island artifacts, judge, write evidence") - gate.add_argument("--completed-groups", required=True) + gate.add_argument("--completed-groups", default=None, + help="island checkpoint with completed groups; optional when " + "--tape-events carries rl_sample_outcome records (ports engine)") gate.add_argument("--adapter-before", default=None) gate.add_argument("--adapter-after", default=None) gate.add_argument("--tape-events", default=None, @@ -409,7 +454,12 @@ def main(argv: list[str] | None = None) -> None: config = GateConfig(tasks_dir=args.tasks_dir, flags=flags, repeat=args.repeat) prepare_prompt_data(config, args.out) return - samples = collect_rollout_samples(args.completed_groups) + if args.completed_groups: + samples = collect_rollout_samples(args.completed_groups) + elif args.tape_events: + samples = collect_rollout_samples_from_tape(args.tape_events) + else: + raise SystemExit("rollout samples need --completed-groups or --tape-events") adapter_pair = (args.adapter_before, args.adapter_after) if args.tape_events and any(adapter_pair): raise SystemExit("--tape-events is mutually exclusive with --adapter-before/--adapter-after") @@ -424,6 +474,7 @@ def main(argv: list[str] | None = None) -> None: rows = build_gate_rows(samples, update) evidence = run_gate(rows, collector="miles_island", update=update) evidence["policy_update_source"] = update_source + evidence["sample_source"] = "completed_groups" if args.completed_groups else "event_tape" write_evidence(evidence, args.out, secrets=secrets) if not evidence["verdict"]: raise SystemExit(f"RL gate failed: {evidence['gate']}") From 177ee5db1f3912594b32da57885c17986ceb29ae Mon Sep 17 00:00:00 2001 From: michael <78113554+chowhsu@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:49:32 +0000 Subject: [PATCH 09/10] rl gate: accept rl_trajectory_reward tape records (ports engine) rlf-codex-rl 5.8 attempt 8 lesson: the ports launcher binds --rollout-all-samples-process-path to rollout_meta_hook.extract_rollout_metadata, so the legacy queue_completed_groups hook (054c20d5) never runs there and the tape carried zero rl_sample_outcome events. The meta hook already reports per-sample task_id/reward/success which the driver tapes as rl_trajectory_reward behind --rl-observe-timeline; teach the gate collector to accept both event shapes (sample_index fills index), same fail-closed rules. Co-authored-by: Cursor --- tests/test_rlf_rl_gate.py | 37 +++++++++++++++++++++++++++++++++++++ yeto/rl/eval/rlf_rl_gate.py | 24 ++++++++++++++++++++---- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/tests/test_rlf_rl_gate.py b/tests/test_rlf_rl_gate.py index faa063dc..6a132f7e 100644 --- a/tests/test_rlf_rl_gate.py +++ b/tests/test_rlf_rl_gate.py @@ -311,6 +311,43 @@ def test_tape_sample_outcomes_fail_closed(tmp_path): collect_rollout_samples_from_tape(tape) +def _trajectory_reward(group, sample, reward, success, task="fixture-ctf-ezshell"): + return {"event": "rl_trajectory_reward", "rollout_id": 0, "policy_version": 0, + "group_index": group, "sample_index": sample, "trajectory_id": f"t-{sample}", + "task_id": task, "reward": reward, "success": success, "aborted": False} + + +def test_tape_trajectory_rewards_collect(tmp_path): + """rlf-codex-rl 5.8 attempt 8 lesson: on the ports engine the all-samples + hook is rollout_meta_hook (queue_completed_groups never runs); the driver + tapes its per-sample records as rl_trajectory_reward.""" + from yeto.rl.eval.rlf_rl_gate import collect_rollout_samples_from_tape + tape = _tape(tmp_path, [ + {"event": "rl_driver_start"}, + _trajectory_reward(0, 0, 1.0, True), + _trajectory_reward(0, 1, 0.0, False), + _trajectory_reward(1, 0, 0.0, False, task="fixture-ctf-ret2win"), + ]) + records = collect_rollout_samples_from_tape(tape) + assert len(records) == 3 + assert records[0]["reward"] == 1.0 and records[0]["success"] is True + assert records[0]["index"] == 0 and records[1]["index"] == 1 + assert records[2]["task_id"] == "fixture-ctf-ret2win" + + +def test_tape_trajectory_rewards_fail_closed(tmp_path): + from yeto.rl.eval.rlf_rl_gate import collect_rollout_samples_from_tape + from yeto.rl.eval.rlf_rl_gate import GateEvidenceError + # success None (no signed verdict) must abort, never default to False + tape = _tape(tmp_path, [_trajectory_reward(0, 0, 1.0, None)]) + with pytest.raises(GateEvidenceError, match="lacks success"): + collect_rollout_samples_from_tape(tape) + # reward None (non-finite) must abort + tape = _tape(tmp_path, [_trajectory_reward(0, 0, None, True)]) + with pytest.raises(GateEvidenceError, match="numeric reward"): + collect_rollout_samples_from_tape(tape) + + def test_emit_sample_outcomes_writes_tape(tmp_path, monkeypatch): from types import SimpleNamespace from yeto.rl.adapters.miles.legacy import engine as legacy_engine diff --git a/yeto/rl/eval/rlf_rl_gate.py b/yeto/rl/eval/rlf_rl_gate.py index 3df4c501..f682ba6c 100644 --- a/yeto/rl/eval/rlf_rl_gate.py +++ b/yeto/rl/eval/rlf_rl_gate.py @@ -200,6 +200,17 @@ def collect_rollout_samples_from_tape( the event tape (mirrored to the Modal volume, survives the island). The same fail-closed rules as the checkpoint path: any gap in reward/success aborts the gate. + + Two event shapes are accepted (rlf-codex-rl 5.8 attempt 8 lesson: the + ports launcher binds ``--rollout-all-samples-process-path`` to + ``rollout_meta_hook.extract_rollout_metadata``, so the legacy + ``queue_completed_groups`` hook never runs there; the meta hook already + reports per-sample records which the driver tapes as + ``rl_trajectory_reward`` behind ``--rl-observe-timeline``): + + - ``rl_sample_outcome`` (legacy engine hook, 054c20d5); + - ``rl_trajectory_reward`` (rl-fn-codex-rollout 1.0 observe-only pipeline; + ``sample_index`` fills ``index``). """ outcomes: list[dict[str, Any]] = [] for line in Path(tape_path).expanduser().read_text().splitlines(): @@ -210,11 +221,15 @@ def collect_rollout_samples_from_tape( record = json.loads(line) except json.JSONDecodeError: continue - if not isinstance(record, dict) or record.get("event") != "rl_sample_outcome": + if not isinstance(record, dict): + continue + if record.get("event") not in ("rl_sample_outcome", "rl_trajectory_reward"): continue outcomes.append(record) if not outcomes: - raise GateEvidenceError("event tape has no rl_sample_outcome records") + raise GateEvidenceError( + "event tape has no rl_sample_outcome/rl_trajectory_reward records" + ) records: list[dict[str, Any]] = [] for order, outcome in enumerate(outcomes): reward = outcome.get("reward") @@ -226,7 +241,7 @@ def collect_rollout_samples_from_tape( "(rlf_success reward must write it)" ) records.append({"group_index": int(outcome.get("group_index", order)), - "index": int(outcome.get("index", 0)), + "index": int(outcome.get("index", outcome.get("sample_index", 0))), "task_id": outcome.get("task_id"), "reward": float(reward), "success": bool(outcome["success"])}) @@ -431,7 +446,8 @@ def main(argv: list[str] | None = None) -> None: gate = sub.add_parser("gate", help="collect island artifacts, judge, write evidence") gate.add_argument("--completed-groups", default=None, help="island checkpoint with completed groups; optional when " - "--tape-events carries rl_sample_outcome records (ports engine)") + "--tape-events carries rl_sample_outcome/rl_trajectory_reward " + "records (ports engine)") gate.add_argument("--adapter-before", default=None) gate.add_argument("--adapter-after", default=None) gate.add_argument("--tape-events", default=None, From 6dffab71e190e6a96b4f5fd82411785cd11ab0a1 Mon Sep 17 00:00:00 2001 From: michael <78113554+chowhsu@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:31:35 +0000 Subject: [PATCH 10/10] =?UTF-8?q?rlf-codex-rl:=205.2/5.8=20pass=20?= =?UTF-8?q?=E2=80=94=20GPU=20RL=20gate=20verdict=3DTrue=20(rlf-gate-202610?= =?UTF-8?q?11-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five criteria on the ports engine tape (32 rl_trajectory_reward records, 2 rounds x 16 samples): reward_sum=6.0, reward_std=0.390, success_count=6, advantage_abs_sum=7.0, policy_update payload hashes differ (v0/v1), collector=miles_island. Decision log: -6/-7/-8 root causes (cuDNN fused refusal, ports empty completed-group queue, all-samples hook occupied by extract_rollout_metadata). Co-authored-by: Cursor --- evidence/rlf/acceptance-status.json | 28 +- evidence/rlf/decision-log.md | 8 + evidence/rlf/gate-20261011-9.json | 351 +++++++++++++++++++++++++ openspec/changes/rlf-codex-rl/tasks.md | 4 +- 4 files changed, 375 insertions(+), 16 deletions(-) create mode 100644 evidence/rlf/gate-20261011-9.json diff --git a/evidence/rlf/acceptance-status.json b/evidence/rlf/acceptance-status.json index c858cd9c..608fc8c6 100644 --- a/evidence/rlf/acceptance-status.json +++ b/evidence/rlf/acceptance-status.json @@ -1,21 +1,21 @@ { - "package_admission": "pass", - "modal_vm_dockerd": "pass", - "ctf_image_build": "pass", - "target_health": "pass", - "trusted_reference_flag": "pass", "codex_trajectory_metadata": "pass_cpu_tests", + "compaction_evidence": "run18 (s1-runs/.../run18-out): 12K window/4K reserve, compaction fired at 10320 tokens, summary_ok, context 8210->2176, episode continued to 12 turns + submit; run14: qwen3_cyber 24K profile active with correct no-fire gating", + "compaction_real_firing": "pass", "cpu_reward_gae_gate": "pass", - "modal_gpu_rl_gate": "attempted_failed_trainer_cutlass", - "qwen3_baseline": "pass", + "ctf_image_build": "pass", + "ctf_lease_evidence": "evidence/rlf/modal-ctf-lease-e2e.log (vm sb-01M4MEKE66M30GA31EZBGEBRR0, destroyed, describe=gone)", + "ctf_lease_vm_e2e": "pass", "failure_class": "resolved_transport_single_recv", - "swe": "deferred", + "modal_gpu_rl_gate": "pass", + "modal_gpu_rl_gate_evidence": "evidence/rlf/gate-20261011-9.json (rlf-gate-20261011-9, Modal H200 ports+Qwen3.5-4B rev 851bf6e8 LoRA r16 unfused, 2轮×4组×4样本: reward_sum=6.0 std=0.390 success_count=6 adv_abs_sum=7.0 policy_update=True, collector=miles_island; tape run9-tape, head logs run9-head-logs.tgz, island+head torn down)", + "modal_vm_dockerd": "pass", "online_harness": "codex_only", - "trusted_flag_evidence": "evidence/rlf/modal-ctf-trusted-recvfix2.log (sandbox sb-01M4MB62RAE1FR0Y3HDGWZJRX7, terminated)", - "ctf_lease_vm_e2e": "pass", - "ctf_lease_evidence": "evidence/rlf/modal-ctf-lease-e2e.log (vm sb-01M4MEKE66M30GA31EZBGEBRR0, destroyed, describe=gone)", + "package_admission": "pass", + "qwen3_baseline": "pass", "qwen3_baseline_evidence": "run10 (s1-runs/s21-rlf-baseline-20261011a/run10-out): ezshell success reward=1.0 (2 turns), ret2win failed reward=0.0 (9 turns, wrong win addr); success_count=1, reward mean=0.5 std>0; run8: ret2win 36-turn 31K-token max_seq_len truncation (5.7 trigger evidence)", - "compaction_real_firing": "pass", - "compaction_evidence": "run18 (s1-runs/.../run18-out): 12K window/4K reserve, compaction fired at 10320 tokens, summary_ok, context 8210->2176, episode continued to 12 turns + submit; run14: qwen3_cyber 24K profile active with correct no-fire gating", - "modal_gpu_rl_gate_evidence": "s1-runs/s21-rlf-baseline-20261011a/gate-launch7.log (rlf-gate-20261011-3): rollout+RLF reward path live; Megatron train step cutlass crd2idx compile error; artifacts lost with island (pull-before-stop lesson recorded)" + "swe": "deferred", + "target_health": "pass", + "trusted_flag_evidence": "evidence/rlf/modal-ctf-trusted-recvfix2.log (sandbox sb-01M4MB62RAE1FR0Y3HDGWZJRX7, terminated)", + "trusted_reference_flag": "pass" } \ No newline at end of file diff --git a/evidence/rlf/decision-log.md b/evidence/rlf/decision-log.md index 13e2814d..418fdeec 100644 --- a/evidence/rlf/decision-log.md +++ b/evidence/rlf/decision-log.md @@ -70,3 +70,11 @@ 3. 其余不变(LoRA r16 attention、seq 8192、GRPO 2×4×4、worker 8)。预算预登记 ≤$6。 第五跑(rlf-gate-20261011-7)head 形态(代拍板):本地 preflight 线程守卫把内核线程计入 root(~1000),真实用户线程仅 ~1841 仍卡 2800 线;按用户指示改用 `--controller head`(Nebius eu-north1 无卡 head)+ `--preflight-threads off`——理由:guard 的目的是保护跑 driver 的机器,head 模式下 driver 在 Nebius VM,本地只剩瘦客户端,本地计数是不适用的机器。Modal 令牌按 s19-critic-g3.sh 模式走环境变量进 head(不落盘)。跑完先从 head 拉回产物再 sky down。 + +## 2026-10-11 5.8 gate 第六至八跑(-7/-8/-9)与最终通过 + +- **-6(fused/L40S/0.8B)**:训练步 cuDNN fused 报 "No dot product attention backend is available"——flash(cute JIT 拒)、fused(cuDNN 拒)、unfused(OOM) 三后端在 L40S/sm89 全灭。代拍板:换 H200×1(141G)+unfused+Qwen3.5-4B(rev 851bf6e8 M1 钉版;0.8B 连续两跑 reward 全零过不了 success_count)。 +- **-7(H200+4B)**:训练 SUCCEEDED(raw_reward 0.125→0.375)但判定卡证据缺口:ports 引擎 completed_groups 恒空,样本 outcome 随岛销毁。修 054c20d5(legacy hook 写磁带)。 +- **-8**:训练 SUCCEEDED(0.5→0.1875)但仍零 outcome——ports 启动配置里 --rollout-all-samples-process-path 被 rollout_meta_hook.extract_rollout_metadata 占用(config.py:834),054c20d5 的 queue_completed_groups 只在 legacy 非 ports 路径注册,修复打在了不执行的位置。根因教训:**改代码前先验证目标函数在真实配置下被调用**(日志 2164 行一锤定音)。 +- **-9(通过)**:judge 改收 driver 侧现成的 rl_trajectory_reward 磁带事件(rl-fn-codex-rollout 1.0 管线,--rl-observe-timeline 打开,ec656e4e)。磁带 32 条轨迹事件(16样本×2轮),五判据全过:reward_sum=6.0、std=0.390、success_count=6、advantage_abs_sum=7.0、policy_update=True(v0/v1 payload hash 相异),collector=miles_island,verdict=True。**rlf-codex-rl 5.2/5.8 通过。** +- 奖励曲线(4B,ezshell+ret2win 2×2):-7: 0.125→0.375;-8: 0.5→0.1875;-9: 0.25→0.125。ret2win 全程零(符合设计,30B 也解不出);轮间波动为采样噪声,gate 不考趋势。 diff --git a/evidence/rlf/gate-20261011-9.json b/evidence/rlf/gate-20261011-9.json new file mode 100644 index 00000000..0cf0f991 --- /dev/null +++ b/evidence/rlf/gate-20261011-9.json @@ -0,0 +1,351 @@ +{ + "collected_at": 1791725338.4261763, + "collector": "miles_island", + "gate": { + "passed": true, + "result": { + "advantage_abs_sum": 7.0, + "passed": true, + "policy_update": true, + "reward_std": 0.3903123748998999, + "reward_sum": 6.0, + "success_count": 6 + } + }, + "policy_update_evidence": { + "after_digest": "54546bee1d72ccdd11e832bfc8fd1e2a87bc1598c13cd2bdc8d91a4bb4f9cd6f", + "before_digest": "fb37744257923fd8159a3eaa81b99025ddddecc362f8f12b4810b3076dd4f3f0", + "changed": [ + "policy_payload" + ], + "step": 2, + "updated": true + }, + "policy_update_source": "event_tape", + "rows": [ + { + "advantage": 0.5, + "metadata": { + "group_index": 0, + "success": true, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 1.0 + }, + { + "advantage": -0.5, + "metadata": { + "group_index": 0, + "success": false, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.5, + "metadata": { + "group_index": 0, + "success": true, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 1.0 + }, + { + "advantage": -0.5, + "metadata": { + "group_index": 0, + "success": false, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.5, + "metadata": { + "group_index": 1, + "success": true, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 1.0 + }, + { + "advantage": -0.5, + "metadata": { + "group_index": 1, + "success": false, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.5, + "metadata": { + "group_index": 1, + "success": true, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 1.0 + }, + { + "advantage": -0.5, + "metadata": { + "group_index": 1, + "success": false, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 2, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 2, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 2, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 2, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 3, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 3, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 3, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 3, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 4, + "success": false, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 4, + "success": false, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 4, + "success": false, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 4, + "success": false, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": -0.25, + "metadata": { + "group_index": 5, + "success": false, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": -0.25, + "metadata": { + "group_index": 5, + "success": false, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.75, + "metadata": { + "group_index": 5, + "success": true, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 1.0 + }, + { + "advantage": -0.25, + "metadata": { + "group_index": 5, + "success": false, + "task_id": "fixture-ctf-ezshell" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 6, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 6, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 6, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.0, + "metadata": { + "group_index": 6, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": -0.25, + "metadata": { + "group_index": 7, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": -0.25, + "metadata": { + "group_index": 7, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + }, + { + "advantage": 0.75, + "metadata": { + "group_index": 7, + "success": true, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 1.0 + }, + { + "advantage": -0.25, + "metadata": { + "group_index": 7, + "success": false, + "task_id": "fixture-ctf-ret2win" + }, + "policy_update": true, + "reward": 0.0 + } + ], + "sample_source": "event_tape", + "schema": "yeto.rlf.rl_gate.evidence/v1", + "verdict": true, + "verdict_note": "gate criteria evaluated on real Miles island artifacts" +} diff --git a/openspec/changes/rlf-codex-rl/tasks.md b/openspec/changes/rlf-codex-rl/tasks.md index 9a322a66..f1343e56 100644 --- a/openspec/changes/rlf-codex-rl/tasks.md +++ b/openspec/changes/rlf-codex-rl/tasks.md @@ -28,13 +28,13 @@ ## 5. End-to-end gates - [x] 5.1 增加 CPU fake-provider 到 learner 的端到端测试。 -- [ ] 5.2 在 Modal GPU 上运行小规模 Codex-only RL gate,核对 rollout、trusted reward、success metadata 和 policy update。 +- [x] 5.2 在 Modal GPU 上运行小规模 Codex-only RL gate,核对 rollout、trusted reward、success metadata 和 policy update。 - [x] 5.3 记录资源清理、失败分类和可复现 manifest;确认 SWE/其他 harness 未成为前置依赖。 - [x] 5.4 更新运行文档与 handoff,说明旧 `rl-agentic-reward-env` change 保持搁置及后续 apply 顺序。 - [x] 5.5 明确并验证 AgentFlow 仅用于离线任务生产/验收,在线 Yeto Codex RL rollout 不依赖 AgentFlow。 - [x] 5.6 用 Qwen3-30B-A3B thinking mode 在固定 cyber task 集上先跑 32K 单窗口能力 baseline,分析成功率、tool trace、提交 turn、`max_seq_len_hit` 和 reward 分布。 - [x] 5.7 若 baseline 在提交 flag 前截断,显式注册 Qwen compaction budget,启用 24K window、4K–8K reserve 和最多 2–3 次 compaction,并验证 segment identity/reward 合并。 -- [ ] 5.8 在 Modal GPU RL gate 中硬性验证 reward sum/std、success count、`advantage_abs_sum > 0` 和 policy update;全零或恒定 reward 必须失败。 +- [x] 5.8 在 Modal GPU RL gate 中硬性验证 reward sum/std、success count、`advantage_abs_sum > 0` 和 policy update;全零或恒定 reward 必须失败。