from .common_imports import *
from .text_utils import extract_first_fenced_block

from .healing import _strip_triple_quoted
from .model_io import load_seed, save_adapters, load_adapters
from .wm import _resolve_best_wm_model_path
from .curriculum import Memory, Curriculum, _maybe_gate_curriculum
from .evolution import evolution_loop, sft_step
from .tasks import sample_task, render_prompt
from .generation import sample, generate_text, beam_candidates, speculative_accept_reject
from .early_exit import generate_with_early_exit, EarlyExitHead
from .taskgen import model_generate_task
from .foundry_llm import llm_sample_task
from .reward import _compute_reward
from .tasks_bank import (
    task_bank_add,
    task_bank_pick,
    task_bank_stats,
    task_bank_update,
    task_hash,
    summarize_tests,
)
from .train import contrastive_step, sft_step, dpo_step, kto_step, ul2_span_corruption_loss, ppo_lite_step
from .tasks_bank import task_bank_update_assim, task_bank_load
from .healing import is_disallowed, heal_code
from .sandbox import run_and_test, _worker_run_one_subproc, run_subset_tests
from .verifier_stages import grade_staged, stage_reward
from .healer_autosurgeon import diagnose, apply_fix
from .paths import data_path
# Temporary shim module to decouple episode loop from reward computation.
# In a follow-up step, the episode loop implementation and helpers will be
# moved here fully; for now, re-export from reward to keep behavior stable.

import ast
from collections import deque
from pathlib import Path
import time as _time

def _extract_imports(code_text: str) -> List[str]:
    try:
        tree = ast.parse(code_text.lstrip("\ufeff"))
    except Exception:
        # Fallback: simple regex scan
        try:
            mods: List[str] = []
            for m in re.finditer(r"^\s*(?:from\s+([\w\.]+)\s+import\s+|import\s+([\w\.,\s]+))", code_text, flags=re.MULTILINE):
                if m.group(1):
                    mods.append(m.group(1))
                elif m.group(2):
                    for name in str(m.group(2)).split(','):
                        name = name.strip()
                        if name:
                            mods.append(name.split('.')[0])
            return sorted(set(mods))
        except Exception:
            return []
    mods: List[str] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for n in node.names:
                if getattr(n, 'name', ''):
                    mods.append(str(n.name).split('.')[0])
        elif isinstance(node, ast.ImportFrom):
            mod = getattr(node, 'module', None)
            if isinstance(mod, str) and mod:
                mods.append(mod.split('.')[0])
    return sorted(set(mods))

def _dbg(msg: str) -> None:
    try:
        v = os.environ.get("SELFPLAY_DEBUG")
        if v is None or str(v).strip() in ("", "0", "false", "False"):
            return
    except Exception:
        return
    try:
        print(f"[selfplay][debug] {msg}")
    except Exception:
        pass

def _extract_solve_with_imports(code_text: str) -> str | None:
    # Attempt to parse module and reconstruct top-level imports + solve() definition
    try:
        tree = ast.parse(code_text.lstrip("\ufeff"))
    except Exception:
        return None
    try:
        lines = code_text.splitlines()
        # Collect top-level import blocks in original order
        import_spans: List[Tuple[int, int]] = []
        for node in getattr(tree, "body", []):
            if isinstance(node, (ast.Import, ast.ImportFrom)):
                start = max(1, getattr(node, "lineno", 1))
                end = max(start, getattr(node, "end_lineno", start))
                import_spans.append((start, end))
        # Find the solve() function
        solve_node = None
        for node in ast.walk(tree):
            if isinstance(node, ast.FunctionDef) and getattr(node, "name", "") == "solve":
                solve_node = node
                break
        if solve_node is None:
            return None
        s_start = max(1, getattr(solve_node, "lineno", 1))
        s_end = max(s_start, getattr(solve_node, "end_lineno", s_start))
        # Build output: imports (deduped by text) + blank line + solve function block
        chunks: List[str] = []
        seen_import_block: set[str] = set()
        for a, b in import_spans:
            block = "\n".join(lines[a - 1 : b])
            if block.strip() and block not in seen_import_block:
                seen_import_block.add(block)
                chunks.append(block)
        solve_block = "\n".join(lines[s_start - 1 : s_end])
        if chunks:
            chunks.append("")
        chunks.append(solve_block)
        return "\n".join(chunks)
    except Exception:
        return None

def _imports_approved_path() -> Path:
    try:
        return data_path("selfplay", "imports_approved.json")
    except Exception:
        return Path("imports_approved.json")

def _load_approved_imports() -> set[str]:
    try:
        p = _imports_approved_path()
        if p.exists():
            obj = json.loads(p.read_text(encoding="utf-8"))
            if isinstance(obj, list):
                return set(str(x) for x in obj)
    except Exception:
        pass
    return set()

def _save_approved_imports(s: set[str]) -> None:
    try:
        p = _imports_approved_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(json.dumps(sorted(list(s)), ensure_ascii=False), encoding="utf-8")
    except Exception:
        pass

def _maybe_gate_imports(modules: List[str], require_approval: bool, timeout_sec: int) -> bool:
    """Return True if imports are approved or approval not required; else False."""
    # Hard block: if env requests primitive-only code, reject any imports
    try:
        if str(os.environ.get("SELFPLAY_BLOCK_IMPORTS", "")).strip() not in ("", "0", "false", "False"):
            mods = [m for m in (modules or []) if isinstance(m, str) and m.strip()]
            if mods:
                return False
    except Exception:
        pass
    mods = [m for m in (modules or []) if isinstance(m, str) and m.strip()]
    if not mods:
        return True
    if not require_approval:
        return True
    approved = _load_approved_imports()
    pending = sorted(set(m for m in mods if m not in approved))
    if not pending:
        return True
    try:
        from agi_dw.core.hitl.approval_queue import ApprovalQueue, ApprovalItem  # type: ignore
        root = Path(__file__).resolve().parents[3]
        q = ApprovalQueue(root)
        if not q.exists():  # type: ignore[attr-defined]
            return False
        ts = __import__("datetime").datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
        meta = {"modules": pending}
        item = ApprovalItem(id=f"imports_{ts}", ts=ts, status="pending", kind="imports", preview_path="", repo="", meta=meta)
        q.write(item)
        dec = q.wait_for_decision(item.id, timeout_sec=int(timeout_sec))
        ok = bool(dec and str(dec.get("decision", "")).lower() in ("approved", "approve"))
        if ok:
            approved.update(pending)
            _save_approved_imports(approved)
        return ok
    except Exception:
        return False

def _policy_reason(code: str) -> str:
    try:
        txt = _strip_triple_quoted(code)
    except Exception:
        txt = code
    try:
        if re.search(r"^\s*(import\s+|from\s+\w+\s+import\s+)(os|sys|subprocess)\b", txt, re.MULTILINE):
            return "disallowed_import"
        if re.search(r"^\s*(exec|eval)\s*\(", txt, re.MULTILINE):
            return "exec_eval"
        if re.search(r"\bopen\s*\(", txt):
            return "file_io"
        if re.search(r"^\s*subprocess\.", txt, re.MULTILINE):
            return "subprocess"
        if re.search(r"while\s*(True|1)\s*:\s*", txt):
            return "infinite_loop"
        # C-style infinite for loops (flagged by is_disallowed)
        if re.search(r"for\s*\(\s*;\s*;\s*\)\s*:\s*", txt):
            return "infinite_loop"
        # Attempts to change recursion limit
        if re.search(r"recursionlimit", txt, re.IGNORECASE):
            return "recursionlimit"
        # Match is_disallowed length threshold (2000 chars)
        if len(code) > 2000:
            return "oversize"
    except Exception:
        pass
    return "unknown"

def _load_ref_model_for_ppo(model: torch.nn.Module, cfg: Dict[str, Any]) -> torch.nn.Module | None:
    """Load a frozen reference model onto selected GPUs (e.g., only 24GB cards).

    Respects:
    - SELFPLAY_PPO_REF_GPU_IDS (e.g., "0,1"), default "0,1".
    - SELFPLAY_PPO_REF_HEADROOM_GIB (int, default 2 GiB headroom per GPU).
    Falls back to deepcopy(model) if loading fails.
    """
    try:
        # Resolve model name
        name = None
        try:
            name = str(getattr(getattr(model, "config", object()), "_name_or_path", "") or "")
        except Exception:
            name = None
        if not name:
            name = str(os.environ.get("SELFPLAY_MODEL", cfg.get("model_name", "")) or cfg.get("model_name", ""))
        if not name:
            return copy.deepcopy(model).eval()
        # Build max_memory for selected GPU ids
        try:
            ids_env = str(os.environ.get("SELFPLAY_PPO_REF_GPU_IDS", "0,1") or "0,1")
            gpu_ids = [int(x) for x in ids_env.split(',') if x.strip().isdigit()]
        except Exception:
            gpu_ids = [0, 1]
        try:
            headroom = int(os.environ.get("SELFPLAY_PPO_REF_HEADROOM_GIB", "2") or 2)
        except Exception:
            headroom = 2
        max_memory: Dict[Any, str] = {}
        if torch.cuda.is_available() and gpu_ids:
            try:
                for i in gpu_ids:
                    props = torch.cuda.get_device_properties(i)
                    total_gib = int(max(1, props.total_memory // (1024**3)))
                    cap = max(1, total_gib - headroom)
                    max_memory[i] = f"{cap}GiB"
                max_memory["cpu"] = os.environ.get("HF_MAX_MEMORY_CPU", "64GiB")
            except Exception:
                max_memory = {}
        # Match dtype to active model
        try:
            torch_dtype = next(model.parameters()).dtype
        except Exception:
            torch_dtype = None
        # Load fresh frozen baseline
        ref = AutoModelForCausalLM.from_pretrained(  # type: ignore
            name,
            device_map=("auto" if torch.cuda.is_available() else None),
            max_memory=(max_memory if max_memory else None),
            torch_dtype=torch_dtype,
            low_cpu_mem_usage=True,
        )
        ref.eval()
        for p in ref.parameters():
            p.requires_grad_(False)
        return ref
    except Exception:
        try:
            r = copy.deepcopy(model).eval()
            for p in r.parameters():
                p.requires_grad_(False)
            return r
        except Exception:
            return None

def _normalize_code_candidate(txt: str) -> str | None:
    """Normalize raw model text to a code-only candidate.

    Priority:
    1) First fenced block sans language header
    2) The first def solve(...) block with its indented body
    3) If neither found, return None
    """
    try:
        blk = extract_first_fenced_block(txt)
        if blk is not None and blk.strip():
            return blk.strip()
    except Exception:
        pass
    try:
        s = str(txt)
        lines = s.splitlines()
        # Find the line with def solve(...):
        idx = -1
        for i, ln in enumerate(lines):
            if re.match(r"^\s*def\s+solve\s*\([^)]*\)\s*(?:->\s*[^:]+)?\s*:\s*(?:#.*)?$", ln):
                idx = i
                break
        if idx == -1:
            return None
        sig_line = lines[idx]
        body_lines: List[str] = [sig_line]
        i = idx + 1
        # Consume indented body lines only; stop at first non-indented, non-empty top-level line
        while i < len(lines):
            ln = lines[i]
            if not ln.strip():
                body_lines.append(ln)
                i += 1
                continue
            if re.match(r"^\S", ln) or re.match(r"^def\s+|^class\s+", ln):
                break
            body_lines.append(ln)
            i += 1
        block = "\n".join(body_lines).rstrip()
        # Drop trailing main-guard if somehow included
        block = re.split(r"^\s*if\s+__name__\s*==\s*['\"]__main__['\"]\s*:\s*$", block, maxsplit=1, flags=re.MULTILINE)[0].rstrip()
        return block.strip() if block.strip() else None
    except Exception:
        return None

def _suggest_stdlib_imports(code_text: str) -> str:
    """Heuristically prepend missing stdlib imports for common helpers.

    Safe, minimal, and only for benign modules. No effect if primitive-only/block-imports enabled.
    """
    try:
        # Respect primitive/block-import modes
        if str(os.environ.get("SELFPLAY_BLOCK_IMPORTS", "")).strip() not in ("", "0", "false", "False"):
            return code_text
        if str(os.environ.get("SELFPLAY_IMPORT_SUGGEST", "1")).strip() in ("0", "false", "False"):
            return code_text
    except Exception:
        pass
    txt = code_text
    # Simple text checks to avoid heavy parsing and keep deterministic
    wants: list[str] = []
    lower = txt
    # functools.reduce / lru_cache
    if (re.search(r"\breduce\s*\(", lower) is not None) and ("from functools import reduce" not in txt):
        wants.append("from functools import reduce")
    if (re.search(r"\blru_cache\b", lower) is not None) and ("from functools import lru_cache" not in txt):
        wants.append("from functools import lru_cache")
    # collections.Counter / deque
    if (re.search(r"\bCounter\s*\(", lower) is not None) and ("from collections import Counter" not in txt):
        wants.append("from collections import Counter")
    if (re.search(r"\bdeque\s*\(", lower) is not None) and ("from collections import deque" not in txt):
        wants.append("from collections import deque")
    # heapq
    if ("heapq." in lower or re.search(r"\bheappush\s*\(|\bheappop\s*\(", lower) is not None) and ("import heapq" not in txt):
        wants.append("import heapq")
    # bisect
    if ("bisect." in lower or re.search(r"\bbisect\s*\(", lower) is not None) and ("import bisect" not in txt):
        wants.append("import bisect")
    # math
    if ("math." in lower) and ("import math" not in txt):
        wants.append("import math")
    # itertools
    if ("itertools." in lower) and ("import itertools" not in txt):
        wants.append("import itertools")
    if not wants:
        return code_text
    # Prepend unique imports above the first non-empty line (top-level)
    try:
        wants_uniq = []
        seen = set()
        for w in wants:
            if w not in seen:
                seen.add(w)
                wants_uniq.append(w)
        lines = txt.splitlines()
        first_nz = 0
        for i, ln in enumerate(lines):
            if ln.strip():
                first_nz = i
                break
        new_txt = "\n".join(wants_uniq) + "\n" + ("\n".join(lines[first_nz:]) if lines else txt)
        return new_txt
    except Exception:
        return code_text

def _autofix_reduce_initializer(code_text: str) -> str:
    """Auto-fix common bug: reduce without initializer over additive lambda.

    If we detect patterns like reduce(lambda x, y: x + y, <iter>) with no
    initializer, append ", 0)" to avoid empty-sequence errors (e.g., n=0).
    Conservative: only apply when the lambda contains '+' and not '*', and
    when reduce call appears to have two top-level arguments.
    """
    try:
        txt = code_text
        # Detect if solve signature indicates string argument (e.g., def solve(s:str)->str:)
        try:
            m_sig = re.search(r"^\s*def\s+solve\s*\(\s*([A-Za-z_]\w*)\s*:\s*str\b", txt, flags=re.MULTILINE)
            str_arg = (m_sig.group(1) if m_sig else None)
        except Exception:
            str_arg = None
        # Quick check to avoid unnecessary regex work
        if "reduce(" not in txt:
            return txt
        # Regex to capture reduce(lambda x, y: x + y, <iter>) with two args
        # It avoids nested parentheses greediness by a balanced heuristic up to closing )
        pat = re.compile(r"reduce\(\s*(lambda[^)]*\+[^)]*)\,\s*([^\)]+)\)")
        def repl(m: re.Match[str]) -> str:
            lam = m.group(1)
            it = m.group(2)
            # Only add initializer if lambda looks additive and not multiplicative
            lam_s = lam.replace(" ", "")
            if "+" in lam_s and "*" not in lam_s:
                it_s = it.strip()
                # Heuristics:
                # - If iterable looks like range(...), treat as numeric -> 0
                # - If solve takes a str arg and iterable is that arg, use empty string initializer
                # - Else, default to 0 as safe numeric initializer
                if it_s.startswith("range("):
                    return f"reduce({lam}, {it}, 0)"
                if str_arg and (it_s == str_arg):
                    return f"reduce({lam}, {it}, \"\")"
                return f"reduce({lam}, {it}, 0)"
            return m.group(0)
        new_txt = pat.sub(repl, txt)
        return new_txt
    except Exception:
        return code_text

def _ledger_path() -> Path:
    try:
        return data_path("selfplay", "ledger.md")
    except Exception:
        return Path("ledger.md")

def _ledger_append(line: str) -> None:
    try:
        p = _ledger_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        with p.open("a", encoding="utf-8") as f:
            f.write(line.rstrip() + "\n")
    except Exception:
        pass

def _ledger_tail(n: int = 20) -> List[str]:
    try:
        p = _ledger_path()
        if not p.exists():
            return []
        lines = p.read_text(encoding="utf-8").splitlines()[-max(1, int(n)) :]
        return [ln for ln in lines if ln.strip()][: max(1, int(n))]
    except Exception:
        return []

def _negatives_path() -> Path:
    try:
        return data_path("selfplay", "negatives.jsonl")
    except Exception:
        return Path("negatives.jsonl")

def _normalize_failure(text: Any, max_len: int = 120) -> str:
    try:
        s = str(text or "").strip()
        return s[: max_len]
    except Exception:
        return ""

def _write_negative_example(task: str, prompt: str, body: str, rep: Dict[str, Any], policy_reason: Optional[str]) -> None:
    try:
        fam = _normalize_failure((rep or {}).get("first_failure")) or str(policy_reason or "other")
        obj = {
            "task": str(task or ""),
            "family": fam,
            "prompt_head": (prompt or "")[:1000],
            "body_head": (body or "")[:1000],
            "rep": {k: rep.get(k) for k in ("passed", "total", "timeouts", "first_failure", "elapsed_ms") if isinstance(rep, dict)},
        }
        p = _negatives_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        with p.open("a", encoding="utf-8") as f:
            f.write(json.dumps(obj, ensure_ascii=False) + "\n")
    except Exception:
        pass

def _sample_family_negative(family: str) -> Optional[str]:
    try:
        p = _negatives_path()
        if not p.exists():
            return None
        import random as _r  # type: ignore
        bodies: List[str] = []
        for ln in p.read_text(encoding="utf-8").splitlines()[-1000:]:
            try:
                obj = json.loads(ln)
            except Exception:
                continue
            if str(obj.get("family", "")) == str(family or ""):
                b = str(obj.get("body_head", ""))
                if b:
                    bodies.append(b)
        return _r.choice(bodies) if bodies else None
    except Exception:
        return None

def _maybe_inject_structured(prompt_text: str, task_name: str, cfg: Dict[str, Any]) -> str:
    try:
        use_struct = str(os.environ.get("SEL
# ... truncated ...


    task_bank_update,
    task_hash,
    summarize_tests,
)
from .train import contrastive_step, sft_step, dpo_step, kto_step, ul2_span_corruption_loss, ppo_lite_step
from .tasks_bank import task_bank_update_assim, task_bank_load
from .healing import is_disallowed, heal_code
from .sandbox import run_and_test, _worker_run_one_subproc, run_subset_tests
from .verifier_stages import grade_staged, stage_reward
from .healer_autosurgeon import diagnose, apply_fix
from .paths import data_path
# Temporary shim module to decouple episode loop from reward computation.
# In a follow-up step, the episode loop implementation and helpers will be
# moved here fully; for now, re-export from reward to keep behavior stable.

import ast
from collections import deque
from pathlib import Path
import time as _time

def _extract_imports(code_text: str) -> List[str]:
    try:
        tree = ast.parse(code_text.lstrip("\ufeff"))
    except Exception:
        # Fallback: simple regex scan
        try:
            mods: List[str] = []
            for m in re.finditer(r"^\s*(?:from\s+([\w\.]+)\s+import\s+|import\s+([\w\.,\s]+))", code_text, flags=re.MULTILINE):
                if m.group(1):
                    mods.append(m.group(1))
                elif m.group(2):
                    for name in str(m.group(2)).split(','):
                        name = name.strip()
                        if name:
                            mods.append(name.split('.')[0])
            return sorted(set(mods))
        except Exception:
            return []
    mods: List[str] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for n in node.names:
                if getattr(n, 'name', ''):
                    mods.append(str(n.name).split('.')[0])
        elif isinstance(node, ast.ImportFrom):
            mod = getattr(node, 'module', None)
            if isinstance(mod, str) and mod:
                mods.append(mod.split('.')[0])
    return sorted(set(mods))

def _dbg(msg: str) -> None:
    try:
        v = os.environ.get("SELFPLAY_DEBUG")
        if v is None or str(v).strip() in ("", "0", "false", "False"):
            return
    except Exception:
        return
    try:
        print(f"[selfplay][debug] {msg}")
    except Exception:
        pass

def _extract_solve_with_imports(code_text: str) -> str | None:
    # Attempt to parse module and reconstruct top-level imports + solve() definition
    try:
        tree = ast.parse(code_text.lstrip("\ufeff"))
    except Exception:
        return None
    try:


                elif m.group(2):
                    for name in str(m.group(2)).split(','):
                        name = name.strip()
                        if name:
                            mods.append(name.split('.')[0])
            return sorted(set(mods))
        except Exception:
            return []
    mods: List[str] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for n in node.names:
                if getattr(n, 'name', ''):
                    mods.append(str(n.name).split('.')[0])
        elif isinstance(node, ast.ImportFrom):
            mod = getattr(node, 'module', None)
            if isinstance(mod, str) and mod:
                mods.append(mod.split('.')[0])
    return sorted(set(mods))

def _dbg(msg: str) -> None:
    try:
        v = os.environ.get("SELFPLAY_DEBUG")
        if v is None or str(v).strip() in ("", "0", "false", "False"):
            return
    except Exception:
        return
    try:
        print(f"[selfplay][debug] {msg}")
    except Exception:
        pass

def _extract_solve_with_imports(code_text: str) -> str | None:
    # Attempt to parse module and reconstruct top-level imports + solve() definition
    try:
        tree = ast.parse(code_text.lstrip("\ufeff"))
    except Exception:
        return None
    try:
        lines = code_text.splitlines()
        # Collect top-level import blocks in original order
        import_spans: List[Tuple[int, int]] = []
        for node in getattr(tree, "body", []):
            if isinstance(node, (ast.Import, ast.ImportFrom)):
                start = max(1, getattr(node, "lineno", 1))
                end = max(start, getattr(node, "end_lineno", start))
                import_spans.append((start, end))
        # Find the solve() function
        solve_node = None
        for node in ast.walk(tree):
            if isinstance(node, ast.FunctionDef) and getattr(node, "name", "") == "solve":


                if getattr(n, 'name', ''):
                    mods.append(str(n.name).split('.')[0])
        elif isinstance(node, ast.ImportFrom):
            mod = getattr(node, 'module', None)
            if isinstance(mod, str) and mod:
                mods.append(mod.split('.')[0])
    return sorted(set(mods))

def _dbg(msg: str) -> None:
    try:
        v = os.environ.get("SELFPLAY_DEBUG")
        if v is None or str(v).strip() in ("", "0", "false", "False"):
            return
    except Exception:
        return
    try:
        print(f"[selfplay][debug] {msg}")
    except Exception:
        pass

def _extract_solve_with_imports(code_text: str) -> str | None:
    # Attempt to parse module and reconstruct top-level imports + solve() definition
    try:
        tree = ast.parse(code_text.lstrip("\ufeff"))
    except Exception:
        return None
    try:
        lines = code_text.splitlines()
        # Collect top-level import blocks in original order
        import_spans: List[Tuple[int, int]] = []
        for node in getattr(tree, "body", []):
            if isinstance(node, (ast.Import, ast.ImportFrom)):
                start = max(1, getattr(node, "lineno", 1))
                end = max(start, getattr(node, "end_lineno", start))
                import_spans.append((start, end))
        # Find the solve() function
        solve_node = None
        for node in ast.walk(tree):
            if isinstance(node, ast.FunctionDef) and getattr(node, "name", "") == "solve":
                solve_node = node
                break
        if solve_node is None:
            return None
        s_start = max(1, getattr(solve_node, "lineno", 1))
        s_end = max(s_start, getattr(solve_node, "end_lineno", s_start))
        # Build output: imports (deduped by text) + blank line + solve function block
        chunks: List[str] = []
        seen_import_block: set[str] = set()
        for a, b in import_spans:
            block = "\n".join(lines[a - 1 : b])
            if block.strip() and block not in seen_import_block:
                seen_import_block.add(block)
                chunks.append(block)
        solve_block = "\n".join(lines[s_start - 1 : s_end])
        if chunks:
            chunks.append("")
        chunks.append(solve_block)
        return "\n".join(chunks)
    except Exception:
        return None

def _imports_approved_path() -> Path:
    try:
        return data_path("selfplay", "imports_approved.json")
    except Exception:
        return Path("imports_approved.json")

def _load_approved_imports() -> set[str]:
    try:
        p = _imports_approved_path()
        if p.exists():
            obj = json.loads(p.read_text(encoding="utf-8"))
            if isinstance(obj, list):
                return set(str(x) for x in obj)
    except Exception:
        pass
    return set()

def _save_approved_imports(s: set[str]) -> None:
    try:


        if solve_node is None:
            return None
        s_start = max(1, getattr(solve_node, "lineno", 1))
        s_end = max(s_start, getattr(solve_node, "end_lineno", s_start))
        # Build output: imports (deduped by text) + blank line + solve function block
        chunks: List[str] = []
        seen_import_block: set[str] = set()
        for a, b in import_spans:
            block = "\n".join(lines[a - 1 : b])
            if block.strip() and block not in seen_import_block:
                seen_import_block.add(block)
                chunks.append(block)
        solve_block = "\n".join(lines[s_start - 1 : s_end])
        if chunks:
            chunks.append("")
        chunks.append(solve_block)
        return "\n".join(chunks)
    except Exception:
        return None

def _imports_approved_path() -> Path:
    try:
        return data_path("selfplay", "imports_approved.json")
    except Exception:
        return Path("imports_approved.json")

def _load_approved_imports() -> set[str]:
    try:
        p = _imports_approved_path()
        if p.exists():
            obj = json.loads(p.read_text(encoding="utf-8"))
            if isinstance(obj, list):
                return set(str(x) for x in obj)
    except Exception:
        pass
    return set()

def _save_approved_imports(s: set[str]) -> None:
    try:
        p = _imports_approved_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(json.dumps(sorted(list(s)), ensure_ascii=False), encoding="utf-8")
    except Exception:
        pass


        seen_import_block: set[str] = set()
        for a, b in import_spans:
            block = "\n".join(lines[a - 1 : b])
            if block.strip() and block not in seen_import_block:
                seen_import_block.add(block)
                chunks.append(block)
        solve_block = "\n".join(lines[s_start - 1 : s_end])
        if chunks:
            chunks.append("")
        chunks.append(solve_block)
        return "\n".join(chunks)
    except Exception:
        return None

def _imports_approved_path() -> Path:
    try:
        return data_path("selfplay", "imports_approved.json")
    except Exception:
        return Path("imports_approved.json")

def _load_approved_imports() -> set[str]:
    try:
        p = _imports_approved_path()
        if p.exists():
            obj = json.loads(p.read_text(encoding="utf-8"))
            if isinstance(obj, list):
                return set(str(x) for x in obj)
    except Exception:
        pass
    return set()

def _save_approved_imports(s: set[str]) -> None:
    try:
        p = _imports_approved_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(json.dumps(sorted(list(s)), ensure_ascii=False), encoding="utf-8")
    except Exception:
        pass

def _maybe_gate_imports(modules: List[str], require_approval: bool, timeout_sec: int) -> bool:
    """Return True if imports are approved or approval not required; else False."""
    # Hard block: if env requests primitive-only code, reject any imports
    try:
        if str(os.environ.get("SELFPLAY_BLOCK_IMPORTS", "")).strip() not in ("", "0", "false", "False"):
            mods = [m for m in (modules or []) if isinstance(m, str) and m.strip()]
            if mods:
                return False
    except Exception:
        pass
    mods = [m for m in (modules or []) if isinstance(m, str) and m.strip()]


    except Exception:
        return None

def _imports_approved_path() -> Path:
    try:
        return data_path("selfplay", "imports_approved.json")
    except Exception:
        return Path("imports_approved.json")

def _load_approved_imports() -> set[str]:
    try:
        p = _imports_approved_path()
        if p.exists():
            obj = json.loads(p.read_text(encoding="utf-8"))
            if isinstance(obj, list):
                return set(str(x) for x in obj)
    except Exception:
        pass
    return set()

def _save_approved_imports(s: set[str]) -> None:
    try:
        p = _imports_approved_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(json.dumps(sorted(list(s)), ensure_ascii=False), encoding="utf-8")
    except Exception:
        pass

def _maybe_gate_imports(modules: List[str], require_approval: bool, timeout_sec: int) -> bool:
    """Return True if imports are approved or approval not required; else False."""
    # Hard block: if env requests primitive-only code, reject any imports
    try:
        if str(os.environ.get("SELFPLAY_BLOCK_IMPORTS", "")).strip() not in ("", "0", "false", "False"):
            mods = [m for m in (modules or []) if isinstance(m, str) and m.strip()]
            if mods:
                return False
    except Exception:
        pass
    mods = [m for m in (modules or []) if isinstance(m, str) and m.strip()]
    if not mods:
        return True
    if not require_approval:
        return True
    approved = _load_approved_imports()
    pending = sorted(set(m for m in mods if m not in approved))
    if not pending:
        return True


def _load_approved_imports() -> set[str]:
    try:
        p = _imports_approved_path()
        if p.exists():
            obj = json.loads(p.read_text(encoding="utf-8"))
            if isinstance(obj, list):
                return set(str(x) for x in obj)
    except Exception:
        pass
    return set()

def _save_approved_imports(s: set[str]) -> None:
    try:
        p = _imports_approved_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(json.dumps(sorted(list(s)), ensure_ascii=False), encoding="utf-8")
    except Exception:
        pass

def _maybe_gate_imports(modules: List[str], require_approval: bool, timeout_sec: int) -> bool:
    """Return True if imports are approved or approval not required; else False."""
    # Hard block: if env requests primitive-only code, reject any imports
    try:
        if str(os.environ.get("SELFPLAY_BLOCK_IMPORTS", "")).strip() not in ("", "0", "false", "False"):
            mods = [m for m in (modules or []) if isinstance(m, str) and m.strip()]
            if mods:
                return False
    except Exception:
        pass
    mods = [m for m in (modules or []) if isinstance(m, str) and m.strip()]
    if not mods:
        return True
    if not require_approval:
        return True
    approved = _load_approved_imports()
    pending = sorted(set(m for m in mods if m not in approved))
    if not pending:
        return True
    try:
        from agi_dw.core.hitl.approval_queue import ApprovalQueue, ApprovalItem  # type: ignore
        root = Path(__file__).resolve().parents[3]
        q = ApprovalQueue(root)
        if not q.exists():  # type: ignore[attr-defined]
            return False
        ts = __import__("datetime").datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
        meta = {"modules": pending}
        item = ApprovalItem(id=f"imports_{ts}", ts=ts, status="pending", kind="imports", preview_path="", repo="", meta=meta)
        q.write(item)
        dec = q.wait_for_decision(item.id, timeout_sec=int(timeout_sec))
        ok = bool(dec and str(dec.get("decision", "")).lower() in ("approved", "approve"))
        if ok:
            approved.update(pending)
            _save_approved_imports(approved)
        return ok
    except Exception:
        return False

def _policy_reason(code: str) -> str:
    try:
        txt = _strip_triple_quoted(code)
    except Exception:
        txt = code
    try:
        if re.search(r"^\s*(import\s+|from\s+\w+\s+import\s+)(os|sys|subprocess)\b", txt, re.MULTILINE):
            return "disallowed_import"
        if re.search(r"^\s*(exec|eval)\s*\(", txt, re.MULTILINE):
            return "exec_eval"
        if re.search(r"\bopen\s*\(", txt):
            return "file_io"
        if re.search(r"^\s*subprocess\.", txt, re.MULTILINE):
            return "subprocess"
        if re.search(r"while\s*(True|1)\s*:\s*", txt):
            return "infinite_loop"
        # C-style infinite for loops (flagged by is_disallowed)
        if re.search(r"for\s*\(\s*;\s*;\s*\)\s*:\s*", txt):
            return "infinite_loop"


        return True
    try:
        from agi_dw.core.hitl.approval_queue import ApprovalQueue, ApprovalItem  # type: ignore
        root = Path(__file__).resolve().parents[3]
        q = ApprovalQueue(root)
        if not q.exists():  # type: ignore[attr-defined]
            return False
        ts = __import__("datetime").datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
        meta = {"modules": pending}
        item = ApprovalItem(id=f"imports_{ts}", ts=ts, status="pending", kind="imports", preview_path="", repo="", meta=meta)
        q.write(item)
        dec = q.wait_for_decision(item.id, timeout_sec=int(timeout_sec))
        ok = bool(dec and str(dec.get("decision", "")).lower() in ("approved", "approve"))
        if ok:
            approved.update(pending)
            _save_approved_imports(approved)
        return ok
    except Exception:
        return False

def _policy_reason(code: str) -> str:
    try:
        txt = _strip_triple_quoted(code)
    except Exception:
        txt = code
    try:
        if re.search(r"^\s*(import\s+|from\s+\w+\s+import\s+)(os|sys|subprocess)\b", txt, re.MULTILINE):
            return "disallowed_import"
        if re.search(r"^\s*(exec|eval)\s*\(", txt, re.MULTILINE):
            return "exec_eval"
        if re.search(r"\bopen\s*\(", txt):
            return "file_io"
        if re.search(r"^\s*subprocess\.", txt, re.MULTILINE):
            return "subprocess"
        if re.search(r"while\s*(True|1)\s*:\s*", txt):
            return "infinite_loop"
        # C-style infinite for loops (flagged by is_disallowed)
        if re.search(r"for\s*\(\s*;\s*;\s*\)\s*:\s*", txt):
            return "infinite_loop"
        # Attempts to change recursion limit
        if re.search(r"recursionlimit", txt, re.IGNORECASE):
            return "recursionlimit"
        # Match is_disallowed length threshold (2000 chars)
        if len(code) > 2000:
            return "oversize"
    except Exception:
        pass
    return "unknown"

def _load_ref_model_for_ppo(model: torch.nn.Module, cfg: Dict[str, Any]) -> torch.nn.Module | None:
    """Load a frozen reference model onto selected GPUs (e.g., only 24GB cards).

    Respects:
    - SELFPLAY_PPO_REF_GPU_IDS (e.g., "0,1"), default "0,1".
    - SELFPLAY_PPO_REF_HEADROOM_GIB (int, default 2 GiB headroom per GPU).
    Falls back to deepcopy(model) if loading fails.
    """
    try:
        # Resolve model name
        name = None
        try:
            name = str(getattr(getattr(model, "config", object()), "_name_or_path", "") or "")
        except Exception:
            name = None
        if not name:
            name = str(os.environ.get("SELFPLAY_MODEL", cfg.get("model_name", "")) or cfg.get("model_name", ""))


            return "file_io"
        if re.search(r"^\s*subprocess\.", txt, re.MULTILINE):
            return "subprocess"
        if re.search(r"while\s*(True|1)\s*:\s*", txt):
            return "infinite_loop"
        # C-style infinite for loops (flagged by is_disallowed)
        if re.search(r"for\s*\(\s*;\s*;\s*\)\s*:\s*", txt):
            return "infinite_loop"
        # Attempts to change recursion limit
        if re.search(r"recursionlimit", txt, re.IGNORECASE):
            return "recursionlimit"
        # Match is_disallowed length threshold (2000 chars)
        if len(code) > 2000:
            return "oversize"
    except Exception:
        pass
    return "unknown"

def _load_ref_model_for_ppo(model: torch.nn.Module, cfg: Dict[str, Any]) -> torch.nn.Module | None:
    """Load a frozen reference model onto selected GPUs (e.g., only 24GB cards).

    Respects:
    - SELFPLAY_PPO_REF_GPU_IDS (e.g., "0,1"), default "0,1".
    - SELFPLAY_PPO_REF_HEADROOM_GIB (int, default 2 GiB headroom per GPU).
    Falls back to deepcopy(model) if loading fails.
    """
    try:
        # Resolve model name
        name = None
        try:
            name = str(getattr(getattr(model, "config", object()), "_name_or_path", "") or "")
        except Exception:
            name = None
        if not name:
            name = str(os.environ.get("SELFPLAY_MODEL", cfg.get("model_name", "")) or cfg.get("model_name", ""))
        if not name:
            return copy.deepcopy(model).eval()
        # Build max_memory for selected GPU ids
        try:
            ids_env = str(os.environ.get("SELFPLAY_PPO_REF_GPU_IDS", "0,1") or "0,1")
            gpu_ids = [int(x) for x in ids_env.split(',') if x.strip().isdigit()]
        except Exception:
            gpu_ids = [0, 1]
        try:
            headroom = int(os.environ.get("SELFPLAY_PPO_REF_HEADROOM_GIB", "2") or 2)
        except Exception:
            headroom = 2
        max_memory: Dict[Any, str] = {}
        if torch.cuda.is_available() and gpu_ids:
            try:
                for i in gpu_ids:
                    props = torch.cuda.get_device_properties(i)
                    total_gib = int(max(1, props.total_memory // (1024**3)))
                    cap = max(1, total_gib - headroom)
                    max_memory[i] = f"{cap}GiB"
                max_memory["cpu"] = os.environ.get("HF_MAX_MEMORY_CPU", "64GiB")
            except Exception:
                max_memory = {}
        # Match dtype to active model
        try:
            torch_dtype = next(model.parameters()).dtype
        except Exception:
            torch_dtype = None
        # Load fresh frozen baseline
        ref = AutoModelForCausalLM.from_pretrained(  # type: ignore
            name,
            device_map=("auto" if torch.cuda.is_available() else None),
            max_memory=(max_memory if max_memory else None),
            torch_dtype=torch_dtype,
            low_cpu_mem_usage=True,
        )
        ref.eval()
        for p in ref.parameters():
            p.requires_grad_(False)
        return ref
    except Exception:
        try:
            r = copy.deepcopy(model).eval()
            for p in r.parameters():
                p.requires_grad_(False)
            return r
        except Exception:
            return None

def _normalize_code_candidate(txt: str) -> str | None:
    """Normalize raw model text to a code-only candidate.

    Priority:
    1) First fenced block sans language header
    2) The first def solve(...) block with its indented body
    3) If neither found, return None
    """
    try:
        blk = extract_first_fenced_block(txt)
        if blk is not None and blk.strip():
            return blk.strip()
    except Exception:
        pass
    try:
        s = str(txt)
        lines = s.splitlines()
        # Find the line with def solve(...):
        idx = -1


        ref = AutoModelForCausalLM.from_pretrained(  # type: ignore
            name,
            device_map=("auto" if torch.cuda.is_available() else None),
            max_memory=(max_memory if max_memory else None),
            torch_dtype=torch_dtype,
            low_cpu_mem_usage=True,
        )
        ref.eval()
        for p in ref.parameters():
            p.requires_grad_(False)
        return ref
    except Exception:
        try:
            r = copy.deepcopy(model).eval()
            for p in r.parameters():
                p.requires_grad_(False)
            return r
        except Exception:
            return None

def _normalize_code_candidate(txt: str) -> str | None:
    """Normalize raw model text to a code-only candidate.

    Priority:
    1) First fenced block sans language header
    2) The first def solve(...) block with its indented body
    3) If neither found, return None
    """
    try:
        blk = extract_first_fenced_block(txt)
        if blk is not None and blk.strip():
            return blk.strip()
    except Exception:
        pass
    try:
        s = str(txt)
        lines = s.splitlines()
        # Find the line with def solve(...):
        idx = -1
        for i, ln in enumerate(lines):
            if re.match(r"^\s*def\s+solve\s*\([^)]*\)\s*(?:->\s*[^:]+)?\s*:\s*(?:#.*)?$", ln):
                idx = i
                break
        if idx == -1:
            return None
        sig_line = lines[idx]
        body_lines: List[str] = [sig_line]
        i = idx + 1
        # Consume indented body lines only; stop at first non-indented, non-empty top-level line
        while i < len(lines):
            ln = lines[i]
            if not ln.strip():
                body_lines.append(ln)
                i += 1
                continue
            if re.match(r"^\S", ln) or re.match(r"^def\s+|^class\s+", ln):
                break
            body_lines.append(ln)
            i += 1
        block = "\n".join(body_lines).rstrip()
        # Drop trailing main-guard if somehow included
        block = re.split(r"^\s*if\s+__name__\s*==\s*['\"]__main__['\"]\s*:\s*$", block, maxsplit=1, flags=re.MULTILINE)[0].rstrip()
        return block.strip() if block.strip() else None
    except Exception:
        return None

def _suggest_stdlib_imports(code_text: str) -> str:
    """Heuristically prepend missing stdlib imports for common helpers.

    Safe, minimal, and only for benign modules. No effect if primitive-only/block-imports enabled.
    """
    try:
        # Respect primitive/block-import modes
        if str(os.environ.get("SELFPLAY_BLOCK_IMPORTS", "")).strip() not in ("", "0", "false", "False"):
            return code_text
        if str(os.environ.get("SELFPLAY_IMPORT_SUGGEST", "1")).strip() in ("0", "false", "False"):
            return code_text
    except Exception:
        pass
    txt = code_text
    # Simple text checks to avoid heavy parsing and keep deterministic
    wants: list[str] = []
    lower = txt
    # functools.reduce / lru_cache
    if (re.search(r"\breduce\s*\(", lower) is not None) and ("from functools import reduce" not in txt):


        body_lines: List[str] = [sig_line]
        i = idx + 1
        # Consume indented body lines only; stop at first non-indented, non-empty top-level line
        while i < len(lines):
            ln = lines[i]
            if not ln.strip():
                body_lines.append(ln)
                i += 1
                continue
            if re.match(r"^\S", ln) or re.match(r"^def\s+|^class\s+", ln):
                break
            body_lines.append(ln)
            i += 1
        block = "\n".join(body_lines).rstrip()
        # Drop trailing main-guard if somehow included
        block = re.split(r"^\s*if\s+__name__\s*==\s*['\"]__main__['\"]\s*:\s*$", block, maxsplit=1, flags=re.MULTILINE)[0].rstrip()
        return block.strip() if block.strip() else None
    except Exception:
        return None

def _suggest_stdlib_imports(code_text: str) -> str:
    """Heuristically prepend missing stdlib imports for common helpers.

    Safe, minimal, and only for benign modules. No effect if primitive-only/block-imports enabled.
    """
    try:
        # Respect primitive/block-import modes
        if str(os.environ.get("SELFPLAY_BLOCK_IMPORTS", "")).strip() not in ("", "0", "false", "False"):
            return code_text
        if str(os.environ.get("SELFPLAY_IMPORT_SUGGEST", "1")).strip() in ("0", "false", "False"):
            return code_text
    except Exception:
        pass
    txt = code_text
    # Simple text checks to avoid heavy parsing and keep deterministic
    wants: list[str] = []
    lower = txt
    # functools.reduce / lru_cache
    if (re.search(r"\breduce\s*\(", lower) is not None) and ("from functools import reduce" not in txt):
        wants.append("from functools import reduce")
    if (re.search(r"\blru_cache\b", lower) is not None) and ("from functools import lru_cache" not in txt):
        wants.append("from functools import lru_cache")
    # collections.Counter / deque
    if (re.search(r"\bCounter\s*\(", lower) is not None) and ("from collections import Counter" not in txt):
        wants.append("from collections import Counter")
    if (re.search(r"\bdeque\s*\(", lower) is not None) and ("from collections import deque" not in txt):
        wants.append("from collections import deque")
    # heapq
    if ("heapq." in lower or re.search(r"\bheappush\s*\(|\bheappop\s*\(", lower) is not None) and ("import heapq" not in txt):
        wants.append("import heapq")
    # bisect
    if ("bisect." in lower or re.search(r"\bbisect\s*\(", lower) is not None) and ("import bisect" not in txt):
        wants.append("import bisect")
    # math
    if ("math." in lower) and ("import math" not in txt):
        wants.append("import math")
    # itertools
    if ("itertools." in lower) and ("import itertools" not in txt):
        wants.append("import itertools")
    if not wants:
        return code_text
    # Prepend unique imports above the first non-empty line (top-level)
    try:
        wants_uniq = []
        seen = set()
        for w in wants:
            if w not in seen:
                seen.add(w)
                wants_uniq.append(w)
        lines = txt.splitlines()
        first_nz = 0
        for i, ln in enumerate(lines):
            if ln.strip():
                first_nz = i
                break
        new_txt = "\n".join(wants_uniq) + "\n" + ("\n".join(lines[first_nz:]) if lines else txt)
        return new_txt
    except Exception:
        return code_text

def _autofix_reduce_initializer(code_text: str) -> str:
    """Auto-fix common bug: reduce without initializer over additive lambda.

    If we detect patterns like reduce(lambda x, y: x + y, <iter>) with no
    initializer, append ", 0)" to avoid empty-sequence errors (e.g., n=0).
    Conservative: only apply when the lambda contains '+' and not '*', and
    when reduce call appears to have two top-level arguments.
    """
    try:
        txt = code_text
        # Detect if solve signature indicates string argument (e.g., def solve(s:str)->str:)
        try:
            m_sig = re.search(r"^\s*def\s+solve\s*\(\s*([A-Za-z_]\w*)\s*:\s*str\b", txt, flags=re.MULTILINE)
            str_arg = (m_sig.group(1) if m_sig else None)
        except Exception:
            str_arg = None
        # Quick check to avoid unnecessary regex work
        if "reduce(" not in txt:


    # Prepend unique imports above the first non-empty line (top-level)
    try:
        wants_uniq = []
        seen = set()
        for w in wants:
            if w not in seen:
                seen.add(w)
                wants_uniq.append(w)
        lines = txt.splitlines()
        first_nz = 0
        for i, ln in enumerate(lines):
            if ln.strip():
                first_nz = i
                break
        new_txt = "\n".join(wants_uniq) + "\n" + ("\n".join(lines[first_nz:]) if lines else txt)
        return new_txt
    except Exception:
        return code_text

def _autofix_reduce_initializer(code_text: str) -> str:
    """Auto-fix common bug: reduce without initializer over additive lambda.

    If we detect patterns like reduce(lambda x, y: x + y, <iter>) with no
    initializer, append ", 0)" to avoid empty-sequence errors (e.g., n=0).
    Conservative: only apply when the lambda contains '+' and not '*', and
    when reduce call appears to have two top-level arguments.
    """
    try:
        txt = code_text
        # Detect if solve signature indicates string argument (e.g., def solve(s:str)->str:)
        try:
            m_sig = re.search(r"^\s*def\s+solve\s*\(\s*([A-Za-z_]\w*)\s*:\s*str\b", txt, flags=re.MULTILINE)
            str_arg = (m_sig.group(1) if m_sig else None)
        except Exception:
            str_arg = None
        # Quick check to avoid unnecessary regex work
        if "reduce(" not in txt:
            return txt
        # Regex to capture reduce(lambda x, y: x + y, <iter>) with two args
        # It avoids nested parentheses greediness by a balanced heuristic up to closing )
        pat = re.compile(r"reduce\(\s*(lambda[^)]*\+[^)]*)\,\s*([^\)]+)\)")
        def repl(m: re.Match[str]) -> str:
            lam = m.group(1)
            it = m.group(2)
            # Only add initializer if lambda looks additive and not multiplicative
            lam_s = lam.replace(" ", "")
            if "+" in lam_s and "*" not in lam_s:
                it_s = it.strip()
                # Heuristics:
                # - If iterable looks like range(...), treat as numeric -> 0
                # - If solve takes a str arg and iterable is that arg, use empty string initializer
                # - Else, default to 0 as safe numeric initializer
                if it_s.startswith("range("):
                    return f"reduce({lam}, {it}, 0)"
                if str_arg and (it_s == str_arg):
                    return f"reduce({lam}, {it}, \"\")"
                return f"reduce({lam}, {it}, 0)"
            return m.group(0)
        new_txt = pat.sub(repl, txt)
        return new_txt
    except Exception:
        return code_text

def _ledger_path() -> Path:
    try:
        return data_path("selfplay", "ledger.md")
    except Exception:
        return Path("ledger.md")

def _ledger_append(line: str) -> None:
    try:
        p = _ledger_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        with p.open("a", encoding="utf-8") as f:
            f.write(line.rstrip() + "\n")
    except Exception:
        pass

def _ledger_tail(n: int = 20) -> List[str]:


            # Only add initializer if lambda looks additive and not multiplicative
            lam_s = lam.replace(" ", "")
            if "+" in lam_s and "*" not in lam_s:
                it_s = it.strip()
                # Heuristics:
                # - If iterable looks like range(...), treat as numeric -> 0
                # - If solve takes a str arg and iterable is that arg, use empty string initializer
                # - Else, default to 0 as safe numeric initializer
                if it_s.startswith("range("):
                    return f"reduce({lam}, {it}, 0)"
                if str_arg and (it_s == str_arg):
                    return f"reduce({lam}, {it}, \"\")"
                return f"reduce({lam}, {it}, 0)"
            return m.group(0)
        new_txt = pat.sub(repl, txt)
        return new_txt
    except Exception:
        return code_text

def _ledger_path() -> Path:
    try:
        return data_path("selfplay", "ledger.md")
    except Exception:
        return Path("ledger.md")

def _ledger_append(line: str) -> None:
    try:
        p = _ledger_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        with p.open("a", encoding="utf-8") as f:
            f.write(line.rstrip() + "\n")
    except Exception:
        pass

def _ledger_tail(n: int = 20) -> List[str]:
    try:
        p = _ledger_path()
        if not p.exists():
            return []
        lines = p.read_text(encoding="utf-8").splitlines()[-max(1, int(n)) :]
        return [ln for ln in lines if ln.strip()][: max(1, int(n))]
    except Exception:


                # - Else, default to 0 as safe numeric initializer
                if it_s.startswith("range("):
                    return f"reduce({lam}, {it}, 0)"
                if str_arg and (it_s == str_arg):
                    return f"reduce({lam}, {it}, \"\")"
                return f"reduce({lam}, {it}, 0)"
            return m.group(0)
        new_txt = pat.sub(repl, txt)
        return new_txt
    except Exception:
        return code_text

def _ledger_path() -> Path:
    try:
        return data_path("selfplay", "ledger.md")
    except Exception:
        return Path("ledger.md")

def _ledger_append(line: str) -> None:
    try:
        p = _ledger_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        with p.open("a", encoding="utf-8") as f:
            f.write(line.rstrip() + "\n")
    except Exception:
        pass

def _ledger_tail(n: int = 20) -> List[str]:
    try:
        p = _ledger_path()
        if not p.exists():
            return []
        lines = p.read_text(encoding="utf-8").splitlines()[-max(1, int(n)) :]
        return [ln for ln in lines if ln.strip()][: max(1, int(n))]
    except Exception:
        return []

def _negatives_path() -> Path:
    try:
        return data_path("selfplay", "negatives.jsonl")
    except Exception:
        return Path("negatives.jsonl")

def _normalize_failure(text: Any, max_len: int = 120) -> str:


        return code_text

def _ledger_path() -> Path:
    try:
        return data_path("selfplay", "ledger.md")
    except Exception:
        return Path("ledger.md")

def _ledger_append(line: str) -> None:
    try:
        p = _ledger_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        with p.open("a", encoding="utf-8") as f:
            f.write(line.rstrip() + "\n")
    except Exception:
        pass

def _ledger_tail(n: int = 20) -> List[str]:
    try:
        p = _ledger_path()
        if not p.exists():
            return []
        lines = p.read_text(encoding="utf-8").splitlines()[-max(1, int(n)) :]
        return [ln for ln in lines if ln.strip()][: max(1, int(n))]
    except Exception:
        return []

def _negatives_path() -> Path:
    try:
        return data_path("selfplay", "negatives.jsonl")
    except Exception:
        return Path("negatives.jsonl")

def _normalize_failure(text: Any, max_len: int = 120) -> str:
    try:
        s = str(text or "").strip()
        return s[: max_len]
    except Exception:
        return ""

def _write_negative_example(task: str, prompt: str, body: str, rep: Dict[str, Any], policy_reason: Optional[str]) -> None:
    try:
        fam = _normalize_failure((rep or {}).get("first_failure")) or str(policy_reason or "other")


def _ledger_append(line: str) -> None:
    try:
        p = _ledger_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        with p.open("a", encoding="utf-8") as f:
            f.write(line.rstrip() + "\n")
    except Exception:
        pass

def _ledger_tail(n: int = 20) -> List[str]:
    try:
        p = _ledger_path()
        if not p.exists():
            return []
        lines = p.read_text(encoding="utf-8").splitlines()[-max(1, int(n)) :]
        return [ln for ln in lines if ln.strip()][: max(1, int(n))]
    except Exception:
        return []

def _negatives_path() -> Path:
    try:
        return data_path("selfplay", "negatives.jsonl")
    except Exception:
        return Path("negatives.jsonl")

def _normalize_failure(text: Any, max_len: int = 120) -> str:
    try:
        s = str(text or "").strip()
        return s[: max_len]
    except Exception:
        return ""

def _write_negative_example(task: str, prompt: str, body: str, rep: Dict[str, Any], policy_reason: Optional[str]) -> None:
    try:
        fam = _normalize_failure((rep or {}).get("first_failure")) or str(policy_reason or "other")
        obj = {
            "task": str(task or ""),
            "family": fam,
            "prompt_head": (prompt or "")[:1000],
            "body_head": (body or "")[:1000],
            "rep": {k: rep.get(k) for k in ("passed", "total", "timeouts", "first_failure", "elapsed_ms") if isinstance(rep, dict)},


        pass

def _ledger_tail(n: int = 20) -> List[str]:
    try:
        p = _ledger_path()
        if not p.exists():
            return []
        lines = p.read_text(encoding="utf-8").splitlines()[-max(1, int(n)) :]
        return [ln for ln in lines if ln.strip()][: max(1, int(n))]
    except Exception:
        return []

def _negatives_path() -> Path:
    try:
        return data_path("selfplay", "negatives.jsonl")
    except Exception:
        return Path("negatives.jsonl")

def _normalize_failure(text: Any, max_len: int = 120) -> str:
    try:
        s = str(text or "").strip()
        return s[: max_len]
    except Exception:
        return ""

def _write_negative_example(task: str, prompt: str, body: str, rep: Dict[str, Any], policy_reason: Optional[str]) -> None:
    try:
        fam = _normalize_failure((rep or {}).get("first_failure")) or str(policy_reason or "other")
        obj = {
            "task": str(task or ""),
            "family": fam,
            "prompt_head": (prompt or "")[:1000],
            "body_head": (body or "")[:1000],
            "rep": {k: rep.get(k) for k in ("passed", "total", "timeouts", "first_failure", "elapsed_ms") if isinstance(rep, dict)},
        }
        p = _negatives_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        with p.open("a", encoding="utf-8") as f:
            f.write(json.dumps(obj, ensure_ascii=False) + "\n")
    except Exception:
        pass


        return [ln for ln in lines if ln.strip()][: max(1, int(n))]
    except Exception:
        return []

def _negatives_path() -> Path:
    try:
        return data_path("selfplay", "negatives.jsonl")
    except Exception:
        return Path("negatives.jsonl")

def _normalize_failure(text: Any, max_len: int = 120) -> str:
    try:
        s = str(text or "").strip()
        return s[: max_len]
    except Exception:
        return ""

def _write_negative_example(task: str, prompt: str, body: str, rep: Dict[str, Any], policy_reason: Optional[str]) -> None:
    try:
        fam = _normalize_failure((rep or {}).get("first_failure")) or str(policy_reason or "other")
        obj = {
            "task": str(task or ""),
            "family": fam,
            "prompt_head": (prompt or "")[:1000],
            "body_head": (body or "")[:1000],
            "rep": {k: rep.get(k) for k in ("passed", "total", "timeouts", "first_failure", "elapsed_ms") if isinstance(rep, dict)},
        }
        p = _negatives_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        with p.open("a", encoding="utf-8") as f:
            f.write(json.dumps(obj, ensure_ascii=False) + "\n")
    except Exception:
        pass

def _sample_family_negative(family: str) -> Optional[str]:
    try:
        p = _negatives_path()
        if not p.exists():
            return None
        import random as _r  # type: ignore
        bodies: List[str] = []
        for ln in p.read_text(encoding="utf-8").splitlines()[-1000:]:
            try:
                obj = json.loads(ln)
            except Exception:
                continue
            if str(obj.get("family", "")) == str(family or ""):
                b = str(obj.get("body_head", ""))
                if b:
                    bodies.append(b)
        return _r.choice(bodies) if bodies else None
    except Exception:


def _write_negative_example(task: str, prompt: str, body: str, rep: Dict[str, Any], policy_reason: Optional[str]) -> None:
    try:
        fam = _normalize_failure((rep or {}).get("first_failure")) or str(policy_reason or "other")
        obj = {
            "task": str(task or ""),
            "family": fam,
            "prompt_head": (prompt or "")[:1000],
            "body_head": (body or "")[:1000],
            "rep": {k: rep.get(k) for k in ("passed", "total", "timeouts", "first_failure", "elapsed_ms") if isinstance(rep, dict)},
        }
        p = _negatives_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        with p.open("a", encoding="utf-8") as f:
            f.write(json.dumps(obj, ensure_ascii=False) + "\n")
    except Exception:
        pass

def _sample_family_negative(family: str) -> Optional[str]:
    try:
        p = _negatives_path()
        if not p.exists():
            return None
        import random as _r  # type: ignore
        bodies: List[str] = []
        for ln in p.read_text(encoding="utf-8").splitlines()[-1000:]:
            try:
                obj = json.loads(ln)
            except Exception:
                continue
            if str(obj.get("family", "")) == str(family or ""):
                b = str(obj.get("body_head", ""))
                if b:
                    bodies.append(b)
        return _r.choice(bodies) if bodies else None
    except Exception:
        return None

def _maybe_inject_structured(prompt_text: str, task_name: str, cfg: Dict[str, Any]) -> str:
    try:
        use_struct = str(os.environ.get("SELFPLAY_LEDGER_IN_PROMPT", "0")).strip() not in ("", "0", "false", "False")
    except Exception:
        use_struct = False
    if not use_struct:
        # Optional WM/P/A/V/U scaffold (disabled by default)
        try:
            use_wmpavu = str(os.environ.get("SELFPLAY_STRUCT_WMPAVU", "0")).strip() not in ("", "0", "false", "False")
        except Exception:
            use_wmpavu = False
        if not use_wmpavu:
            return prompt_text
        try:
            # Add commented scaffold so it won't pollute code output
            parts = [prompt_text]


        if not p.exists():
            return None
        import random as _r  # type: ignore
        bodies: List[str] = []
        for ln in p.read_text(encoding="utf-8").splitlines()[-1000:]:
            try:
                obj = json.loads(ln)
            except Exception:
                continue
            if str(obj.get("family", "")) == str(family or ""):
                b = str(obj.get("body_head", ""))
                if b:
                    bodies.append(b)
        return _r.choice(bodies) if bodies else None
    except Exception:
        return None

def _maybe_inject_structured(prompt_text: str, task_name: str, cfg: Dict[str, Any]) -> str:
    try:
        use_struct = str(os.environ.get("SELFPLAY_LEDGER_IN_PROMPT", "0")).strip() not in ("", "0", "false", "False")
    except Exception:
        use_struct = False
    if not use_struct:
        # Optional WM/P/A/V/U scaffold (disabled by default)
        try:
            use_wmpavu = str(os.environ.get("SELFPLAY_STRUCT_WMPAVU", "0")).strip() not in ("", "0", "false", "False")
        except Exception:
            use_wmpavu = False
        if not use_wmpavu:
            return prompt_text
        try:
            # Add commented scaffold so it won't pollute code output
            parts = [prompt_text]
            parts.append(
                "\n" + "\n".join([
                    "# WM: Short working memory bullets for constraints or hints",
                    "# P: High-level plan (2-3 steps max)",
                    "# A: Key actions/transforms to apply to reach the solution",
                    "# V: Self-checks or unit-test-like invariants to verify",
                    "# U: If uncertain, simplify or fall back conservatively",
                ]) + "\n"
            )
            return "".join(parts)
        except Exception:
            return prompt_text
    try:
        ledger_lines = _ledger_tail(int(os.environ.get("SELFPLAY_LEDGER_TAIL", "12") or 12))
        ledger_block = "\n".join(["# LEDGER " + ln for ln in ledger_lines]) if ledger_lines else ""
        stats = {}
        try:
            stats = task_bank_stats(task_name)
        except Exception:
            stats = {}
        try:
            temp = float(os.environ.get("SAMPLING_TEMPERATURE", cfg.get("temperature", 0.2) or 0.2))
        except Exception:
            temp = 0.2
        assim = stats.get("assim_ema")
        assim_n = stats.get("assim_n")
        stats_lines = [
            f"# STATS task={task_name} n={stats.get('n', 0)} wins={stats.get('wins', 0)} ucb={round(float(stats.get('ucb', 0.0) or 0.0), 3)}",
            f"# STATS assim_ema={round(float(assim or 0.0),3)} assim_n={int(assim_n or 0)}",
            f"# STATS n_samples={int(cfg.get('n_samples', 8))} temp={temp} wm_rank={bool(cfg.get('wm_plan_rank', False))}",
        ]
        stats_block = "\n".join(stats_lines)
        parts = [prompt_text]
        if ledger_block:
            parts.append("\n" + ledger_block)
        parts.append("\n" + stats_block + "\n")
        # Optional WM/P/A/V/U scaffold coupled with ledger
        try:
            use_wmpavu = str(os.environ.get("SELFPLAY_STRUCT_WMPAVU", "0")).strip() not in ("", "0", "false", "False")
        except Exception:
            use_wmpavu = False
        if use_wmpavu:
            parts.append(
                "".join([
                    "# WM: Short working memory bullets for constraints or hints\n",
                    "# P: High-level plan (2-3 steps max)\n",
                    "# A: Key actions/transforms to apply to reach the solution\n",
                    "# V: Self-checks or unit-test-like invariants to verify\n",
                    "# U: If uncertain, simplify or fall back conservatively\n",
                ])
            )
        return "".join(parts)
    except Exception:
        return prompt_text

def episode_loop(cfg: Dict[str, Any]) -> None:
    # Default-enable AGI features: meta, WM prior, approval, meta-optimizer
    try:
        if cfg.get("apply_meta") is None:
            cfg["apply_meta"] = True
        if cfg.get("meta_state") is None:
            cfg["meta_state"] = str(data_path("sandbox", "tmp", "meta_state.json"))
        if cfg.get("wm_plan_rank") is None:
            cfg["wm_plan_rank"] = True
        if not cfg.get("wm_model"):
            cfg["wm_model"] = _resolve_best_wm_model_path()
        if cfg.get("require_approval") is None:
            cfg["require_approval"] = False
        if bool(cfg.get("enable_metaopt", True)):
            os.environ.setdefault("AGI_METAOPT", "1")
        # Default-enable optional features unless explicitly disabled by env/cfg
        os.environ.setdefault("SELFPLAY_ENABLE_MODEL_TASKGEN", "1")
        os.environ.setdefault("SELFPLAY_TASK_EXPLORE_P", "0.6")
        os.environ.setdefault("SELFPLAY_LEDGER_IN_PROMPT", "0")


        parts.append("\n" + stats_block + "\n")
        # Optional WM/P/A/V/U scaffold coupled with ledger
        try:
            use_wmpavu = str(os.environ.get("SELFPLAY_STRUCT_WMPAVU", "0")).strip() not in ("", "0", "false", "False")
        except Exception:
            use_wmpavu = False
        if use_wmpavu:
            parts.append(
                "".join([
                    "# WM: Short working memory bullets for constraints or hints\n",
                    "# P: High-level plan (2-3 steps max)\n",
                    "# A: Key actions/transforms to apply to reach the solution\n",
                    "# V: Self-checks or unit-test-like invariants to verify\n",
                    "# U: If uncertain, simplify or fall back conservatively\n",
                ])
            )
        return "".join(parts)
    except Exception:
        return prompt_text

def episode_loop(cfg: Dict[str, Any]) -> None:
    # Default-enable AGI features: meta, WM prior, approval, meta-optimizer
    try:
        if cfg.get("apply_meta") is None:
            cfg["apply_meta"] = True
        if cfg.get("meta_state") is None:
            cfg["meta_state"] = str(data_path("sandbox", "tmp", "meta_state.json"))
        if cfg.get("wm_plan_rank") is None:
            cfg["wm_plan_rank"] = True
        if not cfg.get("wm_model"):
            cfg["wm_model"] = _resolve_best_wm_model_path()
        if cfg.get("require_approval") is None:
            cfg["require_approval"] = False
        if bool(cfg.get("enable_metaopt", True)):
            os.environ.setdefault("AGI_METAOPT", "1")
        # Default-enable optional features unless explicitly disabled by env/cfg
        os.environ.setdefault("SELFPLAY_ENABLE_MODEL_TASKGEN", "1")
        os.environ.setdefault("SELFPLAY_TASK_EXPLORE_P", "0.6")
        os.environ.setdefault("SELFPLAY_LEDGER_IN_PROMPT", "0")
        os.environ.setdefault("SELFPLAY_USE_VERIFIER_PRECHECK", "1")
        os.environ.setdefault("SELFPLAY_USE_VERIFIER", "1")
        os.environ.setdefault("SELFPLAY_ADAPTIVE_MODE", "1")
        # Unified enable-all (default on): turn on decode policies unless explicitly disabled
        os.environ.setdefault("SELFPLAY_ENABLE_ALL", "1")
        try:
            _ena_all = str(os.environ.get("SELFPLAY_ENABLE_ALL", "1")).strip().lower() not in ("", "0", "false", "no", "off")
        except Exception:
            _ena_all = True
        if _ena_all:
            os.environ.setdefault("SELFPLAY_BEAM_VERIFY", "1")
            os.environ.setdefault("SELFPLAY_SPECULATIVE", "1")
            os.environ.setdefault("SELFPLAY_EARLY_EXIT", "1")
        # Lower-cost defaults for subset precheck and per-test timeout to reduce CPU load
        os.environ.setdefault("SELFPLAY_PRECHECK_K", "2")
        os.environ.setdefault("SELFPLAY_TEST_TIMEOUT_SEC", "1.0")
        # Healing default on
        if cfg.get("heal_mode") is None:
            cfg["heal_mode"] = True
        # Code memory default off unless explicitly enabled
        if cfg.get("use_code_memory") is None:
            cfg["use_code_memory"] = False
        # Gradient-free assimilation mode default OFF unless explicitly enabled via env
        if cfg.get("gradient_free") is None:
            try:
                _gf = os.environ.get("SELFPLAY_GRADIENT_FREE")
                if _gf is None or str(_gf).strip() == "":
                    cfg["gradient_free"] = False
                else:
                    cfg["gradient_free"] = (str(_gf).strip().lower() not in ("0", "false", "no", "off"))
            except Exception:
                cfg["gradient_free"] = False
        # Inner/outer loop defaults for gradient-free reasoning
        try:
            if cfg.get("inner_n") is None:
                cfg["inner_n"] = int(os.environ.get("SELFPLAY_INNER_N", "3") or 3)
            if cfg.get("budget_K") is None:
                cfg["budget_K"] = int(os.environ.get("SELFPLAY_BUDGET_K", "3") or 3)
            if cfg.get("tol") is None:
                cfg["tol"] = float(os.environ.get("SELFPLAY_TOL", "1e-3") or 1e-3)
            if cfg.get("min_steps") is None:
                cfg["min_steps"] = int(os.environ.get("SELFPLAY_MIN_STEPS", "1") or 1)
            if cfg.get("patience") is None:
                cfg["patience"] = int(os.environ.get("SELFPLAY_PATIENCE", "1") or 1)
            if cfg.get("ema_alpha") is None:
                cfg["ema_alpha"] = float(os.environ.get("SELFPLAY_EMA_ALPHA", "0.3") or 0.3)
        except Exception:
            pass
    except Exception:
        pass
    # Initialize default safelist for import approval if enabled and list empty
    try:
        if bool(cfg.get("require_import_approval", False)):
            approved = _load_approved_imports()
            if not approved:
                safelist = {
                    "functools","collections","itertools","math","heapq","bisect","operator","statistics","typing"
                }
                _save_approved_imports(set(safelist))
    except Exception:
        pass
    # Optional: apply meta-controller state (ToT, temperature, budgets) like dev loop
    if bool(cfg.get("apply_meta", False)):
        try:
            mpath = str(cfg.get("meta_state", str(data_path("sandbox", "tmp", "meta_state.json"))))
            m = json.loads(Path(mpath).read_text(encoding="utf-8"))
            os.environ["PLANNER_TOT"] = "1" if bool(m.get("enable_tot", False)) else "0"
            os.environ["SAMPLING_TEMPERATURE"] = str(float(m.get("sampling_temperature", 0.2)))
            os.environ["PLANNER_STEP_BUDGET"] = str(int(m.get("planner_step_budget", 1)))
        except Exception:
            pass
    # Optional CodeMemory integration (episodic/code memory)
    code_mem = None
    code_mem_dir = None
    try:
        if bool(cfg.get("use_code_memory", False)):
            from agi_dw.core.memory.code_memory import CodeMemory  # type: ignore
            code_mem_dir = str(cfg.get("code_memory_dir", str(data_path("memory"))))
            code_mem = CodeMemory.load(code_mem_dir)
    except Exception:
        code_mem = None
        code_mem_dir = None
    tok, model = load_seed(cfg)
    # Optionally load adapter weights from checkpoint path if present (persist improvements across runs)
    try:
        ckpt_p = str(cfg.get("ckpt_path", "") or "").strip()
        if ckpt_p and Path(ckpt_p).exists():
            try:
                _ = load_adapters(model, ckpt_p)
            except Exception:
                pass
    except Exception:
        pass
    # Log trainable parameter count and adapter rank once
    try:
        trainable_params = int(sum(int(p.numel()) for p in model.parameters() if p.requires_grad))
    except Exception:
        trainable_params = -1
    adapter_r = None
    try:
        for _n, _m in model.named_modules():
            if isinstance(_m, torch.nn.Linear) and hasattr(_m, "A"):
                adapter_r = int(getattr(_m, "A").shape[0])  # type: ignore[attr-defined]
                break
    except Exception:
        adapter_r = None
    # Optional World Model prior for reranking
    wm_prior = None
    try:
        wm_path = str(cfg.get("wm_model", "") or "").strip()
        if wm_path and Path(wm_path).exists():
            from agi_dw.core.world_model.api import WorldModelPrior  # type: ignore
            wm_prior = WorldModelPrior.load(wm_path)
    except Exception:
        wm_prior = None
    try:
        default_mem_path = str(data_path("selfplay", "progress.jsonl"))
    except Exception:
        default_mem_path = "progress.jsonl"
    mem = Memory(cfg.get("mem_path", default_mem_path))
    try:
        mem.log({"init": True, "trainable_params": trainable_params, "adapter_r": adapter_r})
    except Exception:
        pass
    curr = Curriculum()
    # Seed control (reproducibility)
    try:
        seed = cfg.get("seed")
        if seed is None:
            v = os.environ.get("SELFPLAY_SEED")
            seed = int(v) if v is not None and str(v).strip() != "" else None
        if seed is not None:
            s = int(seed)
            import random as _rnd  # type: ignore
            _rnd.seed(s)
            try:
                import numpy as _np  # type: ignore
                _np.random.seed(s)
            except Exception:
                pass
            try:
                torch.manual_seed(s)
                if torch.cuda.is_available():
                    torch.cuda.manual_seed_all(s)
            except Exception:
                pass
    except Exception:
        pass
    step = 0
    dev_fail_count = 0
    last_pass_ratio: Optional[float] = None
    max_steps = int(cfg.get("max_steps", int(os.environ.get("SELFPLAY_MAX_STEPS", "0") or 0)))
    stop_file = str(cfg.get("stop_file", str(data_path("sandbox", "tmp", "selfplay.stop"))))
    # Evolution mode: run evolutionary self-play loop, then return
    try:
        if bool(cfg.get("evolution_mode", False)):
            evolution_loop(tok, model, mem, cfg)
            return
    except Exception:
        pass
    # task bank and summary helpers moved to tasks_bank.py

    # Track recent tasks to avoid immediate repeats
    try:
        _cooldown_n = int(os.environ.get("SELFPLAY_TASK_COOLDOWN", "5") or 5)
    except Exception:
        _cooldown_n = 5
    recent_tasks = deque(maxlen=max(1, _cooldown_n))
    while True:
        # Optional retention probe: periodically replay older tasks without training
        try:
            ret_every = int(os.environ.get("SELFPLAY_RETENTION_EVERY", "0") or 0)
        except Exception:
            ret_every = 0
        if ret_every > 0 and step > 0 and (step % ret_every == 0):
            try:
                bank = task_bank_load()
                old = [v for v in bank.values() if isinstance(v.get("spec"), dict)]
                if old:
                    old_sorted = sorted(old, key=lambda x: int(x.get("last_ts", 0)))
                    spec = old_sorted[0]["spec"]
                    base_prompt = render_prompt(spec)
                    task_name = str(spec.get("name", ""))
                    ptxt = base_prompt
                    # Optional Memory Service augmentation for retention probes
                    try:
                        if bool(cfg.get("use_memory", False)):
                            try:
                                from agi_dw.core.memory.service import MemoryAugmentConfig, augment_observation  # type: ignore
                                mem_cfg = MemoryAugmentConfig(
                                    use_memory=True,
                                    mem_path=str(cfg.get("episodic_mem_path", "") or "") or None,
                                    mem_topk=int(cfg.get("episodic_mem_topk", 3)),
                                    mem_recency=float(cfg.get("episodic_mem_recency", 0.0)),
                                    mem_query=str(cfg.get("episodic_mem_query", "") or "") or None,
                                    index_k=int(cfg.get("index_k", 0)),
                                    index_path=str(cfg.get("index_path", "") or "") or None,
                                )
                                obs = {"kind": "code", "content": base_prompt, "meta": {}}
                                root_dir = Path(__file__).resolve().parents[3]
                                obs_aug, _, _ = augment_observation(obs, root_dir, mem_cfg)
                                mem_lines: List[str] = []
                                for txt in (obs_aug.get("memory", []) if isinstance(obs_aug, dict) else []):
                                    if not txt:
                                        continue
                                    for ln in str(txt).splitlines()[:40]:
                                        mem_lines.append("# MEM " + ln)
                                if mem_lines:
                                    ptxt = base_prompt + "\n\n" + "\n".join(mem_lines) + "\n"
                            except Exception:
                                ptxt = base_prompt
                    except Exception:
                        ptxt = base_prompt
                    try:
                        ptxt = _maybe_inject_structured(ptxt, task_name, cfg)
                    except Exception:
                        pass
                    # One-shot probe, small n to keep cost low
                    cands = sample(tok, model, ptxt, n=1, max_new_tokens=int(cfg.get("max_tokens", 256)), stop=["```"])
                    ok_r, rep_r = (False, {"passed": 0, "total": len(spec.get("tests", []))})
                    if cands:
                        code0 = cands[0]
                        # Syntax precheck
                        try:
                            _ = ast.parse(code0.lstrip("\ufeff"))
                            syn_ok = True
                        except Exception:
                            syn_ok = False
                        if syn_ok:
                            ok_r, rep_r = run_and_test(code0, spec)
                    mem.log({
                        "step": step,
                        "mode": "retention",
                        "level": curr.level,
                        "task": task_name,
                        "status": ("pass" if ok_r else "fail"),
                        "rep": rep_r,
                    })
                    step += 1
                    # Proceed to next iteration; do not train or adjust curriculum from retention
                    continue
            except Exception:
                pass
        level_before = curr.level
        if cfg.get("dev_repo"):
            # Ensure local repo exists when using default/specified local path
            try:
                repo_uri = str(cfg.get("dev_repo"))
                repo_uri = _ensure_local_repo(repo_uri)
                cfg["dev_repo"] = repo_uri
            except Exception:
                pass
            # Compose dev loop args with optional WM/approval flags
            user_args = str(cfg.get("dev_args", "") or "").strip()
            glue: List[str] = []
            try:
                if bool(cfg.get("wm_plan_rank", False)):
                    glue.append("--wm-plan-rank")
                wm_model = str(cfg.get("wm_model", "") or "").strip()
                if wm_model:
                    glue.extend(["--wm-model", wm_model])
                if bool(cfg.get("require_approval", False)):
                    glue.append("--require-approval")
            except Exception:
                pass
            extra = " ".join([t for t in [user_args, " ".join(glue)] if t]).strip()
            # Fallback logic: if repo is not usable, skip to code-body this step
            repo_uri = str(cfg.get("dev_repo"))
            if not _is_local_repo_usable(repo_uri):
                ok = False
                info = {"returncode": 2, "stdout": "", "stderr": "repo_unusable"}
            else:
                ok, info = _dev_episode(str(cfg["dev_repo"]), extra_args=extra)
            # Capture first non-empty stderr line for easier debugging
            try:
                stderr_txt = str(info.get("stderr", "") or "")
                first_stderr_line = next((ln for ln in stderr_txt.splitlines() if ln.strip()), "")
            except Exception:
                first_stderr_line = ""
            mem.log({
                "step": step,
                "mode": "dev",
                "level": level_before,
                "status": ("pass" if ok else "fail"),
                "info": {
                    "rc": info.get("returncode", 0),
                    "stdout_tail": info.get("stdout", "")[-400:],
                    "stderr_tail": info.get("stderr", "")[-200:],
                    "stderr_head": first_stderr_line,
                },
            })
            # Simple reward signal: 1.0 on success, else 0.0
            curr.observe(pass1=bool(ok), score=(1.0 if ok else 0.0))
            # Track failures to trigger temporary fallback to code-body
            if ok:
                dev_fail_count = 0
                # proceed; skip fallback
            else:
                dev_fail_count += 1
                # If repo unusable or consecutive failures exceed 3, run one code-body episode this step
                if (not _is_local_repo_usable(repo_uri)) or (dev_fail_count >= 3):
                    # Backoff: temporarily reduce curriculum level to stabilize
                    try:
                        if curr.level > 0:
                            curr.level -= 1
                            curr._since_change = 0
                    except Exception:
                        pass
                    # Run one code-body episode immediately to keep progress moving
                    spec = sample_task(curr.level)
                    base_prompt = render_prompt(spec)
                    task_name = str(spec.get("name", ""))
                    ptxt = base_prompt
                    try:
                        if bool(cfg.get("use_memory", False)):
                            from agi_dw.core.memory.service import MemoryAugmentConfig, augment_observation  # type: ignore
                            mem_cfg = MemoryAugmentConfig(use_memory=True, mem_path=str(cfg.get("episodic_mem_path", "") or "") or None, mem_topk=int(cfg.get("episodic_mem_topk", 3)), mem_recency=float(cfg.get("episodic_mem_recency", 0.0)), mem_query=str(cfg.get("episodic_mem_query", "") or "") or None, index_k=int(cfg.get("index_k", 0)), index_path=str(cfg.get("index_path", "") or "") or None)
                            obs = {"kind": "code", "content": base_prompt, "meta": {}}
                            root_dir = Path(__file__).resolve().parents[3]
                            obs_aug, _, _ = augment_observation(obs, root_dir, mem_cfg)
                            mem_lines: List[str] = []
                            for txt in (obs_aug.get("memory", []) if isinstance(obs_aug, dict) else []):
                                if not txt:
                                    continue
                                for ln in str(txt).splitlines()[:40]:
                                    mem_lines.append("# MEM " + ln)
                            if mem_lines:
                                ptxt = base_prompt + "\n\n" + "\n".join(mem_lines) + "\n"
                    except Exception:
                        ptxt = base_prompt
                    # Optional structured injection (ledger + stats)
                    try:
                        ptxt = _maybe_inject_structured(ptxt, task_name, cfg)
                    except Exception:
                        pass
                    cands = sample(tok, model, ptxt, n=int(cfg.get("n_samples", 8)), max_new_tokens=int(cfg.get("max_tokens", 256)), stop=["```"])
                    results: List[Tuple[str, str, Dict[str, Any]]] = []
                    # Telemetry counters
                    total = len(cands)
                    rejected = 0
                    evaluated = 0
                    timeouts = 0
                    prompt_head_fb = (ptxt or "")
                    for idx, code in enumerate(cands):
                        # Prefilter: allow body-only or fenced code; only reject truly empty snippets
                        try:
                            first_line = next((ln for ln in (code or "").splitlines() if ln.strip()), "")
                        except Exception:
                            first_line = ""
                        if not first_line:
                            rejected += 1
                            results.append(("reject", code, {"reason": "empty_code", "score": 0.0}))
                            try:
                                mem.log({
                                    "step": step,
                                    "mode": "arena",
                                    "cand": int(idx),
                                    "status": "reject",
                                    "policy_reason": "empty_code",
                                    "prompt_head": prompt_head_
# ... truncated ...


            save_adapters(model, cfg.get("ckpt_path", "adapters.pt"))
        # Telemetry-only: append verifier/wm summary to last mem log
        try:
            pass  # mem.log already includes scores; richer telemetry could be added here
        except Exception:
            pass
        step += 1
        # Graceful stop conditions
        try:
            if max_steps > 0 and step >= max_steps:
                break
            if stop_file and Path(stop_file).exists():
                break
        except Exception:
            pass

if __name__ == "__main__":
    # Allow environment overrides for key knobs so Make can scale without code edits
    def _env_int(name: str, default: int) -> int:
        try:
            v = os.environ.get(name)
            return int(v) if v is not None and str(v).strip() != "" else int(default)
        except Exception:
            return int(default)

    def _env_float(name: str, default: float) -> float:
        try:
            v = os.environ.get(name)
            return float(v) if v is not None and str(v).strip() != "" else float(default)
        except Exception:
            return float(default)

    def _env_str(name: str, default: str) -> str:
        v = os.environ.get(name)
        return str(v) if v is not None and str(v).strip() != "" else str(default)

    def _env_bool(name: str, default: bool) -> bool:
        v = os.environ.get(name)
        if v is None:
            return bool(default)
        s = str(v).strip().lower()
        return s in ("1", "true", "yes", "y", "on")

    cfg = {


        # Graceful stop conditions
        try:
            if max_steps > 0 and step >= max_steps:
                break
            if stop_file and Path(stop_file).exists():
                break
        except Exception:
            pass

if __name__ == "__main__":
    # Allow environment overrides for key knobs so Make can scale without code edits
    def _env_int(name: str, default: int) -> int:
        try:
            v = os.environ.get(name)
            return int(v) if v is not None and str(v).strip() != "" else int(default)
        except Exception:
            return int(default)

    def _env_float(name: str, default: float) -> float:
        try:
            v = os.environ.get(name)
            return float(v) if v is not None and str(v).strip() != "" else float(default)
        except Exception:
            return float(default)

    def _env_str(name: str, default: str) -> str:
        v = os.environ.get(name)
        return str(v) if v is not None and str(v).strip() != "" else str(default)

    def _env_bool(name: str, default: bool) -> bool:
        v = os.environ.get(name)
        if v is None:
            return bool(default)
        s = str(v).strip().lower()
        return s in ("1", "true", "yes", "y", "on")

    cfg = {
        "model_name": _env_str("SELFPLAY_MODEL", "meta-llama/Llama-3.1-8B-Instruct"),
        "n_samples": _env_int("SELFPLAY_N_SAMPLES", 8),
        "max_tokens": _env_int("SELFPLAY_MAX_TOKENS", 256),
        "lr": _env_float("SELFPLAY_LR", 1e-4),
        "train_steps": _env_int("SELFPLAY_TRAIN_STEPS", 10),
        "neg_steps": _env_int("SELFPLAY_NEG_STEPS", 3),
        "ckpt_every": _env_int("SELFPLAY_CKPT_EVERY", 200),


            pass

if __name__ == "__main__":
    # Allow environment overrides for key knobs so Make can scale without code edits
    def _env_int(name: str, default: int) -> int:
        try:
            v = os.environ.get(name)
            return int(v) if v is not None and str(v).strip() != "" else int(default)
        except Exception:
            return int(default)

    def _env_float(name: str, default: float) -> float:
        try:
            v = os.environ.get(name)
            return float(v) if v is not None and str(v).strip() != "" else float(default)
        except Exception:
            return float(default)

    def _env_str(name: str, default: str) -> str:
        v = os.environ.get(name)
        return str(v) if v is not None and str(v).strip() != "" else str(default)

    def _env_bool(name: str, default: bool) -> bool:
        v = os.environ.get(name)
        if v is None:
            return bool(default)
        s = str(v).strip().lower()
        return s in ("1", "true", "yes", "y", "on")

    cfg = {
        "model_name": _env_str("SELFPLAY_MODEL", "meta-llama/Llama-3.1-8B-Instruct"),
        "n_samples": _env_int("SELFPLAY_N_SAMPLES", 8),
        "max_tokens": _env_int("SELFPLAY_MAX_TOKENS", 256),
        "lr": _env_float("SELFPLAY_LR", 1e-4),
        "train_steps": _env_int("SELFPLAY_TRAIN_STEPS", 10),
        "neg_steps": _env_int("SELFPLAY_NEG_STEPS", 3),
        "ckpt_every": _env_int("SELFPLAY_CKPT_EVERY", 200),
        "ckpt_path": _env_str("SELFPLAY_CKPT_PATH", "adapters.pt"),
        "adapter_r": _env_int("SELFPLAY_ADAPTER_R", 8),
        "adapter_alpha": _env_int("SELFPLAY_ADAPTER_ALPHA", 16),
        # Optional dev-loop mode: default to arena (no dev_repo unless provided via env)


if __name__ == "__main__":
    # Allow environment overrides for key knobs so Make can scale without code edits
    def _env_int(name: str, default: int) -> int:
        try:
            v = os.environ.get(name)
            return int(v) if v is not None and str(v).strip() != "" else int(default)
        except Exception:
            return int(default)

    def _env_float(name: str, default: float) -> float:
        try:
            v = os.environ.get(name)
            return float(v) if v is not None and str(v).strip() != "" else float(default)
        except Exception:
            return float(default)

    def _env_str(name: str, default: str) -> str:
        v = os.environ.get(name)
        return str(v) if v is not None and str(v).strip() != "" else str(default)

    def _env_bool(name: str, default: bool) -> bool:
        v = os.environ.get(name)
        if v is None:
            return bool(default)
        s = str(v).strip().lower()
        return s in ("1", "true", "yes", "y", "on")

    cfg = {
        "model_name": _env_str("SELFPLAY_MODEL", "meta-llama/Llama-3.1-8B-Instruct"),
        "n_samples": _env_int("SELFPLAY_N_SAMPLES", 8),
        "max_tokens": _env_int("SELFPLAY_MAX_TOKENS", 256),
        "lr": _env_float("SELFPLAY_LR", 1e-4),
        "train_steps": _env_int("SELFPLAY_TRAIN_STEPS", 10),
        "neg_steps": _env_int("SELFPLAY_NEG_STEPS", 3),
        "ckpt_every": _env_int("SELFPLAY_CKPT_EVERY", 200),
        "ckpt_path": _env_str("SELFPLAY_CKPT_PATH", "adapters.pt"),
        "adapter_r": _env_int("SELFPLAY_ADAPTER_R", 8),
        "adapter_alpha": _env_int("SELFPLAY_ADAPTER_ALPHA", 16),
        # Optional dev-loop mode: default to arena (no dev_repo unless provided via env)
        "dev_repo": (_env_str("SELFPLAY_DEV_REPO", "").strip() or None),
        "dev_args": _env_str("SELFPLAY_DEV_ARGS", ""),
        # Evolution mode and parameters
        "evolution_mode": _env_bool("SELFPLAY_EVOLUTION_MODE", False),
        "evo_num_agents": _env_int("SELFPLAY_EVO_NUM_AGENTS", 5),
        "evo_num_generations": _env_int("SELFPLAY_EVO_NUM_GENERATIONS", 10),
        "evo_top_k_survivors": _env_int("SELFPLAY_EVO_TOP_K", 2),


repl

    If we detect patterns like reduce(lambda x, y: x + y, <iter>) with no
    initializer, append ", 0)" to avoid empty-sequence errors (e.g., n=0).
    Conservative: only apply when the lambda contains '+' and not '*', and
    when reduce call appears to have two top-level arguments.
    """
    try:
        txt = code_text
        # Detect if solve signature indicates string argument (e.g., def solve(s:str)->str:)
        try:
            m_sig = re.search(r"^\s*def\s+solve\s*\(\s*([A-Za-z_]\w*)\s*:\s*str\b", txt, flags=re.MULTILINE)
            str_arg = (m_sig.group(1) if m_sig else None)
        except Exception:
            str_arg = None
        # Quick check to avoid unnecessary regex work
        if "reduce(" not in txt:
            return txt
        # Regex to capture reduce(lambda x, y: x + y, <iter>) with two args
        # It avoids nested parentheses greediness by a balanced heuristic up to closing )
        pat = re.compile(r"reduce\(\s*(lambda[^)]*\+[^)]*)\,\s*([^\)]+)\)")
        def repl(m: re.Match[str]) -> str:
            lam = m.group(1)
            it = m.group(2)
            # Only add initializer if lambda looks additive and not multiplicative
            lam_s = lam.replace(" ", "")
            if "+" in lam_s and "*" not in lam_s:
                it_s = it.strip()
                # Heuristics:
                # - If iterable looks like range(...), treat as numeric -> 0
                # - If solve takes a str arg and iterable is that arg, use empty string initializer
                # - Else, default to 0 as safe numeric initializer
                if it_s.startswith("range("):
                    return f"reduce({lam}, {it}, 0)"
                if str_arg and (it_s == str_arg):
                    return f"reduce({lam}, {it}, \"\")"
                return f"reduce({lam}, {it}, 0)"
            return m.group(0)
        new_txt = pat.sub(repl, txt)
        return new_txt
    except Exception:
        return code_text

def _ledger_path() -> Path:
    try:
        return data_path("selfplay", "ledger.md")
    except Exception:
        return Path("ledger.md")

def _ledger_append(line: str) -> None:
    try:
        p = _ledger_path()
        p.parent.mkdir(parents=True, exist_ok=True)
        with p.open("a", encoding="utf-8") as f:
            f.write(line.rstrip() + "\n")
    except Exception: