"""SFT rows that teach the live sanity chain: edit microtarget → submit → rejected → edit → submit.""" from __future__ import annotations import json import random import re from pathlib import Path from typing import Any from albedo_eval_service.remote.dataset import EvalSample, apply_submit_protocol, format_messages from albedo_eval_service.shared.observation_format import first_bash_block from sanity_service.chain import followup_instruction, micro_instruction from .constants import DEFAULT_DATA_ROOT, DEFAULT_PACK_DIR, KEEP_ORIGINAL_RATIO, TOKENIZER_DIR from .pack import PackedExample, _iter_raw, gold_paths, is_edit_command, _summary from .think import wrap_completion _FILE_RE = re.compile( r"(?:[\w.-]+/){1,8}[\w.-]+\.(?:py|rs|ts|js|tsx|jsx|go|java|c|h|hpp|cpp|cc|rb|toml|cmake)" r"|(?:[\w.-]+/)*CMakeLists\.txt" ) _FUNC_RES = ( re.compile(r"Function:\s*([A-Za-z_][A-Za-z0-9_]{1,64})"), re.compile(r"\*\*([A-Za-z_][A-Za-z0-9_]{1,64})\*\*"), re.compile(r"\b(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_][A-Za-z0-9_]{1,64})\s*[<(]"), re.compile(r"\b(?:def|fn|func)\s+([A-Za-z_][A-Za-z0-9_]{1,64})\s*[(<\[]"), re.compile(r"\bclass\s+([A-Za-z_][A-Za-z0-9_]{1,64})\b"), re.compile(r"#\s*define\s+([A-Za-z_][A-Za-z0-9_]{2,64})"), ) _WEAK_FUNCS = { "", "the helper", "to", "the target file", "helper", "function", "func", "async", "await", "class", "export", "import", "const", "return", "true", "false", "undefined", "null", "this", "self", "type", "name", "value", "values", "data", "item", "index", "error", "errors", "message", "options", "cause", "payload", "according", "containing", } _SKIP_FILE_PARTS = ("node_modules", "newfile.py", "filename.py") _OBS = ( "0\n\ncommand completed with no captured output\n" ) _REJECTION = ( "Not quite there yet: please double-check the change against the surrounding call " "sites and handle any case you may have missed, then submit again the same way." ) def infer_micro(text: str, gold: str = "") -> dict[str, str]: """Name a real file+symbol the way live GLM does, not English leftovers.""" hay = f"{gold}\n{text}" files = _candidate_files(hay) funcs = _candidate_funcs(hay) path, func = _pair_micro(files, funcs, hay) name = path.rsplit("/", 1)[-1] if path else "the target file" request = ( f"Make a small concrete edit in {name} ({func}) so the adjacent call sites stay consistent." ) return {"file": path or name, "function": func, "request": request, "message": ""} def _candidate_files(hay: str) -> list[str]: seen: list[str] = [] for match in _FILE_RE.finditer(hay): path = match.group(0) if any(part in path for part in _SKIP_FILE_PARTS): continue if path.startswith("n/") or "/testbed/" in path: continue if path not in seen: seen.append(path) return seen def _candidate_funcs(hay: str) -> list[str]: seen: list[str] = [] for pattern in _FUNC_RES: for match in pattern.finditer(hay): name = match.group(1) if name.lower() in _WEAK_FUNCS or name in seen: continue seen.append(name) return seen def _file_score(path: str, hay: str) -> int: head = hay[:8000] base = path.rsplit("/", 1)[-1] score = head.lower().count(path.lower()) * 3 + head.lower().count(base.lower()) if "/" in path: score += 1 if path.endswith("CMakeLists.txt"): score += head.lower().count("cmake") * 4 return score def _pair_micro(files: list[str], funcs: list[str], hay: str) -> tuple[str, str]: files = sorted(files, key=lambda path: (-_file_score(path, hay), path)) for path in files: stem = path.rsplit("/", 1)[-1].rsplit(".", 1)[0] for func in funcs: if func.lower() == stem.lower() or stem.lower() == func.lower(): return path, func path = files[0] if files else "" if funcs: return path, funcs[0] stem = path.rsplit("/", 1)[-1].rsplit(".", 1)[0] if path else "" if stem and stem.lower() not in _WEAK_FUNCS: return path, stem return path, "the helper" def followup_edit_command(path: str, func: str) -> str: target = path or "src/main.py" needle = func if func and func != "the helper" else "return " return f"sed -i '/{needle}/a\\ # handle adjacent call sites' {target}" def pack_chain( *, dataset_root: Path = DEFAULT_DATA_ROOT, out_dir: Path = DEFAULT_PACK_DIR, max_groups: int = 400, seed: str = "chain-pack", n_salts: int = 3, mix_pack: Path | None = None, mix_n: int = 1500, mix_edit_n: int = 0, mix_submit_n: int = 0, tokenizer_path: Path | None = None, ) -> Path: dataset_root = Path(dataset_root) out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) tokenizer = str(tokenizer_path or TOKENIZER_DIR) salts = [f"{seed}-{i}" for i in range(max(1, n_salts))] raw = [ item for item in _iter_raw(dataset_root, max_raw=max(max_groups * 12, 512), seed=seed) if item.kind == "edit" and gold_paths(item.gold) ] rng = random.Random(seed) rng.shuffle(raw) raw = raw[: max(1, max_groups)] examples: list[PackedExample] = [] for index, item in enumerate(raw): salt = salts[index % len(salts)] sample = EvalSample( sample_id=f"{item.shard}:{item.row}:{item.turn_idx}", prompt="", messages=list(item.messages), ) rewritten = apply_submit_protocol( [sample], salt=salt, keep_original_ratio=KEEP_ORIGINAL_RATIO, tokenizer_path=tokenizer, enable_thinking=True, )[0] clause = rewritten.submit_command marker = rewritten.submit_marker if not clause or not marker: continue micro = infer_micro( "\n".join(m.get("content") or "" for m in (rewritten.messages or [])), item.gold, ) if not micro.get("file"): continue instruction = micro_instruction(micro, clause) edit_bash = first_bash_block(item.gold) if not edit_bash or not is_edit_command(edit_bash): continue edit_completion = wrap_completion(item.gold, edit_bash) submit_completion = wrap_completion( f"THOUGHT: Work is saved. Submit with the exact command.\n```bash\n{clause}\n```", clause, ) reject_bash = followup_edit_command(micro["file"], micro["function"]) reject_completion = wrap_completion( f"THOUGHT: Reviewer asked for an adjacent-site fix before resubmitting.\n```bash\n{reject_bash}\n```", reject_bash, ) if not (edit_completion and submit_completion and reject_completion): continue prefix = list(rewritten.messages or item.messages) rows = _chain_rows( sample_id=rewritten.sample_id, prefix=prefix, instruction=instruction, edit_completion=edit_completion, submit_completion=submit_completion, reject_completion=reject_completion, clause=clause, marker=marker, rewrite_mode=rewritten.rewrite_mode, item=item, salt=salt, tokenizer=tokenizer, gold_paths=gold_paths(item.gold), ) examples.extend(rows) if mix_pack and Path(mix_pack).is_file(): if mix_edit_n or mix_submit_n: examples.extend(_mix_stage_a(Path(mix_pack), rng, mix_edit_n, mix_submit_n)) elif mix_n: examples.extend(_mix_balanced(Path(mix_pack), mix_n, rng)) pack_path = out_dir / f"sft-chain-{seed}.jsonl" with pack_path.open("w") as handle: for example in examples: handle.write(json.dumps(example.as_dict(), ensure_ascii=False) + "\n") summary = _summary(examples) summary["chain_groups"] = sum(1 for e in examples if e.kind.startswith("chain_")) (out_dir / f"sft-chain-{seed}.meta.json").write_text(json.dumps(summary, indent=2) + "\n") print(json.dumps(summary, indent=2), flush=True) print(f"pack: {pack_path}", flush=True) return pack_path def _chain_rows( *, sample_id: str, prefix: list[dict[str, str]], instruction: str, edit_completion: str, submit_completion: str, reject_completion: str, clause: str, marker: str, rewrite_mode: str, item: Any, salt: str, tokenizer: str, gold_paths: list[str], ) -> list[PackedExample]: micro_msgs = prefix + [{"role": "user", "content": instruction}] after_edit = micro_msgs + [ {"role": "assistant", "content": edit_completion}, {"role": "user", "content": _OBS}, ] after_submit = after_edit + [ {"role": "assistant", "content": submit_completion}, {"role": "user", "content": followup_instruction(_REJECTION, clause, first=False)}, ] after_reject_edit = after_submit + [ {"role": "assistant", "content": reject_completion}, {"role": "user", "content": _OBS}, ] after_second_submit = after_reject_edit + [ {"role": "assistant", "content": submit_completion}, { "role": "user", "content": followup_instruction( "Thanks, received. Continue with the original issue; do not submit again until you make a new edit.", clause, first=True, ), }, ] specs = [ (f"{sample_id}:chain_micro_edit", micro_msgs, edit_completion, "chain_micro_edit", "at_edit"), (f"{sample_id}:chain_micro_submit", after_edit, submit_completion, "chain_micro_submit", "post_edit"), (f"{sample_id}:chain_reject_edit", after_submit, reject_completion, "chain_reject_edit", "at_edit"), (f"{sample_id}:chain_reject_submit", after_reject_edit, submit_completion, "chain_reject_submit", "post_edit"), (f"{sample_id}:chain_followup_edit", after_second_submit, edit_completion, "chain_followup_edit", "at_edit"), ] out: list[PackedExample] = [] for sid, messages, completion, kind, phase in specs: out.append( PackedExample( sample_id=sid, prompt=format_messages(messages, tokenizer_path=tokenizer, enable_thinking=True), completion=completion, source=item.source, phase=phase, kind=kind, family=item.family, language=item.language, repo=item.repo, submit_command=clause, submit_marker=marker, rewrite_mode=rewrite_mode, gold_paths=gold_paths, salt=salt, ) ) return out def _row_to_example(row: dict[str, Any]) -> PackedExample: return PackedExample( sample_id=str(row.get("sample_id") or ""), prompt=str(row.get("prompt") or ""), completion=str(row.get("completion") or ""), source=str(row.get("source") or ""), phase=str(row.get("phase") or "explore"), kind=str(row.get("kind") or "explore"), family=str(row.get("family") or ""), language=str(row.get("language") or ""), repo=str(row.get("repo") or ""), submit_command=str(row.get("submit_command") or ""), submit_marker=str(row.get("submit_marker") or ""), rewrite_mode=str(row.get("rewrite_mode") or ""), gold_paths=list(row.get("gold_paths") or []), salt=str(row.get("salt") or ""), ) def _take(rows: list[dict[str, Any]], n: int, rng: random.Random) -> list[PackedExample]: rng.shuffle(rows) return [_row_to_example(row) for row in rows[: max(0, n)]] def _mix_balanced(path: Path, n: int, rng: random.Random) -> list[PackedExample]: rows = [json.loads(line) for line in path.read_text().splitlines() if line.strip()] keep = [row for row in rows if row.get("kind") in {"edit", "explore"}] return _take(keep, n, rng) def _mix_stage_a( path: Path, rng: random.Random, edit_n: int, submit_n: int ) -> list[PackedExample]: """Official gold mix: at_edit first, then any edit; post_edit submit, then any submit.""" rows = [json.loads(line) for line in path.read_text().splitlines() if line.strip()] at_edit = [row for row in rows if row.get("kind") == "edit" and row.get("phase") == "at_edit"] other_edit = [ row for row in rows if row.get("kind") == "edit" and row.get("phase") != "at_edit" ] post_submit = [ row for row in rows if row.get("kind") == "submit" and row.get("phase") == "post_edit" ] other_submit = [ row for row in rows if row.get("kind") == "submit" and row.get("phase") != "post_edit" ] edits = _take(at_edit, edit_n, rng) if len(edits) < edit_n: edits.extend(_take(other_edit, edit_n - len(edits), rng)) submits = _take(post_submit, submit_n, rng) if len(submits) < submit_n: submits.extend(_take(other_submit, submit_n - len(submits), rng)) return edits + submits