| """Local pre-eval chain: microtask + reject-first-submit, then official heuristics.""" |
|
|
| from __future__ import annotations |
|
|
| import json |
| import subprocess |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from uuid import uuid4 |
|
|
| from albedo_eval_service.remote.dataset import EvalSample, format_messages |
| from albedo_eval_service.remote.generation import VllmProcessGenerator |
| from albedo_eval_service.shared.observation_format import ( |
| MAX_CONSECUTIVE_BAD_TURNS, |
| detect_format, |
| first_bash_block, |
| unusable_turn, |
| wrap, |
| ) |
| from albedo_eval_service.simulator.prompt_simulator import missing_command_output |
| from local_eval.constants import DEFAULT_DATA_ROOT, DEFAULT_RUNS_DIR, MAX_NEW_TOKENS, TOKENIZER_DIR |
| from local_eval.live_protocol import generate_retrying_bad_turns |
| from local_eval.rollout import build_generator |
| from local_eval.samples import leftover_observations, load_samples |
| from sanity_service.chain import ( |
| SUBMIT_NUDGE, |
| followup_instruction, |
| micro_instruction, |
| segment_has_edit, |
| ) |
|
|
| from .chain_gold import LIVE_CHAIN_IDS |
| from .chain_heuristics import ChainState, evaluate_chain, is_submit_turn, mid_roll_fatal |
| from .chain_pack import _REJECTION, infer_micro |
| from .constants import DEFAULT_RL_EXPORT_DIR |
|
|
| LIVE_SAMPLES = 3 |
| LIVE_TURNS = 32 |
| _OBS_CHARS = 2500 |
| _CHAIN_GPUS = 4 |
| _MIN_FREE_GIB = 100.0 |
|
|
|
|
| def pick_free_gpus(n: int = _CHAIN_GPUS, min_free_gib: float = _MIN_FREE_GIB) -> list[str]: |
| """Prefer cards that can actually satisfy vLLM's 0.8 utilization check.""" |
| try: |
| raw = subprocess.check_output( |
| ["nvidia-smi", "--query-gpu=index,memory.free", "--format=csv,noheader,nounits"], |
| text=True, |
| ) |
| except (subprocess.CalledProcessError, FileNotFoundError) as exc: |
| raise RuntimeError(f"nvidia-smi failed: {exc}") from exc |
| free: list[str] = [] |
| for line in raw.strip().splitlines(): |
| parts = [p.strip() for p in line.split(",")] |
| if len(parts) < 2: |
| continue |
| idx, mem = parts[0], parts[1] |
| try: |
| if float(mem) / 1024.0 >= min_free_gib: |
| free.append(idx) |
| except ValueError: |
| continue |
| if len(free) < n: |
| raise RuntimeError( |
| f"need {n} GPUs with >= {min_free_gib:.0f} GiB free, found {free}. " |
| "kill leftover VLLM::EngineCore / Worker_TP* processes and retry" |
| ) |
| return free[:n] |
|
|
|
|
| def _micro_for(sample) -> dict[str, str]: |
| hay = "\n".join(m.get("content") or "" for m in (sample.messages or [])) |
| return infer_micro(hay) |
|
|
|
|
| def _pick_chain_samples( |
| dataset_root: Path, *, samples: int, seed: str, live_gate: bool = True |
| ): |
| """Use the 3 live-gate files first, then random extras so we test transfer.""" |
| picked: list[tuple] = [] |
| seen: set[str] = set() |
| forced = [] |
| if live_gate: |
| try: |
| forced = load_samples( |
| dataset_root, |
| sample_ids=list(LIVE_CHAIN_IDS), |
| sample_count=len(LIVE_CHAIN_IDS), |
| seed=seed, |
| ) |
| except Exception as exc: |
| print(f"live-gate samples unavailable ({exc}); falling back to random", flush=True) |
| forced = [] |
| for sample in forced: |
| micro = _micro_for(sample) |
| if not sample.submit_command or not micro.get("file"): |
| continue |
| picked.append((sample, micro)) |
| seen.add(sample.sample_id) |
| if len(picked) >= samples: |
| return picked |
| extras = load_samples( |
| dataset_root, |
| sample_ids=None, |
| sample_count=max(samples * 6, 18), |
| seed=seed, |
| ) |
| for sample in extras: |
| if sample.sample_id in seen: |
| continue |
| micro = _micro_for(sample) |
| if not sample.submit_command or not micro.get("file"): |
| continue |
| picked.append((sample, micro)) |
| seen.add(sample.sample_id) |
| if len(picked) >= samples: |
| break |
| return picked |
|
|
|
|
| def run_chain( |
| *, |
| challenger: Path = DEFAULT_RL_EXPORT_DIR, |
| dataset_root: Path = DEFAULT_DATA_ROOT, |
| samples: int = LIVE_SAMPLES, |
| turns: int = LIVE_TURNS, |
| seed: str = "chain-eval", |
| live_gate: bool = True, |
| gpu_ids: list[str] | None = None, |
| runs_dir: Path = DEFAULT_RUNS_DIR, |
| reject_first_submit: bool = True, |
| ) -> dict: |
| from local_eval.cuda_env import apply as apply_cuda |
|
|
| apply_cuda() |
| dataset_root = Path(dataset_root) |
| picked = _pick_chain_samples( |
| dataset_root, samples=samples, seed=seed, live_gate=live_gate |
| ) |
| states: list[ChainState] = [] |
| leftover: dict[str, list[str]] = {} |
| leftover_idx: dict[str, int] = {} |
| for sample, micro in picked: |
| instruction = micro_instruction(micro, sample.submit_command) |
| messages = list(sample.messages or []) + [{"role": "user", "content": instruction}] |
| turns_so_far = [ |
| {"role": m.get("role", "user"), "content": m.get("content", "")} |
| for m in messages |
| ] |
| turns_so_far[-1] = {**turns_so_far[-1], "segment": "micro", "injected": True} |
| state = ChainState( |
| sample_id=sample.sample_id, |
| prompt=format_messages( |
| messages, tokenizer_path=str(TOKENIZER_DIR), enable_thinking=True |
| ), |
| messages=messages, |
| turns=turns_so_far, |
| submit_clause=sample.submit_command, |
| submit_marker=sample.submit_marker, |
| rewrite_mode=getattr(sample, "rewrite_mode", ""), |
| micro=micro, |
| ) |
| states.append(state) |
| leftover[sample.sample_id] = leftover_observations(dataset_root, sample.sample_id) |
| leftover_idx[sample.sample_id] = 0 |
| if len(states) < samples: |
| raise RuntimeError(f"only {len(states)} chain-able samples, need {samples}") |
|
|
| gpu_ids = gpu_ids or pick_free_gpus() |
| if len(gpu_ids) > _CHAIN_GPUS: |
| gpu_ids = gpu_ids[:_CHAIN_GPUS] |
| generator = build_generator( |
| str(challenger), |
| gpu_ids, |
| max_new_tokens=MAX_NEW_TOKENS, |
| ) |
| run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid4().hex[:8] |
| out = Path(runs_dir) / f"{run_id}-chain" |
| out.mkdir(parents=True, exist_ok=True) |
| print( |
| f"local chain run={run_id} samples={len(states)} turns<={turns} chal={challenger}", |
| flush=True, |
| ) |
| try: |
| _roll(generator, states, leftover, leftover_idx, turns, reject_first_submit) |
| finally: |
| generator.close() |
|
|
| evaluate_chain(states, turns) |
| report = _report(run_id, challenger, states, turns) |
| (out / "chain-report.json").write_text(json.dumps(report, indent=2) + "\n") |
| with (out / "chain-samples.jsonl").open("w") as handle: |
| for state in states: |
| handle.write( |
| json.dumps( |
| { |
| "sample_id": state.sample_id, |
| "passed": not state.error and not state.heuristic_reason, |
| "reason": state.error or state.heuristic_reason, |
| "n_submits": len(state.submits), |
| "empty_adjacent": sum(1 for s in state.submits if not s.get("has_edit")), |
| "micro": state.micro, |
| "submit_command": state.submit_clause, |
| "commands": [ |
| first_bash_block(str(t.get("content") or "")) |
| for t in state.turns |
| if t.get("role") == "assistant" and t.get("score_target") |
| ], |
| } |
| ) |
| + "\n" |
| ) |
| print(json.dumps(report, indent=2), flush=True) |
| print(f"artifacts: {out}", flush=True) |
| return report |
|
|
|
|
| def _roll( |
| generator: VllmProcessGenerator, |
| states: list[ChainState], |
| leftover: dict[str, list[str]], |
| leftover_idx: dict[str, int], |
| turn_count: int, |
| reject_first_submit: bool, |
| ) -> None: |
| for turn_index in range(turn_count): |
| active = [s for s in states if not s.stopped and not s.error and not s.heuristic_reason] |
| if not active: |
| break |
| batch = [ |
| EvalSample( |
| sample_id=state.sample_id, |
| prompt=state.prompt, |
| messages=state.messages, |
| submit_command=state.submit_clause, |
| submit_marker=state.submit_marker, |
| ) |
| for state in active |
| ] |
| results = generate_retrying_bad_turns(generator, batch) |
| by_id = {r.sample_id: r for r in results} |
| for state in active: |
| result = by_id.get(state.sample_id) |
| if result is None or result.error: |
| scored = any( |
| t.get("role") == "assistant" and t.get("score_target") for t in state.turns |
| ) |
| if scored: |
| state.stopped = True |
| else: |
| state.error = ( |
| (result.error if result else "missing_generation") or "missing_generation" |
| ) |
| continue |
| text = result.text or "" |
| reason = unusable_turn(text, truncated=bool(result.truncated)) |
| if reason: |
| state.turns.append( |
| { |
| "role": "assistant", |
| "content": text, |
| "score_target": True, |
| "segment": state.segment, |
| } |
| ) |
| state.heuristic_reason = ( |
| f"{reason} on {MAX_CONSECUTIVE_BAD_TURNS} consecutive turns" |
| ) |
| state.stopped = True |
| continue |
| state.turns.append( |
| { |
| "role": "assistant", |
| "content": text, |
| "score_target": True, |
| "segment": state.segment, |
| } |
| ) |
| fatal = mid_roll_fatal(state) |
| if fatal: |
| state.heuristic_reason = fatal |
| state.stopped = True |
| continue |
| if turn_index == turn_count - 1: |
| continue |
| if is_submit_turn(text, state.submit_clause, state.submit_marker): |
| _handle_submit(state, text, turn_index, reject_first_submit) |
| elif not first_bash_block(text): |
| fmt = detect_format(state.sample_id, state.messages) |
| _append_user(state, missing_command_output(fmt)) |
| else: |
| _append_user(state, _env_obs(state, leftover, leftover_idx)) |
| state.prompt = format_messages( |
| state.messages, tokenizer_path=str(TOKENIZER_DIR), enable_thinking=True |
| ) |
| if turn_index >= turn_count - 8 and (turn_count - turn_index) % 4 == 0: |
| _nudge([s for s in states if not s.stopped and not s.error and not s.submits]) |
|
|
|
|
| def _handle_submit( |
| state: ChainState, text: str, turn_index: int, reject_first: bool |
| ) -> None: |
| rejected = reject_first and not any(s.get("rejected") for s in state.submits) |
| record = { |
| "turn": turn_index, |
| "segment": state.segment, |
| "rejected": rejected, |
| "format_ok": state.submit_clause.split("&&")[0].strip() in text, |
| "post_nudge": bool(state.nudged_at) and turn_index > state.nudged_at, |
| "has_edit": segment_has_edit(state.as_ns(), state.segment), |
| } |
| state.submits.append(record) |
| if rejected: |
| _append_user(state, followup_instruction(_REJECTION, state.submit_clause, first=False)) |
| return |
| first = state.segment == "micro" |
| state.segment_index += not first |
| state.segment = "real" if first else f"followup_{state.segment_index}" |
| _append_user( |
| state, |
| followup_instruction( |
| "Thanks, received. Please continue with the original issue and any missed call sites.", |
| state.submit_clause, |
| first=first, |
| ), |
| ) |
|
|
|
|
| def _nudge(states: list[ChainState]) -> None: |
| for state in states: |
| text = SUBMIT_NUDGE.format(clause=state.submit_clause) |
| _append_user(state, text) |
| state.turns[-1]["injected"] = True |
| state.nudged_at = state.nudged_at or len( |
| [t for t in state.turns if t.get("score_target")] |
| ) |
| state.prompt = format_messages( |
| state.messages, tokenizer_path=str(TOKENIZER_DIR), enable_thinking=True |
| ) |
|
|
|
|
| def _append_user(state: ChainState, observation: str) -> None: |
| assistant = str(state.turns[-1].get("content") or "") |
| state.messages.extend( |
| [ |
| {"role": "assistant", "content": assistant}, |
| {"role": "user", "content": observation}, |
| ] |
| ) |
| state.turns.append( |
| {"role": "user", "content": observation, "environment_observation": True} |
| ) |
|
|
|
|
| def _env_obs( |
| state: ChainState, leftover: dict[str, list[str]], leftover_idx: dict[str, int] |
| ) -> str: |
| gold = leftover.get(state.sample_id) or [] |
| index = leftover_idx.get(state.sample_id, 0) |
| if index < len(gold): |
| leftover_idx[state.sample_id] = index + 1 |
| text = gold[index] |
| if len(text) > _OBS_CHARS: |
| text = text[:_OBS_CHARS] + "\n...[truncated]" |
| return text |
| fmt = detect_format(state.sample_id, state.messages) |
| return wrap("command completed with no captured output", fmt) |
|
|
|
|
| def _report(run_id: str, challenger: Path, states: list[ChainState], turns: int) -> dict: |
| rows = [] |
| for state in states: |
| rows.append( |
| { |
| "sample_id": state.sample_id, |
| "passed": not state.error and not state.heuristic_reason, |
| "reason": state.error or state.heuristic_reason, |
| "n_submits": len(state.submits), |
| "unprompted": any(not s.get("post_nudge") for s in state.submits), |
| "micro_file": (state.micro or {}).get("file"), |
| } |
| ) |
| passed = all(r["passed"] for r in rows) and bool(rows) |
| reasons = [r["reason"] for r in rows if r["reason"]] |
| if passed: |
| reasons = ["go: every sample passed the live chain heuristics"] |
| return { |
| "run_id": run_id, |
| "go": passed, |
| "challenger": str(challenger), |
| "n": len(rows), |
| "turns": turns, |
| "pass_rate": round(sum(1 for r in rows if r["passed"]) / max(len(rows), 1), 4), |
| "reasons": reasons, |
| "samples": rows, |
| } |
|
|