Buckets:
| #!/usr/bin/env python | |
| """scripts/run_sr.py — Python success-rate eval driver for ONF LIBERO-Plus cells. | |
| One Python entrypoint for launching+scoring a ladder cell (suite x axis x mode): sets up | |
| the OSMesa render env, polls the policy server's port for readiness, sets PYTHONNOUSERSITE to dodge | |
| a user-site torch that shadows the pinned stablevla one, writes into the | |
| results/plus_<suite>/<Axis_With_Underscores>/<TAG>/ layout the mp4 filenames encode success into, | |
| and passes through QNDF_DIR / LIBERO_INSTANCE_ID_FILTER. It: | |
| * resolves a ladder cell (suite, axis, mode) into an env + CLI recipe by reading | |
| configs/sr_ladder.yaml (the cell table: expected episode counts, comparator dirs, gates, | |
| GPU hints) and PARSING evals/common/modes.sh for the mode env recipes (rather than | |
| re-declaring them — a parsed value can never drift from the bash launchers it replaces); | |
| * launches one GPU-resident StableVLA policy server + N sharded sim clients per cell via | |
| subprocess, always tearing the server down (a context manager + a signal handler, so Ctrl-C | |
| can't orphan a GPU-resident process); | |
| * is polite about shared hardware: refuses a GPU with too little free memory | |
| (torch.cuda.mem_get_info — never nvidia-smi, which is broken on this box) and refuses a | |
| large launch when the box is under load (/proc/loadavg); | |
| * enforces completeness (mp4 count vs. the yaml's expected episode count) rather than assuming it, | |
| and treats a partial result dir as poison for score.py — --resume purges and reruns it; | |
| * appends one append-only record per cell attempt to outputs/sr/ledger.jsonl (see | |
| onf.graph.report's StageLogger for the run-dir/manifest convention this mirrors); | |
| * scores with the EXISTING evals/libero_plus/score.py / mcnemar.py (imported, not | |
| reimplemented) and prints a paired McNemar discordance alongside the marginal rate — on this | |
| deterministic bench, effects are 1-4 episodes and a marginal rate alone is not interpretable. | |
| SHAPE | |
| LadderConfig is the one object that knows the ladder: resolved paths, the external | |
| scorers, the modes.sh recipes, the suite table, the defaults, and the results root. Everything | |
| else takes it in the constructor instead of threading | |
| (ladder_doc, suites, results_root, paths) through twenty signatures. The mutable | |
| process/port/ledger state that used to live in module globals now lives on | |
| ProcessSupervisor / PortAllocator / Ledger, one instance per run. | |
| SECOND CONSUMER | |
| evals/gr00t/run_gr00t.py loads THIS module by file path and reuses its ladder/mode/naming/ | |
| provenance layer so the two drivers can never disagree about which mode an axis defaults to, | |
| what a result dir is called, or what a ledger row looks like. Anything it imports is public API | |
| here; see that file's own RUN_SR. call sites. | |
| Usage: | |
| python scripts/run_sr.py --check-env | |
| python scripts/run_sr.py --rung R4 --gpus 0,1 --dry-run | |
| python scripts/run_sr.py --rung R4 --gpus 0,1 [--mode base] [--resume] | |
| python scripts/run_sr.py --cells goal:Background_Textures,long:Camera_Viewpoints --gpus 0,1 --mode base | |
| python scripts/run_sr.py --score --rung R4 | |
| python scripts/run_sr.py --ledger | |
| Exit codes: 0 = ok / all gates met; 1 = operational error (preflight refusal, crash, nothing to do); | |
| 2 = ran/scored fine but a gate was NOT met (mirrors the graph CLI convention: a clean run that fails | |
| its bar is not the same failure mode as a broken run). | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import ast | |
| import atexit | |
| import dataclasses | |
| import enum | |
| import importlib.util | |
| import itertools | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import signal | |
| import socket | |
| import subprocess | |
| import sys | |
| import threading | |
| import time | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from contextlib import ExitStack | |
| from dataclasses import dataclass, field, replace | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from types import FrameType, ModuleType | |
| from typing import Any, Mapping, Sequence, TextIO | |
| import yaml | |
| REPO_ROOT = Path(__file__).resolve().parents[1] | |
| LADDER_YAML = REPO_ROOT / "configs" / "sr_ladder.yaml" | |
| STABLEVLA_CONDA_ENV = "stablevla" # fixed operational fact: the conda env name this box uses | |
| STABLEVLA_ROOT = REPO_ROOT / "stablevla" | |
| ONF_SRC = REPO_ROOT / "src" | |
| # This box's kernel nvidia driver module and userspace NVML library are mismatched (measured: | |
| # kernel 570.172.08, userspace 580.173) -- nvidia-smi itself is unusable without preloading a | |
| # userspace NVML that matches the kernel module, and so is torch's CUDA caching allocator whenever it | |
| # needs to grow its pool (nvmlInit_v2 hard-asserts on the mismatch -- see the ONF_ROOT-external | |
| # HAMLET-Isaac-GR00T repo's own vulkan570_env.sh fix for the identical class of problem). | |
| # ONF_NVML_PRELOAD overrides the path outright; failing that, the known-good file from that sibling | |
| # repo's fix is used if present on this exact box, so a checkout elsewhere without that sibling repo | |
| # just silently gets no LD_PRELOAD (a degrade to the OLD, occasionally-crashing behaviour, not a hard | |
| # failure). | |
| NVML_PRELOAD_CANDIDATE = Path( | |
| "/srv/data/HAMLET-Isaac-GR00T/.vkenv/nvidia570/lib/libnvidia-ml.so.570.172.08" | |
| ) | |
| # ===================================================================================================== | |
| # enums -- the strings that used to be compared inline | |
| # ===================================================================================================== | |
| class ExitCode(enum.IntEnum): | |
| """This driver's process exit codes. int so sys.exit / main can return one directly.""" | |
| OK = 0 | |
| ERROR = 1 | |
| GATE_FAILED = 2 | |
| class GateKind(str, enum.Enum): | |
| """The gate kinds sr_ladder.yaml may declare. Constructing one validates the yaml value.""" | |
| MIN_SUCCESSES = "min_successes" | |
| MAX_REGRESS_PP = "max_regress_pp" | |
| EXACT_MATCH = "exact_match" | |
| class CellAction(str, enum.Enum): | |
| """What ResumePlanner decided to do with one cell's existing result dir.""" | |
| RUN = "run" | |
| SKIP = "skip" | |
| PURGE = "purge" | |
| ERROR = "error" | |
| class RunStatus(str, enum.Enum): | |
| """A ledger row's terminal state. RUNNING is written at launch and superseded on completion.""" | |
| RUNNING = "running" | |
| COMPLETE = "complete" | |
| INCOMPLETE = "incomplete" | |
| CRASHED = "crashed" | |
| # ===================================================================================================== | |
| # yaml loading + the resolved ladder paths/tables | |
| # ===================================================================================================== | |
| def load_yaml(path: Path) -> dict: | |
| """Read a YAML file into a dict ({} when the document is empty).""" | |
| with open(path) as f: | |
| return yaml.safe_load(f) or {} | |
| class LadderPaths: | |
| """Absolute paths to every external artifact this driver drives. | |
| The single place the driver learns where score.py / mcnemar.py / modes.sh / the policy server / | |
| the harness live -- nothing about them is hardcoded outside this file's bootstrap constants | |
| (REPO_ROOT, LADDER_YAML). | |
| """ | |
| score_py: Path | |
| mcnemar_py: Path | |
| modes_sh: Path | |
| policy_server: Path | |
| harness_dir: Path | |
| shim_dir: Path | |
| suites_yaml: Path | |
| def from_doc(cls, doc: Mapping[str, Any], root: Path = REPO_ROOT) -> "LadderPaths": | |
| """Resolve sr_ladder.yaml's paths: block (all entries relative to the repo root). | |
| Raises: | |
| KeyError: The block is missing a declared path, naming every one that is absent -- a | |
| clear failure here beats a KeyError from deep inside a launch. | |
| """ | |
| raw = doc["paths"] | |
| names = [f.name for f in dataclasses.fields(cls)] | |
| missing = [n for n in names if n not in raw] | |
| if missing: | |
| raise KeyError(f"sr_ladder.yaml: paths block is missing {missing}") | |
| return cls(**{n: root / raw[n] for n in names}) | |
| class SuiteInfo: | |
| """One suite's row of configs/suites.yaml, with every path already made absolute.""" | |
| name: str | |
| bench: str | |
| stablevla_ckpt: Path | |
| fwm_dir: Path | |
| light_filter: Path | |
| class Defaults: | |
| """sr_ladder.yaml's defaults: block.""" | |
| num_clients: int | |
| base_port: int | |
| episodes_per_min: float | |
| server_ready_timeout_s: float | |
| def from_doc(cls, doc: Mapping[str, Any]) -> "Defaults": | |
| return cls( | |
| num_clients=int(doc["num_clients"]), | |
| base_port=int(doc["base_port"]), | |
| episodes_per_min=float(doc["episodes_per_min"]), | |
| server_ready_timeout_s=float(doc["server_ready_timeout_s"]), | |
| ) | |
| class PreflightLimits: | |
| """sr_ladder.yaml's preflight: block -- the thresholds that make this driver a polite | |
| neighbour on a shared box.""" | |
| min_free_gib: float | |
| max_loadavg_factor: float | |
| large_rung_episodes: int | |
| def from_doc(cls, doc: Mapping[str, Any]) -> "PreflightLimits": | |
| return cls( | |
| min_free_gib=float(doc["min_free_gib"]), | |
| max_loadavg_factor=float(doc["max_loadavg_factor"]), | |
| large_rung_episodes=int(doc["large_rung_episodes"]), | |
| ) | |
| # ===================================================================================================== | |
| # reuse evals/libero_plus/score.py + mcnemar.py rather than reimplementing the mp4-glob scorer / the | |
| # paired McNemar test. Both are standalone scripts (no package __init__), so load them by file path. | |
| # ===================================================================================================== | |
| class DirCount: | |
| """score.py's (successes, n) for one result directory.""" | |
| successes: int | |
| n: int | |
| def rate(self) -> float: | |
| """Success rate in percent; nan for an empty directory.""" | |
| return 100.0 * self.successes / self.n if self.n else float("nan") | |
| class ExternalScorers: | |
| """Lazy, cached accessor for score.py / mcnemar.py. | |
| Every scoring question in this driver goes through here, so it can never disagree with | |
| score.py about what a tag directory scores or which axes exist. Loaded on first use: a | |
| --ledger or --help invocation never pays for it. | |
| """ | |
| def __init__(self, paths: LadderPaths) -> None: | |
| self._paths = paths | |
| self._score: ModuleType | None = None | |
| self._mcnemar: ModuleType | None = None | |
| def score(self) -> ModuleType: | |
| if self._score is None: | |
| self._score = self._load("sr_score", self._paths.score_py) | |
| return self._score | |
| def mcnemar(self) -> ModuleType: | |
| if self._mcnemar is None: | |
| self._mcnemar = self._load("sr_mcnemar", self._paths.mcnemar_py) | |
| return self._mcnemar | |
| def axes(self) -> list[str]: | |
| """The perturbation axes, in score.py's own order.""" | |
| return list(self.score.AXES) | |
| def bench(self, suite: str) -> str: | |
| """Suite short-name -> LIBERO benchmark name (score.py's SUITE_BENCH).""" | |
| return self.score.SUITE_BENCH[suite] | |
| def default_results_root(self) -> Path: | |
| return Path(self.score.results_root()) | |
| def count_dir(self, directory: Path) -> DirCount: | |
| """(successes, n) for a result dir; (0, 0) when it does not exist.""" | |
| if not directory.exists(): | |
| return DirCount(0, 0) | |
| successes, n = self.score.count_dir(str(directory)) | |
| return DirCount(int(successes), int(n)) | |
| def episode_outcomes(self, directory: Path) -> dict[str, bool]: | |
| """{episode_key: succeeded} for the paired McNemar test.""" | |
| return self.mcnemar.load(str(directory)) | |
| def exact_p(self, b_only: int, c_only: int) -> float: | |
| return self.mcnemar.mcnemar_exact(b_only, c_only) | |
| def _load(name: str, path: Path) -> ModuleType: | |
| spec = importlib.util.spec_from_file_location(name, path) | |
| if spec is None or spec.loader is None: | |
| raise ImportError(f"cannot load {name} from {path}") | |
| module = importlib.util.module_from_spec(spec) | |
| sys.modules[name] = module # register before exec so dataclass/typing self-references resolve | |
| spec.loader.exec_module(module) | |
| return module | |
| # ===================================================================================================== | |
| # evals/common/modes.sh parser — the mode env recipes are PARSED from the bash file (the | |
| # single source of truth for onf_set_mode()), not re-declared in yaml, so this driver can never drift | |
| # out of sync with the launchers it replaces. See tests/test_run_sr.py for a check that the parser's | |
| # output matches the CONTRACT values documented in modes.sh's own header comment. | |
| # ===================================================================================================== | |
| _CASE_BLOCK_RE = re.compile(r'case\s+"\$\{mode\}"\s+in\n(.*?)\n[ \t]*esac', re.DOTALL) | |
| _ARM_SPLIT_RE = re.compile(r"^[ \t]*([A-Za-z_]\w*(?:\|[A-Za-z_]\w*)*)\)[ \t]*\n", re.MULTILINE) | |
| _ASSIGN_RE = re.compile(r"^([A-Za-z_]\w*)=(.*)$") | |
| _PARAM_DEFAULT_RE = re.compile(r'^"?\$\{[A-Za-z_]\w*:-([^}]*)\}"?$') | |
| def parse_mode_recipes(modes_sh_text: str) -> dict[str, dict[str, str]]: | |
| """Parse onf_set_mode()'s case "${mode}" in ... esac block. | |
| Args: | |
| modes_sh_text: The contents of evals/common/modes.sh. | |
| Returns: | |
| {mode_name: {ENV_VAR: value}} -- whatever the matched arm exports. | |
| Raises: | |
| ValueError: The case block could not be found. | |
| """ | |
| match = _CASE_BLOCK_RE.search(modes_sh_text) | |
| if not match: | |
| raise ValueError('modes.sh: could not find `case "${mode}" in ... esac` block') | |
| recipes: dict[str, dict[str, str]] = {} | |
| # parts[0] is text before the first arm header (comments); then alternating (pattern, arm_text). | |
| parts = _ARM_SPLIT_RE.split(match.group(1)) | |
| for i in range(1, len(parts), 2): | |
| pattern, arm_text = parts[i], parts[i + 1] | |
| env = _parse_arm_exports(arm_text) | |
| for name in pattern.split("|"): | |
| recipes[name] = env | |
| return recipes | |
| def _parse_arm_exports(arm_text: str) -> dict[str, str]: | |
| """One case arm's body -> its NAME=value exports, honouring backslash continuations.""" | |
| text = arm_text.split(";;")[0] # stop at the arm terminator | |
| lines = [line.split("#", 1)[0].rstrip() for line in text.splitlines()] # no '#' in any value | |
| joined = " ".join(line[:-1] if line.endswith("\\") else line for line in lines) | |
| out: dict[str, str] = {} | |
| for token in joined.split(): | |
| if token == "export": | |
| continue | |
| match = _ASSIGN_RE.match(token) | |
| if match: | |
| out[match.group(1)] = _expand_value(match.group(2)) | |
| return out | |
| def _expand_value(value: str) -> str: | |
| """"${VAR:-default}" -> default; anything else just loses its surrounding quotes.""" | |
| match = _PARAM_DEFAULT_RE.match(value) | |
| return match.group(1) if match else value.strip('"') | |
| class ModeRegistry: | |
| """The mode env recipes, parsed once from modes.sh.""" | |
| def __init__(self, modes_sh: Path) -> None: | |
| self._path = modes_sh | |
| self._recipes = parse_mode_recipes(modes_sh.read_text()) | |
| def modes(self) -> list[str]: | |
| return sorted(self._recipes) | |
| def env_for(self, mode: str) -> dict[str, str]: | |
| """A fresh copy of mode's env recipe. | |
| Raises: | |
| ValueError: mode has no arm in modes.sh. | |
| """ | |
| if mode not in self._recipes: | |
| raise ValueError(f"unknown MODE={mode!r}; not found in {self._path} (known: {self.modes})") | |
| return dict(self._recipes[mode]) | |
| # ===================================================================================================== | |
| # ladder cell model | |
| # ===================================================================================================== | |
| class Comparator: | |
| """A previously-measured result directory this cell is scored against.""" | |
| label: str | |
| tag: str | |
| successes: int | |
| n: int | |
| def rate(self) -> float: | |
| return 100.0 * self.successes / self.n if self.n else float("nan") | |
| class GateVerdict: | |
| """Whether a cell cleared its bar, and the one-line reason.""" | |
| passed: bool | |
| message: str | |
| class Gate: | |
| """One cell's pass/fail bar, as declared in sr_ladder.yaml.""" | |
| kind: GateKind | |
| vs: str = "base" | |
| min_successes: int | None = None | |
| max_regress_pp: float | None = None | |
| def from_dict(cls, doc: Mapping[str, Any]) -> "Gate": | |
| """Build from a yaml gate block. Raises ValueError on an unknown kind.""" | |
| return cls( | |
| kind=GateKind(doc["kind"]), | |
| vs=doc.get("vs", "base"), | |
| min_successes=doc.get("min_successes"), | |
| max_regress_pp=doc.get("max_regress_pp"), | |
| ) | |
| def evaluate(self, score: "CellScore") -> GateVerdict: | |
| """Apply this gate to a scored cell. | |
| A gate with no matching comparator on disk PASSES with a SKIP note -- including a | |
| min_successes gate. That is deliberate and load-bearing: the comparator's absence means | |
| the ladder has nothing to compare against yet, and failing a cell for that would make a | |
| fresh checkout's first run red for a reason that has nothing to do with the run. | |
| """ | |
| comparator = score.cell.comparator(self.vs) | |
| if comparator is None: | |
| return GateVerdict(True, f"SKIP: no comparator labeled {self.vs!r} to gate against") | |
| if self.kind is GateKind.EXACT_MATCH: | |
| passed = score.successes == comparator.successes and score.actual_n == comparator.n | |
| return GateVerdict( | |
| passed, | |
| f"exact_match vs {comparator.label} ({comparator.successes}/{comparator.n}): " | |
| f"got {score.successes}/{score.actual_n}", | |
| ) | |
| if self.kind is GateKind.MIN_SUCCESSES: | |
| return GateVerdict( | |
| score.successes >= self.min_successes, | |
| f"min_successes={self.min_successes} vs {comparator.label}: got {score.successes}", | |
| ) | |
| delta = score.rate - comparator.rate | |
| return GateVerdict( | |
| delta >= -self.max_regress_pp, | |
| f"delta vs {comparator.label} = {delta:+.1f}pp (floor -{self.max_regress_pp:.1f}pp)", | |
| ) | |
| def cell_tag(policy: str, mode: str, suite: str, axis: str, variant: str = "") -> str: | |
| """The one on-disk name for a cell: {policy}_{mode}_{suite}_{axis}[_{variant}]. | |
| Both drivers (this one and evals/gr00t/run_gr00t.py) route every result dir, log dir and | |
| ledger row through this function, so a name always says which policy and which mode produced it. | |
| Baking the mode in is what stops --resume's ledger lookup from reporting one mode's numbers | |
| under another's; variant separates a filtered smoke pool from the full axis it subsets. | |
| """ | |
| tag = f"{policy}_{mode}_{suite}_{axis}" | |
| return f"{tag}_{variant}" if variant else tag | |
| def utc_stamp() -> str: | |
| return datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") | |
| def make_log_dir(policy: str, mode: str, suite: str, axis: str, variant: str = "", | |
| root: Path = REPO_ROOT) -> Path: | |
| """logs/{UTC ts}_{tag}/ for this cell's cell_tag -- the log dir and the result dir | |
| therefore carry the SAME name, so one is findable from the other. Axis is normalized to | |
| underscores so a space-separated category name ("Sensor Noise") stays one path component. | |
| Shared by BOTH drivers. Before this, run_sr.py stamped LOCAL time while run_gr00t.py stamped UTC, | |
| and run_gr00t.py additionally used the literal "gr00tn17" instead of its own --policy -- | |
| two runs differing only by mode/suite/axis truncated each other's shard logs into one directory. | |
| """ | |
| tag = cell_tag(policy, mode, suite, axis.replace(" ", "_"), variant) | |
| return root / "logs" / f"{utc_stamp()}_{tag}" | |
| class Cell: | |
| """One ladder cell: a (suite, axis, mode) triple plus everything needed to run and score | |
| it. Frozen -- with_mode is the only way to vary one, so a cell's tag can never fall | |
| out of step with the mode that produced it.""" | |
| rung: str | |
| suite: str | |
| axis: str | |
| bench: str | |
| mode: str | |
| expected_n: int | |
| filter_file: Path | None = None | |
| extra_env: Mapping[str, str] = field(default_factory=dict) | |
| comparators: tuple[Comparator, ...] = () | |
| gate: Gate | None = None | |
| num_clients: int = 8 | |
| policy: str = "stablevla" | |
| variant: str = "" | |
| def tag(self) -> str: | |
| return cell_tag(self.policy, self.mode, self.suite, self.axis, self.variant) | |
| def key(self) -> str: | |
| """suite:axis -- what --cells matches on.""" | |
| return f"{self.suite}:{self.axis}" | |
| def category_value(self) -> str: | |
| """The axis as the harness's --args.category_value wants it (spaces, not underscores).""" | |
| return self.axis.replace("_", " ") | |
| def result_dir(self, results_root: Path) -> Path: | |
| return results_root / f"plus_{self.bench}" / self.axis / self.tag | |
| def comparator_dir(self, results_root: Path, comparator: Comparator) -> Path: | |
| return results_root / f"plus_{self.bench}" / self.axis / comparator.tag | |
| def comparator(self, label: str) -> Comparator | None: | |
| return next((c for c in self.comparators if c.label == label), None) | |
| def with_mode(self, mode: str) -> "Cell": | |
| """Swap this cell's mode. The tag follows automatically -- it is derived, not stored.""" | |
| return replace(self, mode=mode) | |
| class CellScore: | |
| """What one cell's result directory currently contains.""" | |
| cell: Cell | |
| count: DirCount | |
| def successes(self) -> int: | |
| return self.count.successes | |
| def actual_n(self) -> int: | |
| return self.count.n | |
| def rate(self) -> float: | |
| return self.count.rate | |
| def complete(self) -> bool: | |
| return self.count.n == self.cell.expected_n | |
| class CellReport: | |
| """A scored cell plus its verdict and (optionally) its paired McNemar line.""" | |
| score: CellScore | |
| verdict: GateVerdict | |
| mcnemar: str | None = None | |
| def line(self) -> str: | |
| """The one RESULT ... line printed per cell.""" | |
| cell = self.score.cell | |
| comparator = ( | |
| cell.comparator(cell.gate.vs) if cell.gate | |
| else (cell.comparators[0] if cell.comparators else None) | |
| ) | |
| parts: list[str] = [] | |
| if comparator is not None and self.score.actual_n: | |
| parts.append(f"delta vs {comparator.label} = {self.score.rate - comparator.rate:+.1f}pp") | |
| if self.mcnemar: | |
| parts.append(self.mcnemar) | |
| rate = f"{self.score.rate:.1f}%" if self.score.actual_n else "--" | |
| status = "PASS" if self.verdict.passed else "FAIL" | |
| return ( | |
| f"RESULT {cell.tag}: {self.score.successes}/{self.score.actual_n} = {rate}" | |
| f" ({', '.join(parts)}) [{status}: {self.verdict.message}]" | |
| ) | |
| # ===================================================================================================== | |
| # the ladder itself | |
| # ===================================================================================================== | |
| class LadderConfig: | |
| """Everything sr_ladder.yaml + suites.yaml + modes.sh say, resolved once. | |
| Constructed once per driver invocation and handed to every collaborator, replacing the | |
| (ladder_doc, suites, results_root, paths) four-tuple that used to be threaded through most of | |
| this module's signatures. | |
| """ | |
| def __init__(self, doc: Mapping[str, Any], results_root: Path | None = None, | |
| root: Path = REPO_ROOT) -> None: | |
| self.doc = doc | |
| self.root = root | |
| self.paths = LadderPaths.from_doc(doc, root) | |
| self.scorers = ExternalScorers(self.paths) | |
| self.modes = ModeRegistry(self.paths.modes_sh) | |
| self.defaults = Defaults.from_doc(doc["defaults"]) | |
| self.limits = PreflightLimits.from_doc(doc["preflight"]) | |
| self.suites = self._load_suites() | |
| self.results_root = Path(results_root) if results_root else self.scorers.default_results_root() | |
| def load(cls, ladder_yaml: Path | None = None, results_root: Path | None = None) -> "LadderConfig": | |
| return cls(load_yaml(ladder_yaml or LADDER_YAML), results_root) | |
| def suite(self, name: str) -> SuiteInfo: | |
| if name not in self.suites: | |
| raise ValueError(f"unknown suite {name!r} (known: {sorted(self.suites)})") | |
| return self.suites[name] | |
| def default_mode(self, axis: str) -> str: | |
| """The per-axis default recipe from axis_default_mode. Shared with the GR00T driver, so | |
| the two can never disagree about which mode an axis defaults to.""" | |
| table = self.doc["axis_default_mode"] | |
| return table.get(axis, table["default"]) | |
| def expected_episodes(self, suite: str, axis: str) -> int: | |
| return int(self.doc["expected_episodes"][suite][axis]) | |
| def cells_for_rung(self, rung_name: str) -> list[Cell]: | |
| """Every cell of a named rung, either declared or auto-generated as the full grid. | |
| Raises: | |
| ValueError: No such rung. | |
| """ | |
| if rung_name not in self.doc["rungs"]: | |
| raise ValueError(f"unknown rung {rung_name!r}; known: {sorted(self.doc['rungs'])}") | |
| rung_doc = self.doc["rungs"][rung_name] | |
| if rung_doc.get("auto_generate") == "full_grid": | |
| return self._full_grid_cells(rung_name, Gate.from_dict(rung_doc["gate"])) | |
| return [self.cell_from_doc(rung_name, c) for c in rung_doc["cells"]] | |
| def cells_from_tokens(self, spec: str, mode: str | None = None) -> list[Cell]: | |
| """--cells suite:axis,... -> ad hoc cells. | |
| If an existing rung already declares this exact (suite, axis) pair, its comparators/gate are | |
| reused so an ad hoc cell isn't scored in a vacuum. | |
| Raises: | |
| ValueError: An unknown suite or axis token. | |
| """ | |
| axes = self.scorers.axes | |
| cells: list[Cell] = [] | |
| for token in (t.strip() for t in spec.split(",")): | |
| if not token: | |
| continue | |
| suite, _, axis = token.partition(":") | |
| if suite not in self.suites: | |
| raise ValueError(f"--cells: unknown suite {suite!r} in {token!r} (known: {sorted(self.suites)})") | |
| if axis not in axes: | |
| raise ValueError(f"--cells: unknown axis {axis!r} in {token!r} (known: {axes})") | |
| declared = self._find_declared_cell(suite, axis) | |
| cells.append(Cell( | |
| rung="adhoc", suite=suite, axis=axis, bench=self.scorers.bench(suite), | |
| mode=mode or self.default_mode(axis), | |
| expected_n=self.expected_episodes(suite, axis), | |
| filter_file=self._resolve_filter(None, axis, suite), | |
| comparators=(declared.comparators if declared | |
| else self.probe_comparators(suite, axis)), | |
| gate=declared.gate if declared else Gate.from_dict(self.doc["default_gate"]), | |
| num_clients=self.defaults.num_clients, | |
| )) | |
| return cells | |
| def probe_comparators(self, suite: str, axis: str) -> tuple[Comparator, ...]: | |
| """The PRE-EXISTING baseline result dirs already on disk for this (suite, axis). | |
| The full grid's comparators aren't hand-enumerated (28 cells would go stale the moment a new | |
| baseline lands): probe what is there, so a run never re-measures a baseline that has already | |
| been measured. Those directory names are historical, from whichever run produced them -- they | |
| are disk facts, not modes this repo can produce. Reuses score.py's count_dir so this | |
| can never disagree with it about what a tag directory scores. | |
| """ | |
| bench = self.scorers.bench(suite) | |
| if axis == "Robot_Initial_States": | |
| candidates = [("base", f"onfbase_{bench}_{axis}"), ("onf", f"onfv1_{bench}_{axis}")] | |
| else: | |
| candidates = [ | |
| ("base", f"cat_{axis}_base"), | |
| ("sentinel", f"cat_{axis}_sentinel"), | |
| ("base", f"lp_{bench}_{axis}"), | |
| ("prior", f"cndf_{bench}_{axis}"), | |
| ] | |
| found: list[Comparator] = [] | |
| seen: set[tuple[str, str]] = set() | |
| for label, tag in candidates: | |
| if (label, tag) in seen: | |
| continue | |
| directory = self.results_root / f"plus_{bench}" / axis / tag | |
| if not directory.is_dir(): | |
| continue | |
| count = self.scorers.count_dir(directory) | |
| if count.n: | |
| found.append(Comparator(label=label, tag=tag, successes=count.successes, n=count.n)) | |
| seen.add((label, tag)) | |
| return tuple(found) | |
| def cell_from_doc(self, rung_name: str, cell_doc: Mapping[str, Any]) -> Cell: | |
| """One cells: entry -> a Cell, resolving its filter, gate and client count.""" | |
| suite, axis = cell_doc["suite"], cell_doc["axis"] | |
| gate_doc = cell_doc.get("gate") or self.doc.get("default_gate") | |
| return Cell( | |
| rung=rung_name, suite=suite, axis=axis, bench=self.scorers.bench(suite), | |
| mode=cell_doc.get("mode") or self.default_mode(axis), | |
| expected_n=int(cell_doc["expected_n"]), | |
| variant=cell_doc.get("variant", ""), | |
| filter_file=self._resolve_filter(cell_doc.get("filter"), axis, suite), | |
| extra_env={k: str(v) for k, v in (cell_doc.get("env") or {}).items()}, | |
| comparators=tuple(Comparator(**c) for c in cell_doc.get("comparators", [])), | |
| gate=Gate.from_dict(gate_doc) if gate_doc else None, | |
| num_clients=int(cell_doc.get("num_clients", self.defaults.num_clients)), | |
| ) | |
| def _full_grid_cells(self, rung_name: str, gate: Gate) -> list[Cell]: | |
| return [ | |
| Cell( | |
| rung=rung_name, suite=suite, axis=axis, bench=self.scorers.bench(suite), | |
| mode=self.default_mode(axis), | |
| expected_n=self.expected_episodes(suite, axis), | |
| filter_file=self._resolve_filter(None, axis, suite), | |
| comparators=self.probe_comparators(suite, axis), | |
| gate=gate, num_clients=self.defaults.num_clients, | |
| ) | |
| for suite in ("object", "spatial", "goal", "long") | |
| for axis in self.scorers.axes | |
| ] | |
| def _find_declared_cell(self, suite: str, axis: str) -> Cell | None: | |
| for rung_name, rung_doc in self.doc["rungs"].items(): | |
| if rung_doc.get("auto_generate"): | |
| continue | |
| for cell_doc in rung_doc["cells"]: | |
| if cell_doc["suite"] == suite and cell_doc["axis"] == axis: | |
| return self.cell_from_doc(rung_name, cell_doc) | |
| return None | |
| def _resolve_filter(self, filt: str | None, axis: str, suite: str) -> Path | None: | |
| if filt in (None, "auto"): | |
| if filt == "auto" or axis == "Light_Conditions": | |
| return self.suite(suite).light_filter | |
| return None | |
| return self.root / filt | |
| def _load_suites(self) -> dict[str, SuiteInfo]: | |
| doc = load_yaml(self.paths.suites_yaml) | |
| return { | |
| name: SuiteInfo( | |
| name=name, | |
| bench=info["task_suite"], | |
| stablevla_ckpt=self.root / info["stablevla_ckpt"], | |
| fwm_dir=self.root / "data" / info["fwm_dir"], | |
| light_filter=self.root / info["light_filter"], | |
| ) | |
| for name, info in doc["suites"].items() | |
| } | |
| # ===================================================================================================== | |
| # child-process environment (see the module docstring). This dict (minus the per-process | |
| # CUDA_VISIBLE_DEVICES/PYTHONPATH layered on at launch time) is what gets recorded verbatim in the | |
| # ledger for reproducibility. | |
| # ===================================================================================================== | |
| def resolve_conda_env_prefix(name: str) -> Path | None: | |
| """Resolve a conda env's prefix without sourcing a shell: $CONDA_EXE's parent-of-parent, then | |
| the usual install prefixes.""" | |
| candidates: list[Path] = [] | |
| conda_exe = os.environ.get("CONDA_EXE") | |
| if conda_exe: | |
| candidates.append(Path(conda_exe).resolve().parent.parent / "envs" / name) | |
| candidates += [ | |
| base / "envs" / name | |
| for base in (Path.home() / "miniconda3", Path.home() / "anaconda3", | |
| Path.home() / "miniforge3", Path("/opt/conda"), Path("/opt/miniconda3")) | |
| ] | |
| return next((c for c in candidates if (c / "bin" / "python").exists()), None) | |
| def resolve_stablevla_python() -> Path | None: | |
| prefix = resolve_conda_env_prefix(STABLEVLA_CONDA_ENV) | |
| return (prefix / "bin" / "python") if prefix else None | |
| def nvml_preload_path() -> str | None: | |
| """The NVML shim to LD_PRELOAD into every child, or None on a box that doesn't need it. | |
| See NVML_PRELOAD_CANDIDATE for why.""" | |
| override = os.environ.get("ONF_NVML_PRELOAD") | |
| if override: | |
| return override if Path(override).exists() else None | |
| return str(NVML_PRELOAD_CANDIDATE) if NVML_PRELOAD_CANDIDATE.exists() else None | |
| def libero_home_dir() -> Path: | |
| return Path(os.environ.get("LIBERO_HOME", str(REPO_ROOT / "external" / "LIBERO-plus"))) | |
| def resolve_graph_dir(suite: str, env: Mapping[str, str] | None = None) -> Path: | |
| """The graph-artifact dir (g_nodes.npz / g_edges.npz / g_head.npz / g_track.npz) for suite. | |
| Args: | |
| suite: Suite name, for the default location. | |
| env: The CHILD's environment. Its GR_GRAPH_DIR is what the child will actually load, and | |
| passing it is not optional in practice: onf.config.Paths.graph honors GR_GRAPH_DIR from | |
| os.environ, and the driver never sets that variable on ITSELF -- it builds a per-cell | |
| env dict and hands it to the subprocess. So resolving without env silently reported and | |
| hard-checked outputs/<suite>/latest/artifacts for every arm that overrides the dir, | |
| which is every belief arm on this branch. The recorded graph_hash and g_head_mtime for | |
| those runs describe a directory the run never opened, and an arm pointed at an | |
| incomplete dir would have passed the pre-launch check and died hours in. | |
| Returns: | |
| The directory the child will load. | |
| """ | |
| from onf.config import default_paths | |
| override = (env or {}).get("GR_GRAPH_DIR", "") | |
| return Path(override) if override else default_paths().graph(suite) | |
| class GraphArtifacts: | |
| """What a resolved graph dir actually contains, for the pre-launch check and the run record.""" | |
| graph_dir: Path | |
| ok: bool | |
| missing: tuple[str, ...] | |
| graph_hash: str | None = None | |
| g_head_mtime: str | None = None | |
| g_track_mtime: str | None = None | |
| def as_record(self) -> dict[str, Any]: | |
| """The subset that goes into run.json / the ledger row.""" | |
| return { | |
| "graph_dir": str(self.graph_dir), | |
| "graph_hash": self.graph_hash, | |
| "g_head_mtime": self.g_head_mtime, | |
| "g_track_mtime": self.g_track_mtime, | |
| } | |
| def graph_artifact_status(graph_dir: Path) -> GraphArtifacts: | |
| """Inspect a resolved graph dir for the two artifacts the sentinel needs. | |
| Args: | |
| graph_dir: A resolved graph-artifact directory. | |
| Returns: | |
| The status. graph_hash is read straight off g_head.npz's own stamp (no need to load | |
| nodes/edges/the network just to log it); the mtimes are ISO-8601 UTC strings. | |
| Never raises: a suite with no graph built yet gets ok=False and None fields, not a crash. | |
| Both drivers fail loud on not ok for any non-base mode BEFORE launching anything | |
| GPU-resident -- a missing g_track.npz otherwise means an unfit belief-filter kernel and | |
| onf.graph.run.readout.BasinReadout raising at the first check, hours into a run. | |
| """ | |
| import numpy as np | |
| from onf.graph.core import schema as graph_schema | |
| graph_dir = Path(graph_dir) | |
| head = graph_dir / graph_schema.HEAD_NPZ | |
| track = graph_dir / graph_schema.TRACK_NPZ | |
| missing = [str(p) for p in (head, track) if not p.exists()] | |
| graph_hash = head_mtime = track_mtime = None | |
| if head.exists(): | |
| try: | |
| with np.load(head, allow_pickle=False) as raw: | |
| if "graph_hash" in raw.files: | |
| graph_hash = str(raw["graph_hash"]) | |
| except Exception as exc: # noqa: BLE001 -- report, never crash a status probe | |
| missing.append(f"{head} (unreadable: {exc})") | |
| head_mtime = _iso_mtime(head) | |
| if track.exists(): | |
| track_mtime = _iso_mtime(track) | |
| return GraphArtifacts( | |
| graph_dir=graph_dir, ok=not missing, missing=tuple(missing), | |
| graph_hash=graph_hash, g_head_mtime=head_mtime, g_track_mtime=track_mtime, | |
| ) | |
| def _iso_mtime(path: Path) -> str: | |
| return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat(timespec="seconds") | |
| class EnvBuilder: | |
| """Builds the environment every child process of a cell inherits. | |
| The conda prefix, NVML preload and PYTHONPATHs are resolved ONCE per driver rather than per cell: | |
| they cannot change mid-run, and each involves filesystem probing. | |
| """ | |
| def __init__(self, config: LadderConfig) -> None: | |
| self._config = config | |
| self._libero_home = libero_home_dir() | |
| self._conda_prefix = resolve_conda_env_prefix(STABLEVLA_CONDA_ENV) | |
| self._nvml_preload = nvml_preload_path() | |
| self._server_pythonpath = self._pythonpath([STABLEVLA_ROOT, config.paths.harness_dir, ONF_SRC]) | |
| self._client_pythonpath = self._pythonpath( | |
| [config.paths.harness_dir, STABLEVLA_ROOT, self._libero_home, ONF_SRC, config.paths.shim_dir] | |
| ) | |
| def libero_home(self) -> Path: | |
| return self._libero_home | |
| def server_pythonpath(self) -> str: | |
| return self._server_pythonpath | |
| def client_pythonpath(self) -> str: | |
| return self._client_pythonpath | |
| def for_cell(self, cell: Cell) -> dict[str, str]: | |
| """The full env recipe for one cell, recorded verbatim in the ledger and run.json.""" | |
| task_classification = ( | |
| self._libero_home / "libero" / "libero" / "benchmark" / "task_classification.json" | |
| ) | |
| env: dict[str, str] = { | |
| "PYTHONNOUSERSITE": "1", | |
| "OMP_NUM_THREADS": "4", "MKL_NUM_THREADS": "4", "OPENBLAS_NUM_THREADS": "4", | |
| "LOAD_VISION": "0", | |
| "QNDF_DIR": str(self._config.suite(cell.suite).fwm_dir), | |
| "LIBERO_HOME": str(self._libero_home), | |
| "LIBERO_CONFIG_PATH": str(self._libero_home / "libero"), | |
| "LIBERO_PLUS_TASK_CLASSIFICATION": str(task_classification), | |
| "MUJOCO_GL": "osmesa", "PYOPENGL_PLATFORM": "osmesa", | |
| } | |
| if self._conda_prefix is not None: | |
| env["MAGICK_HOME"] = str(self._conda_prefix) | |
| env["LD_LIBRARY_PATH"] = self._prepend( | |
| str(self._conda_prefix / "lib"), os.environ.get("LD_LIBRARY_PATH", "") | |
| ) | |
| if self._nvml_preload is not None: | |
| # torch's CUDA caching allocator calls nvmlInit_v2 whenever it needs to GROW its pool and | |
| # hard-asserts on failure (a nondeterministic, memory-pressure-dependent crash: only the | |
| # shard that happens to need more pool at the wrong moment dies -- graph/track-mode runs | |
| # allocate more and hit this far more often than a plain base rollout). Every launched | |
| # process (server AND client shards) inherits the fix from here, not an ad-hoc export. | |
| env["LD_PRELOAD"] = self._prepend(self._nvml_preload, os.environ.get("LD_PRELOAD", "")) | |
| if cell.filter_file is not None: | |
| env["LIBERO_INSTANCE_ID_FILTER"] = str(cell.filter_file) | |
| env.update(self._config.modes.env_for(cell.mode)) | |
| env.update(cell.extra_env) # explicit per-cell overrides (a yaml cell's env: block) win last | |
| return env | |
| def _prepend(value: str, existing: str) -> str: | |
| return f"{value}:{existing}" if existing else value | |
| def _pythonpath(dirs: Sequence[Path]) -> str: | |
| parts = [str(d) for d in dirs] | |
| existing = os.environ.get("PYTHONPATH") | |
| if existing: | |
| parts.append(existing) | |
| return os.pathsep.join(parts) | |
| # ===================================================================================================== | |
| # GPU / load preflight — torch.cuda ONLY. nvidia-smi is broken on this box (NVML library/kernel | |
| # version mismatch); torch.cuda.device_count()/mem_get_info() work fine regardless. | |
| # ===================================================================================================== | |
| class GpuStatus: | |
| index: int | |
| free_gib: float | |
| total_gib: float | |
| ok: bool | |
| reason: str = "" | |
| class LoadStatus: | |
| load1: float | |
| ncpu: int | |
| threshold: float | |
| def ok(self) -> bool: | |
| return self.load1 <= self.threshold | |
| class CheckResult: | |
| ok: bool | |
| message: str | |
| class Preflight: | |
| """The "is this box ready, and are we being polite" checks.""" | |
| def __init__(self, config: LadderConfig) -> None: | |
| self._config = config | |
| self._limits = config.limits | |
| def gpus(self, indices: Sequence[int] | None = None) -> list[GpuStatus]: | |
| """Free-memory status per GPU. indices=None reports every visible device.""" | |
| import torch | |
| device_count = torch.cuda.device_count() | |
| wanted = list(indices) if indices is not None else list(range(device_count)) | |
| statuses = [] | |
| for index in wanted: | |
| if not 0 <= index < device_count: | |
| statuses.append(GpuStatus( | |
| index, 0.0, 0.0, False, | |
| f"GPU {index} does not exist (device_count={device_count})", | |
| )) | |
| continue | |
| free, total = torch.cuda.mem_get_info(index) | |
| free_gib, total_gib = free / 1024**3, total / 1024**3 | |
| ok = free_gib >= self._limits.min_free_gib | |
| reason = "" if ok else ( | |
| f"only {free_gib:.1f} GiB free (< {self._limits.min_free_gib:.1f} GiB threshold)" | |
| ) | |
| statuses.append(GpuStatus(index, free_gib, total_gib, ok, reason)) | |
| return statuses | |
| def load(self) -> LoadStatus: | |
| ncpu = os.cpu_count() or 1 | |
| return LoadStatus(load1=os.getloadavg()[0], ncpu=ncpu, | |
| threshold=ncpu * self._limits.max_loadavg_factor) | |
| def libero_plus(self, timeout_s: float = 120.0) -> CheckResult: | |
| """Verify the LIBERO-Plus checkout resolves and reports the episode counts the ladder expects.""" | |
| libero_home = libero_home_dir() | |
| benchmark_init = libero_home / "libero" / "libero" / "benchmark" / "__init__.py" | |
| if not libero_home.is_dir() or not benchmark_init.exists(): | |
| return CheckResult(False, ( | |
| f"external/LIBERO-plus missing/empty at {libero_home} " | |
| f"(no libero/libero/benchmark/__init__.py) -- clone it, see docs/SETUP.md step 1" | |
| )) | |
| python = resolve_stablevla_python() | |
| if python is None: | |
| return CheckResult(False, f"cannot locate the `{STABLEVLA_CONDA_ENV}` conda env's python") | |
| snippet = ("from libero.libero.benchmark import get_ids_by_category as g; " | |
| "d = g('Robot Initial States'); print({k: len(v[0]) for k, v in d.items()})") | |
| env = dict(os.environ) | |
| env.update({ | |
| "LIBERO_HOME": str(libero_home), | |
| "LIBERO_CONFIG_PATH": str(libero_home / "libero"), | |
| "PYTHONPATH": EnvBuilder._pythonpath([libero_home]), | |
| "PYTHONNOUSERSITE": "1", | |
| "OMP_NUM_THREADS": "4", "MKL_NUM_THREADS": "4", "OPENBLAS_NUM_THREADS": "4", | |
| }) | |
| try: | |
| proc = subprocess.run([str(python), "-c", snippet], cwd=self._config.root, env=env, | |
| capture_output=True, text=True, timeout=timeout_s) | |
| except Exception as exc: # noqa: BLE001 -- report, don't crash --check-env | |
| return CheckResult(False, f"LIBERO-Plus check crashed: {exc}") | |
| if proc.returncode != 0: | |
| tail = "\n".join(proc.stderr.strip().splitlines()[-15:]) | |
| return CheckResult(False, f"LIBERO-Plus check FAILED (rc={proc.returncode}):\n{tail}") | |
| counts = self._parse_counts(proc.stdout) | |
| if counts is None: | |
| return CheckResult(False, f"LIBERO-Plus check: could not parse output: {proc.stdout!r}") | |
| # Expected "Robot Initial States" counts per bench, read from the ladder yaml's | |
| # expected_episodes table (via SUITE_BENCH) rather than a second hardcoded copy. | |
| expected = { | |
| self._config.scorers.bench(suite): table["Robot_Initial_States"] | |
| for suite, table in self._config.doc["expected_episodes"].items() | |
| } | |
| if any(counts.get(bench) != n for bench, n in expected.items()): | |
| return CheckResult(False, f"LIBERO-Plus counts mismatch: got {counts}, expected {expected}") | |
| return CheckResult(True, f"counts OK: {counts}") | |
| def _parse_counts(stdout: str) -> dict[str, int] | None: | |
| lines = stdout.strip().splitlines() | |
| match = re.search(r"\{.*\}", lines[-1]) if lines else None | |
| if not match: | |
| return None | |
| try: | |
| return ast.literal_eval(match.group(0)) | |
| except (ValueError, SyntaxError): | |
| return None | |
| # ===================================================================================================== | |
| # ledger — outputs/sr/ledger.jsonl, one append-only record per cell attempt (a "running" record at | |
| # launch, a final complete/incomplete/crashed record after scoring). Uses onf.config.Paths.outputs() | |
| # so it honors ONF_OUTPUTS like every other StageLogger-based run dir in this repo. | |
| # ===================================================================================================== | |
| def default_ledger_path() -> Path: | |
| from onf.config import default_paths | |
| return default_paths().outputs("sr", "ledger.jsonl") | |
| class LedgerRecord: | |
| """One row of ledger.jsonl. Written twice per attempt: running, then the outcome.""" | |
| tag: str | |
| suite: str | |
| bench: str | |
| axis: str | |
| mode: str | |
| status: RunStatus | |
| ts: str = "" | |
| gpu: int | None = None | |
| port: int | None = None | |
| expected_n: int | None = None | |
| actual_n: int | None = None | |
| successes: int | None = None | |
| rate: float | None = None | |
| wall_s: float | None = None | |
| episodes_per_min: float | None = None | |
| git_sha: str | None = None | |
| git_dirty: bool | None = None | |
| graph_dir: str | None = None | |
| graph_hash: str | None = None | |
| g_head_mtime: str | None = None | |
| result_dir: str | None = None | |
| log_dir: str | None = None | |
| env: dict[str, str] = field(default_factory=dict) | |
| error: str | None = None | |
| def to_dict(self) -> dict[str, Any]: | |
| """JSON-ready payload; stamps ts at write time when the caller left it blank.""" | |
| payload = dataclasses.asdict(self) | |
| payload["status"] = self.status.value | |
| payload["ts"] = self.ts or datetime.now(timezone.utc).isoformat(timespec="seconds") | |
| return payload | |
| def from_dict(cls, payload: Mapping[str, Any]) -> "LedgerRecord": | |
| """Parse one JSONL row, tolerating rows written by an older schema: unknown keys are dropped, | |
| absent identity fields become "", and an unrecognised status reads as CRASHED.""" | |
| known = {f.name for f in dataclasses.fields(cls)} | |
| data = {k: v for k, v in payload.items() if k in known} | |
| for name in ("tag", "suite", "bench", "axis", "mode"): | |
| data.setdefault(name, "") | |
| try: | |
| data["status"] = RunStatus(data.get("status", "crashed")) | |
| except ValueError: | |
| data["status"] = RunStatus.CRASHED | |
| return cls(**data) | |
| class Ledger: | |
| """Append-only JSONL run record. Thread-safe: one worker thread per GPU writes to it.""" | |
| COLUMNS = ( | |
| "ts", "tag", "suite", "axis", "mode", "status", "actual_n", "expected_n", | |
| "successes", "rate", "wall_s", "episodes_per_min", "gpu", | |
| ) | |
| def __init__(self, path: Path | None = None) -> None: | |
| self._path = path or default_ledger_path() | |
| self._lock = threading.Lock() | |
| def path(self) -> Path: | |
| return self._path | |
| def append(self, record: LedgerRecord) -> None: | |
| self._path.parent.mkdir(parents=True, exist_ok=True) | |
| line = json.dumps(record.to_dict(), default=str) | |
| with self._lock, open(self._path, "a") as f: | |
| f.write(line + "\n") | |
| def read(self) -> list[LedgerRecord]: | |
| if not self._path.exists(): | |
| return [] | |
| with open(self._path) as f: | |
| return [LedgerRecord.from_dict(json.loads(line)) for line in f if line.strip()] | |
| def latest(self, tag: str, mode: str | None = None, | |
| records: Sequence[LedgerRecord] | None = None) -> LedgerRecord | None: | |
| """Last (most recent -- JSONL is append-ordered) record for tag, or None. | |
| mode, if given, must ALSO match. Tags bake the mode into their own string (see | |
| cell_tag), so this is defense in depth -- it guards a ledger record written before | |
| that fix landed (an old-style tag with no mode suffix) from being mistaken for a match. | |
| """ | |
| candidates = records if records is not None else self.read() | |
| match: LedgerRecord | None = None | |
| for record in candidates: | |
| if record.tag == tag and (mode is None or record.mode == mode): | |
| match = record | |
| return match | |
| def table(self, records: Sequence[LedgerRecord] | None = None) -> str: | |
| """The ledger rendered as an aligned text table.""" | |
| source = records if records is not None else self.read() | |
| rows = [ | |
| {c: ("" if getattr(r, c, None) is None else getattr(r, c)) for c in self.COLUMNS} | |
| for r in source | |
| ] | |
| for row in rows: | |
| if isinstance(row["status"], RunStatus): | |
| row["status"] = row["status"].value | |
| widths = {c: max(len(c), *(len(str(row[c])) for row in rows)) for c in self.COLUMNS} | |
| header = " ".join(c.ljust(widths[c]) for c in self.COLUMNS) | |
| lines = [header, "-" * len(header)] | |
| lines += [" ".join(str(row[c]).ljust(widths[c]) for c in self.COLUMNS) for row in rows] | |
| return "\n".join(lines) | |
| # ===================================================================================================== | |
| # run.json provenance — written into EVERY result directory by both drivers so any success-rate number | |
| # can later be traced back to the exact code + artifacts that produced it. | |
| # ===================================================================================================== | |
| def write_run_json(result_dir: Path, **fields: Any) -> Path: | |
| """Write <result_dir>/run.json and return its path.""" | |
| result_dir = Path(result_dir) | |
| result_dir.mkdir(parents=True, exist_ok=True) | |
| payload = {"written_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), **fields} | |
| out = result_dir / "run.json" | |
| with open(out, "w") as f: | |
| json.dump(payload, f, indent=2, default=str) | |
| return out | |
| class GitState: | |
| """The repo's commit + dirtiness at launch. Both fields are None when git is unavailable.""" | |
| sha: str | None | |
| dirty: bool | None | |
| def probe(cls, root: Path = REPO_ROOT) -> "GitState": | |
| return cls(sha=cls._run(["rev-parse", "HEAD"], root), dirty=cls._dirty(root)) | |
| def _run(args: Sequence[str], root: Path) -> str | None: | |
| try: | |
| proc = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, timeout=10) | |
| except (OSError, subprocess.SubprocessError): | |
| return None | |
| return proc.stdout.strip() if proc.returncode == 0 else None | |
| def _dirty(cls, root: Path) -> bool | None: | |
| out = cls._run(["status", "--porcelain"], root) | |
| return None if out is None else bool(out) | |
| # ===================================================================================================== | |
| # scoring / completeness / gates | |
| # ===================================================================================================== | |
| class CellScorer: | |
| """Scores a cell's result dir, applies its gate, and pairs it against a comparator.""" | |
| def __init__(self, config: LadderConfig) -> None: | |
| self._config = config | |
| self._scorers = config.scorers | |
| self._results_root = config.results_root | |
| def score(self, cell: Cell) -> CellScore: | |
| return CellScore(cell=cell, count=self._scorers.count_dir(cell.result_dir(self._results_root))) | |
| def report(self, cell: Cell, allow_partial: bool) -> CellReport: | |
| score = self.score(cell) | |
| return CellReport( | |
| score=score, | |
| verdict=self.verdict(score, allow_partial), | |
| mcnemar=self.mcnemar(cell) if cell.gate else None, | |
| ) | |
| def verdict(self, score: CellScore, allow_partial: bool) -> GateVerdict: | |
| """Completeness first, then the cell's own gate. An incomplete cell fails regardless of its | |
| gate: a partial mp4 glob silently under-counts n and makes every rate meaningless.""" | |
| cell = score.cell | |
| if not score.complete and not allow_partial: | |
| return GateVerdict(False, ( | |
| f"INCOMPLETE: {score.actual_n}/{cell.expected_n} episodes " | |
| f"(excluded from totals; pass --allow-partial to score anyway)" | |
| )) | |
| if cell.gate is None: | |
| return GateVerdict(True, "no gate configured") | |
| return cell.gate.evaluate(score) | |
| def mcnemar(self, cell: Cell, label: str | None = None) -> str | None: | |
| """The paired McNemar discordance vs a comparator, or None when it can't be computed. | |
| On this deterministic bench an effect is 1-4 episodes, so the marginal rate alone is not | |
| interpretable -- the paired test is what says whether the same episodes flipped. | |
| """ | |
| comparator_label = label or (cell.gate.vs if cell.gate else None) | |
| if comparator_label is None: | |
| return None | |
| comparator = cell.comparator(comparator_label) | |
| if comparator is None: | |
| return None | |
| comparator_dir = cell.comparator_dir(self._results_root, comparator) | |
| cell_dir = cell.result_dir(self._results_root) | |
| if not comparator_dir.exists() or not cell_dir.exists(): | |
| return None | |
| import numpy as np | |
| baseline = self._scorers.episode_outcomes(comparator_dir) | |
| measured = self._scorers.episode_outcomes(cell_dir) | |
| keys = sorted(set(baseline) & set(measured)) | |
| if not keys: | |
| return f"McNemar vs {comparator.label}: no shared episodes" | |
| base_vec = np.array([baseline[k] for k in keys]) | |
| cell_vec = np.array([measured[k] for k in keys]) | |
| b_only = int((base_vec & (~cell_vec)).sum()) # comparator-only success | |
| c_only = int(((~base_vec) & cell_vec).sum()) # this cell-only success | |
| p = self._scorers.exact_p(b_only, c_only) | |
| return f"McNemar b/c={b_only}/{c_only} p={p:.2e} n={len(keys)}" | |
| # ===================================================================================================== | |
| # scheduling — longest-processing-time (order by expected_n desc, greedily assign to the | |
| # currently-lightest GPU track) + a rolling episodes/min estimate for the --dry-run makespan. | |
| # ===================================================================================================== | |
| class ScheduledCell: | |
| gpu: int | |
| cell: Cell | |
| projected_min: float | |
| class Schedule: | |
| """An LPT assignment of cells to GPU tracks, with a projected makespan.""" | |
| assignments: dict[int, list[Cell]] | |
| lines: tuple[ScheduledCell, ...] | |
| per_gpu_minutes: dict[int, float] | |
| def makespan_min(self) -> float: | |
| return max(self.per_gpu_minutes.values()) if self.per_gpu_minutes else 0.0 | |
| def build(cls, cells: Sequence[Cell], gpus: Sequence[int], config: LadderConfig, | |
| ledger_records: Sequence[LedgerRecord]) -> "Schedule": | |
| assignments: dict[int, list[Cell]] = {g: [] for g in gpus} | |
| load = dict.fromkeys(gpus, 0) | |
| for cell in sorted(cells, key=lambda c: c.expected_n, reverse=True): | |
| gpu = min(load, key=lambda k: (load[k], k)) | |
| assignments[gpu].append(cell) | |
| load[gpu] += cell.expected_n | |
| lines: list[ScheduledCell] = [] | |
| per_gpu_minutes: dict[int, float] = {} | |
| for gpu, assigned in assignments.items(): | |
| total = 0.0 | |
| for cell in assigned: | |
| rate = cls.rolling_rate(cell, config, ledger_records) | |
| minutes = cell.expected_n / rate if rate else float("inf") | |
| total += minutes | |
| lines.append(ScheduledCell(gpu=gpu, cell=cell, projected_min=minutes)) | |
| per_gpu_minutes[gpu] = total | |
| return cls(assignments=assignments, lines=tuple(lines), per_gpu_minutes=per_gpu_minutes) | |
| def rolling_rate(cell: Cell, config: LadderConfig, | |
| records: Sequence[LedgerRecord]) -> float: | |
| """Mean episodes/min over the last 5 COMPLETE runs of this exact cell, else the yaml default.""" | |
| matches = [ | |
| float(r.episodes_per_min) | |
| for r in records | |
| if r.suite == cell.suite and r.axis == cell.axis and r.mode == cell.mode | |
| and r.status is RunStatus.COMPLETE and r.episodes_per_min | |
| ] | |
| if matches: | |
| recent = matches[-5:] | |
| return sum(recent) / len(recent) | |
| return config.defaults.episodes_per_min | |
| # ===================================================================================================== | |
| # resume — skip a cell only when the ledger says complete AND the mp4 count still matches; otherwise | |
| # purge the (partial/stale) result dir, because a partial dir silently poisons score.py's glob count. | |
| # ===================================================================================================== | |
| class CellPlan: | |
| cell: Cell | |
| action: CellAction | |
| reason: str | |
| class ResumePlanner: | |
| """Decides what to do with a cell's existing result dir. | |
| plan is a PURE decision (no filesystem writes) so --dry-run can preview it safely; | |
| apply performs the purge and is only ever called from the real (non-dry-run) path. | |
| """ | |
| def __init__(self, config: LadderConfig, ledger: Ledger) -> None: | |
| self._config = config | |
| self._ledger = ledger | |
| self._results_root = config.results_root | |
| def plan(self, cell: Cell, resume: bool, | |
| records: Sequence[LedgerRecord] | None = None) -> CellPlan: | |
| directory = cell.result_dir(self._results_root) | |
| if not (directory.exists() and any(directory.rglob("*.mp4"))): | |
| return CellPlan(cell, CellAction.RUN, "no existing result dir") | |
| if not resume: | |
| return CellPlan(cell, CellAction.ERROR, | |
| f"result dir already has content at {directory} (pass --resume)") | |
| record = self._ledger.latest(cell.tag, cell.mode, records) | |
| if record and record.status is RunStatus.COMPLETE: | |
| count = self._config.scorers.count_dir(directory) | |
| if count.n == cell.expected_n: | |
| return CellPlan(cell, CellAction.SKIP, ( | |
| f"ledger says complete and {count.n} mp4s match expected_n={cell.expected_n}" | |
| )) | |
| return CellPlan(cell, CellAction.PURGE, "partial/stale result dir -- would be purged and re-run") | |
| def apply(self, plan: CellPlan) -> None: | |
| """Perform the filesystem side effect for a purge verdict.""" | |
| if plan.action is CellAction.PURGE: | |
| shutil.rmtree(plan.cell.result_dir(self._results_root)) | |
| # ===================================================================================================== | |
| # process supervision — one GPU-resident policy server + N sharded sim clients per cell. | |
| # ===================================================================================================== | |
| class ProcessSupervisor: | |
| """Tracks every live child so Ctrl-C can't orphan a GPU-resident process.""" | |
| def __init__(self) -> None: | |
| self._procs: set[subprocess.Popen] = set() | |
| self._lock = threading.Lock() | |
| def spawn(self, cmd: Sequence[str], env: Mapping[str, str], stdout: Any, | |
| cwd: Path = REPO_ROOT) -> subprocess.Popen: | |
| proc = subprocess.Popen(list(cmd), cwd=cwd, env=dict(env), stdout=stdout, | |
| stderr=subprocess.STDOUT) | |
| with self._lock: | |
| self._procs.add(proc) | |
| return proc | |
| def forget(self, proc: subprocess.Popen) -> None: | |
| """Stop tracking a process the caller is now reaping itself.""" | |
| with self._lock: | |
| self._procs.discard(proc) | |
| def terminate_all(self, grace_s: float = 2.0) -> None: | |
| with self._lock: | |
| procs = list(self._procs) | |
| for proc in procs: | |
| self._quietly(proc.terminate) | |
| time.sleep(grace_s) | |
| for proc in procs: | |
| if proc.poll() is None: | |
| self._quietly(proc.kill) | |
| def install_signal_handlers(self) -> None: | |
| """Tear everything down on SIGINT/SIGTERM. Installed only on the real launch path, so a | |
| --dry-run never replaces the caller's handlers.""" | |
| def handler(signum: int, _frame: FrameType | None) -> None: | |
| self.terminate_all() | |
| sys.exit(128 + signum) | |
| signal.signal(signal.SIGINT, handler) | |
| signal.signal(signal.SIGTERM, handler) | |
| def _quietly(action: Any) -> None: | |
| try: | |
| action() | |
| except OSError: # best-effort teardown, never let this raise | |
| pass | |
| class PortAllocator: | |
| """Hands out policy-server ports that are free both in this process and across the box. | |
| A process-local counter alone is not enough: two concurrent run_sr.py invocations (a rung on | |
| one GPU plus an ad-hoc --cells run on another) both start at base_port, and the second | |
| one's clients then connect to the FIRST one's policy server. That is silent and it mis-scores -- | |
| caught in practice with a goal Robot-Init cell whose clients reached a long-checkpoint | |
| server on the shared port and produced 121 episodes under the wrong policy, with nothing in any | |
| log saying so. | |
| A bind-probe alone is not enough either: a policy server takes minutes to load its checkpoint | |
| before it binds, so a second process probing in that window still sees the port as free. Hence | |
| the filesystem reservation, taken the instant a port is chosen and outliving the gap until the | |
| server actually listens. A reservation lasts as long as the owning process (dropped by the atexit | |
| hook); one left behind by a killed run is reclaimed by the next caller's pid-liveness check. | |
| """ | |
| MAX_PROBES = 256 | |
| def __init__(self, base_port: int, reservation_dir: Path | None = None) -> None: | |
| self._base_port = base_port | |
| self._dir = reservation_dir or (REPO_ROOT / "logs" / ".ports") | |
| self._lock = threading.Lock() | |
| self._counter = itertools.count() | |
| self._held: set[int] = set() | |
| atexit.register(self.release_all) | |
| def claim(self) -> int: | |
| """The next port at or above base_port that is both unbound AND unreserved. | |
| Raises: | |
| RuntimeError: No free port within MAX_PROBES of the base. | |
| """ | |
| with self._lock: | |
| self._dir.mkdir(parents=True, exist_ok=True) | |
| for _ in range(self.MAX_PROBES): | |
| port = self._base_port + next(self._counter) | |
| if self._bindable(port) and self._reserve(port): | |
| self._held.add(port) | |
| return port | |
| raise RuntimeError( | |
| f"no free port in [{self._base_port}, {self._base_port + self.MAX_PROBES}) " | |
| f"-- is something leaking servers?" | |
| ) | |
| def release_all(self) -> None: | |
| for port in list(self._held): | |
| (self._dir / str(port)).unlink(missing_ok=True) | |
| self._held.discard(port) | |
| def _bindable(port: int) -> bool: | |
| """True iff nothing is listening -- tested by actually binding, not by connecting (a | |
| connect-probe races with a server still loading its checkpoint and not yet accepting).""" | |
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: | |
| sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | |
| try: | |
| sock.bind(("0.0.0.0", port)) | |
| return True | |
| except OSError: | |
| return False | |
| def _reserve(self, port: int) -> bool: | |
| """Cross-process claim: create <dir>/<port> exclusively, holding this pid.""" | |
| marker = self._dir / str(port) | |
| try: | |
| fd = os.open(marker, os.O_CREAT | os.O_EXCL | os.O_WRONLY) | |
| except FileExistsError: | |
| return self._reclaim_if_stale(marker) | |
| with os.fdopen(fd, "w") as f: | |
| f.write(f"{os.getpid()}\n") | |
| return True | |
| def _reclaim_if_stale(marker: Path) -> bool: | |
| """Take over a reservation whose owning pid is gone; refuse while it is alive.""" | |
| try: | |
| content = marker.read_text().strip() | |
| owner: int | None = int(content) if content else None | |
| except (ValueError, OSError): | |
| owner = None | |
| if owner is not None: | |
| try: | |
| os.kill(owner, 0) # signal 0 = liveness probe, sends nothing | |
| return False # a live run_sr owns this port | |
| except (ProcessLookupError, PermissionError, OSError): | |
| pass # stale reservation from a dead run -- take it over | |
| try: | |
| marker.write_text(f"{os.getpid()}\n") | |
| return True | |
| except OSError: | |
| return False | |
| class PolicyServer: | |
| """The GPU-resident StableVLA policy server for one cell, as a context manager. | |
| __enter__ starts it and blocks until its port accepts; __exit__ always tears it down, | |
| which is what stops a client-side crash from leaking a process holding GPU memory. | |
| """ | |
| def __init__(self, config: LadderConfig, supervisor: ProcessSupervisor, python: Path, | |
| checkpoint: Path, port: int, log_dir: Path, env: Mapping[str, str]) -> None: | |
| self._config = config | |
| self._supervisor = supervisor | |
| self._python = python | |
| self._checkpoint = checkpoint | |
| self._port = port | |
| self._log_path = log_dir / "server.log" | |
| self._env = dict(env) | |
| self._proc: subprocess.Popen | None = None | |
| self._log: TextIO | None = None | |
| def __enter__(self) -> "PolicyServer": | |
| self._log = open(self._log_path, "w") | |
| self._proc = self._supervisor.spawn( | |
| [str(self._python), str(self._config.paths.policy_server), | |
| "--ckpt_path", str(self._checkpoint), "--port", str(self._port), | |
| "--cuda", "0", "--use_bf16"], | |
| env=self._env, stdout=self._log, | |
| ) | |
| try: | |
| self._wait_ready(self._config.defaults.server_ready_timeout_s) | |
| except BaseException: | |
| self.__exit__(None, None, None) | |
| raise | |
| return self | |
| def __exit__(self, *_exc: Any) -> None: | |
| if self._proc is not None: | |
| self._supervisor.forget(self._proc) | |
| self._proc.terminate() | |
| try: | |
| self._proc.wait(timeout=15) | |
| except subprocess.TimeoutExpired: | |
| self._proc.kill() | |
| self._proc.wait(timeout=15) | |
| self._proc = None | |
| if self._log is not None: | |
| self._log.close() | |
| self._log = None | |
| def _wait_ready(self, timeout_s: float, poll_every: float = 2.0) -> None: | |
| assert self._proc is not None | |
| deadline = time.time() + timeout_s | |
| while time.time() < deadline: | |
| if self._proc.poll() is not None: | |
| raise RuntimeError( | |
| f"policy server exited before ready (rc={self._proc.returncode}); " | |
| f"log tail:\n{self._log_tail()}" | |
| ) | |
| try: | |
| with socket.create_connection(("127.0.0.1", self._port), timeout=1): | |
| return | |
| except OSError: | |
| time.sleep(poll_every) | |
| raise RuntimeError(f"policy server not ready after {timeout_s:.0f}s on port {self._port}") | |
| def _log_tail(self, lines: int = 40) -> str: | |
| if not self._log_path.exists(): | |
| return "" | |
| return "\n".join(self._log_path.read_text(errors="replace").splitlines()[-lines:]) | |
| class LaunchResult: | |
| wall_s: float | |
| client_returncodes: tuple[int, ...] | |
| def crashed(self) -> bool: | |
| return any(rc != 0 for rc in self.client_returncodes) | |
| def crash_note(self) -> str: | |
| """How the RESULT line reports a crash, or "" when every client exited 0. | |
| A crashed cell used to print as merely INCOMPLETE, because the verdict is computed from the | |
| episode count alone and never sees the return codes -- only the ledger recorded the truth. | |
| One Light_Conditions cell died 156 s into a 45-minute run, wrote 4 of 273 episodes, and | |
| printed "4/4 = 100.0% [FAIL: INCOMPLETE]", which reads like a short run rather than a dead | |
| one. | |
| """ | |
| bad = [rc for rc in self.client_returncodes if rc != 0] | |
| if not bad: | |
| return "" | |
| return (f" [CRASHED: {len(bad)}/{len(self.client_returncodes)} clients exited " | |
| f"{sorted(set(bad))} -- this cell did not finish; the episode count below is " | |
| "whatever it had written by then]") | |
| class CellRunner: | |
| """Runs one cell end to end: provenance, launch, score, ledger, verdict.""" | |
| def __init__(self, config: LadderConfig, env_builder: EnvBuilder, scorer: CellScorer, | |
| ledger: Ledger, ports: PortAllocator, supervisor: ProcessSupervisor, | |
| allow_partial: bool = False) -> None: | |
| self._config = config | |
| self._env_builder = env_builder | |
| self._scorer = scorer | |
| self._ledger = ledger | |
| self._ports = ports | |
| self._supervisor = supervisor | |
| self._allow_partial = allow_partial | |
| self._git = GitState.probe(config.root) | |
| self._python = resolve_stablevla_python() | |
| def run(self, cell: Cell, gpu: int) -> ExitCode: | |
| """Run and score one cell on one GPU. | |
| Returns: | |
| OK when it completed and cleared its gate, GATE_FAILED when it completed but did | |
| not, ERROR when it crashed or came back incomplete. A crash is recorded in the ledger | |
| and returned, never raised -- one bad cell must not take the whole schedule down. | |
| """ | |
| if self._python is None: | |
| raise RuntimeError(f"cannot locate the `{STABLEVLA_CONDA_ENV}` conda env's python") | |
| port = self._ports.claim() | |
| log_dir = make_log_dir(cell.policy, cell.mode, cell.suite, cell.axis, cell.variant, | |
| self._config.root) | |
| env = self._env_builder.for_cell(cell) | |
| artifacts = self._require_graph_artifacts(cell, env) | |
| result_dir = cell.result_dir(self._config.results_root) | |
| write_run_json( | |
| result_dir, policy=cell.policy, mode=cell.mode, suite=cell.suite, axis=cell.axis, | |
| expected_n=cell.expected_n, git_commit=self._git.sha, git_dirty=self._git.dirty, | |
| mode_env=env, **artifacts.as_record(), | |
| ) | |
| base = dict( | |
| tag=cell.tag, suite=cell.suite, bench=cell.bench, axis=cell.axis, mode=cell.mode, | |
| gpu=gpu, port=port, expected_n=cell.expected_n, git_sha=self._git.sha, | |
| git_dirty=self._git.dirty, env=env, result_dir=str(result_dir), log_dir=str(log_dir), | |
| graph_dir=str(artifacts.graph_dir), graph_hash=artifacts.graph_hash, | |
| g_head_mtime=artifacts.g_head_mtime, | |
| ) | |
| self._ledger.append(LedgerRecord(status=RunStatus.RUNNING, **base)) | |
| try: | |
| launch = self._launch(cell, gpu, port, log_dir, env) | |
| except Exception as exc: # noqa: BLE001 -- record the crash, don't kill the schedule | |
| self._ledger.append(LedgerRecord(status=RunStatus.CRASHED, error=str(exc), **base)) | |
| print(f"RESULT {cell.tag}: CRASHED -- {exc}", file=sys.stderr) | |
| return ExitCode.ERROR | |
| report = self._scorer.report(cell, self._allow_partial) | |
| score = report.score | |
| rate = (score.actual_n / (launch.wall_s / 60.0)) if launch.wall_s > 0 and score.actual_n else 0.0 | |
| status = (RunStatus.CRASHED if launch.crashed | |
| else (RunStatus.COMPLETE if score.complete else RunStatus.INCOMPLETE)) | |
| self._ledger.append(LedgerRecord( | |
| status=status, actual_n=score.actual_n, successes=score.successes, | |
| rate=score.rate, wall_s=launch.wall_s, episodes_per_min=rate, **base, | |
| )) | |
| print(report.line + launch.crash_note) | |
| if status is not RunStatus.COMPLETE and not self._allow_partial: | |
| return ExitCode.ERROR | |
| return ExitCode.OK if report.verdict.passed else ExitCode.GATE_FAILED | |
| def _require_graph_artifacts(self, cell: Cell, env: Mapping[str, str]) -> GraphArtifacts: | |
| """One startup line naming the resolved graph dir, then FAIL HARD for any non-base mode | |
| missing an artifact -- same policy as evals/gr00t/run_gr00t.py's own check.""" | |
| artifacts = graph_artifact_status(resolve_graph_dir(cell.suite, env)) | |
| print(f"[run_sr] {cell.tag}: mode={cell.mode} graph_dir={artifacts.graph_dir} " | |
| f"graph_hash={artifacts.graph_hash} g_head_mtime={artifacts.g_head_mtime} " | |
| f"g_track_mtime={artifacts.g_track_mtime}") | |
| if not artifacts.ok and cell.mode != "base": | |
| raise RuntimeError( | |
| f"{cell.tag}: resolved graph dir {artifacts.graph_dir} is missing required artifacts: " | |
| f"{list(artifacts.missing)} -- train the graph for this suite first, or point " | |
| f"GR_GRAPH_DIR at a suite that already has g_head.npz + g_track.npz." | |
| ) | |
| return artifacts | |
| def _launch(self, cell: Cell, gpu: int, port: int, log_dir: Path, | |
| cell_env: Mapping[str, str]) -> LaunchResult: | |
| assert self._python is not None | |
| started = time.time() | |
| result_dir = cell.result_dir(self._config.results_root) | |
| result_dir.mkdir(parents=True, exist_ok=True) | |
| log_dir.mkdir(parents=True, exist_ok=True) | |
| shared = {**os.environ, **cell_env, "CUDA_VISIBLE_DEVICES": str(gpu)} | |
| checkpoint = self._config.suite(cell.suite).stablevla_ckpt | |
| server_env = {**shared, "PYTHONPATH": self._env_builder.server_pythonpath} | |
| client_env = {**shared, "PYTHONPATH": self._env_builder.client_pythonpath} | |
| with PolicyServer(self._config, self._supervisor, self._python, checkpoint, port, | |
| log_dir, server_env): | |
| with ExitStack() as stack: | |
| procs = [ | |
| self._supervisor.spawn( | |
| self._client_cmd(cell, checkpoint, port, shard, result_dir), | |
| env=client_env, | |
| stdout=stack.enter_context(open(log_dir / f"client_shard{shard}.log", "w")), | |
| ) | |
| for shard in range(cell.num_clients) | |
| ] | |
| returncodes = [] | |
| for proc in procs: | |
| returncodes.append(proc.wait()) | |
| self._supervisor.forget(proc) | |
| return LaunchResult(wall_s=time.time() - started, client_returncodes=tuple(returncodes)) | |
| def _client_cmd(self, cell: Cell, checkpoint: Path, port: int, shard: int, | |
| result_dir: Path) -> list[str]: | |
| assert self._python is not None | |
| return [ | |
| str(self._python), str(self._config.paths.harness_dir / "eval_libero.py"), | |
| "--args.pretrained-path", str(checkpoint), | |
| "--args.host", "127.0.0.1", | |
| "--args.port", str(port), | |
| "--args.task-suite-name", cell.bench, | |
| "--args.category_value", cell.category_value, | |
| "--args.num-trials-per-task", "1", | |
| "--args.with_state", "true", | |
| "--args.shard_index", str(shard), | |
| "--args.num_shards", str(cell.num_clients), | |
| "--args.video-out-path", str(result_dir), | |
| ] | |
| # ===================================================================================================== | |
| # CLI | |
| # ===================================================================================================== | |
| class SRDriver: | |
| """Dispatches one CLI invocation. Owns the lazily-built LadderConfig and the ledger.""" | |
| def __init__(self, args: argparse.Namespace, ledger: Ledger | None = None) -> None: | |
| self._args = args | |
| self._config: LadderConfig | None = None | |
| self._ledger = ledger or Ledger() | |
| def config(self) -> LadderConfig: | |
| """Built on first use: --ledger never needs to read suites.yaml or modes.sh.""" | |
| if self._config is None: | |
| self._config = LadderConfig.load( | |
| Path(self._args.ladder), | |
| Path(self._args.results_root) if self._args.results_root else None, | |
| ) | |
| return self._config | |
| def run(self) -> ExitCode: | |
| args = self._args | |
| if args.check_env: | |
| return self._check_env() | |
| if args.ledger: | |
| return self._print_ledger() | |
| cells = self._resolve_cells() | |
| if not cells: | |
| print("[run_sr] nothing to do -- pass --rung or --cells", file=sys.stderr) | |
| return ExitCode.ERROR | |
| if args.score: | |
| return self._score(cells) | |
| gpus = self._parse_gpus(args.gpus) | |
| if not gpus: | |
| print("[run_sr] --gpus is required to launch or dry-run a schedule", file=sys.stderr) | |
| return ExitCode.ERROR | |
| return self._launch(cells, gpus) | |
| # ---- cell resolution ------------------------------------------------------------------------- | |
| def _resolve_cells(self) -> list[Cell]: | |
| args = self._args | |
| if args.rung: | |
| if args.rung not in self.config.doc["rungs"]: | |
| raise SystemExit( | |
| f"unknown rung {args.rung!r}; known: {sorted(self.config.doc['rungs'])}" | |
| ) | |
| cells = self.config.cells_for_rung(args.rung) | |
| if args.cells: | |
| cells = self._subset(cells, args.cells, args.rung) | |
| if args.mode: | |
| # with_mode (not a bare dataclasses.replace) so the tag's mode suffix is re-keyed too | |
| # -- see cell_tag's docstring for the wrong-number hazard a stale tag creates. | |
| cells = [c.with_mode(args.mode) for c in cells] | |
| return cells | |
| if args.cells: | |
| return self.config.cells_from_tokens(args.cells, args.mode) | |
| return [] | |
| def _subset(cells: Sequence[Cell], spec: str, rung: str) -> list[Cell]: | |
| """--rung R --cells suite:axis,... runs a SUBSET of the rung, keeping each cell's own | |
| expected_n / filter / comparators / gate. Without this, re-running one cell means --cells | |
| alone, which rebuilds it as an ad hoc full-axis cell and silently drops the rung's episode | |
| filter -- a 12-episode smoke would become a 393-episode run.""" | |
| wanted = {t.strip() for t in spec.split(",") if t.strip()} | |
| unknown = wanted - {c.key for c in cells} | |
| if unknown: | |
| raise SystemExit( | |
| f"--cells: {sorted(unknown)} not in rung {rung} (has: {sorted(c.key for c in cells)})" | |
| ) | |
| return [c for c in cells if c.key in wanted] | |
| def _parse_gpus(spec: str | None) -> list[int]: | |
| """"0,1" -> [0, 1], preserving order and dropping repeats (a duplicated index would | |
| otherwise open two schedule tracks against the same device).""" | |
| if not spec: | |
| return [] | |
| seen: dict[int, None] = {} | |
| for token in spec.split(","): | |
| if token.strip(): | |
| seen[int(token)] = None | |
| return list(seen) | |
| # ---- subcommands ----------------------------------------------------------------------------- | |
| def _check_env(self) -> ExitCode: | |
| preflight = Preflight(self.config) | |
| ok = True | |
| print("=== GPU ===") | |
| try: | |
| statuses = preflight.gpus() | |
| if not statuses: | |
| print(" no CUDA devices visible") | |
| ok = False | |
| for status in statuses: | |
| label = "OK" if status.ok else "BUSY" | |
| print(f" gpu{status.index}: {status.free_gib:6.1f} / {status.total_gib:6.1f} GiB free" | |
| f" [{label}]") | |
| ok = ok and status.ok | |
| except Exception as exc: # noqa: BLE001 -- a broken torch must not crash the report | |
| print(f" GPU check FAILED: {exc}") | |
| ok = False | |
| print("=== load ===") | |
| load = preflight.load() | |
| print(f" loadavg(1m)={load.load1:.2f} ncpu={load.ncpu} threshold={load.threshold:.1f} " | |
| f"[{'OK' if load.ok else 'BUSY'}]") | |
| ok = ok and load.ok | |
| print("=== LIBERO-Plus ===") | |
| libero = preflight.libero_plus() | |
| print(f" {libero.message}") | |
| ok = ok and libero.ok | |
| print(f"\n[check-env] overall: {'OK' if ok else 'NOT READY'}") | |
| return ExitCode.OK if ok else ExitCode.ERROR | |
| def _print_ledger(self) -> ExitCode: | |
| records = self._ledger.read() | |
| if not records: | |
| print(f"[run_sr] ledger is empty (no runs recorded yet): {self._ledger.path}") | |
| return ExitCode.OK | |
| print(self._ledger.table(records)) | |
| return ExitCode.OK | |
| def _score(self, cells: Sequence[Cell]) -> ExitCode: | |
| scorer = CellScorer(self.config) | |
| any_scored = False | |
| any_failed = False | |
| for cell in cells: | |
| report = scorer.report(cell, self._args.allow_partial) | |
| if report.score.actual_n == 0: | |
| print(f"RESULT {cell.tag}: NO DATA (expected {cell.expected_n} episodes at " | |
| f"{cell.result_dir(self.config.results_root)})") | |
| continue | |
| any_scored = True | |
| any_failed = any_failed or not report.verdict.passed | |
| print(report.line) | |
| if not any_scored: | |
| return ExitCode.ERROR | |
| return ExitCode.GATE_FAILED if any_failed else ExitCode.OK | |
| # ---- launch ---------------------------------------------------------------------------------- | |
| def _launch(self, cells: Sequence[Cell], gpus: Sequence[int]) -> ExitCode: | |
| usable_gpus = self._validate_system(cells, gpus) | |
| if not usable_gpus: | |
| return ExitCode.ERROR | |
| plans = self._build_execution_plan(cells) | |
| if plans is None: | |
| return ExitCode.ERROR | |
| if not plans: | |
| print("[run_sr] nothing left to run (everything already complete)") | |
| return ExitCode.OK | |
| schedule = Schedule.build([p.cell for p in plans], usable_gpus, self.config, | |
| self._ledger.read()) | |
| self._print_schedule(schedule, usable_gpus) | |
| if self._args.dry_run: | |
| print("\n[dry-run] no subprocess launched, no filesystem changes made.") | |
| return ExitCode.OK | |
| return self._execute_schedule(plans, schedule) | |
| def _validate_system(self, cells: Sequence[Cell], gpus: Sequence[int]) -> list[int]: | |
| """GPU + loadavg preflight. Returns the usable GPUs, or [] to refuse the launch.""" | |
| preflight = Preflight(self.config) | |
| statuses = preflight.gpus(gpus) | |
| for status in statuses: | |
| if not status.ok: | |
| print(f"[preflight] SKIP gpu{status.index}: {status.reason}") | |
| sys.stdout.flush() # keep stdout ahead of the stderr refusal below when interleaved (2>&1) | |
| usable = [s.index for s in statuses if s.ok] | |
| if not usable: | |
| print("[preflight] no usable GPUs -- refusing to start anything", file=sys.stderr) | |
| return [] | |
| total_expected = sum(c.expected_n for c in cells) | |
| is_large = total_expected > self.config.limits.large_rung_episodes | |
| load = preflight.load() | |
| print(f"[preflight] load1={load.load1:.2f} ncpu={load.ncpu} threshold={load.threshold:.1f} " | |
| f"large_launch={is_large} (total_expected={total_expected})") | |
| sys.stdout.flush() | |
| if is_large and not load.ok: | |
| print(f"[preflight] REFUSING: a large launch ({total_expected} episodes) and loadavg(1m)=" | |
| f"{load.load1:.2f} exceeds {load.threshold:.1f} -- box looks busy with a " | |
| f"neighbouring job", file=sys.stderr) | |
| return [] | |
| for cell in cells: | |
| if cell.filter_file is not None and not cell.filter_file.exists(): | |
| print(f"[preflight] WARNING: {cell.tag}: filter file missing at {cell.filter_file}") | |
| return usable | |
| def _build_execution_plan(self, cells: Sequence[Cell]) -> list[CellPlan] | None: | |
| """The per-cell resume decisions. None means refuse the whole launch.""" | |
| planner = ResumePlanner(self.config, self._ledger) | |
| records = self._ledger.read() | |
| runnable: list[CellPlan] = [] | |
| for cell in cells: | |
| plan = planner.plan(cell, self._args.resume, records) | |
| print(f"[plan] {cell.tag}: {plan.action.value} ({plan.reason})") | |
| if plan.action is CellAction.ERROR: | |
| print(f"[run_sr] {cell.tag}: {plan.reason}", file=sys.stderr) | |
| return None | |
| if plan.action in (CellAction.RUN, CellAction.PURGE): | |
| runnable.append(plan) | |
| return runnable | |
| def _execute_schedule(self, plans: Sequence[CellPlan], schedule: Schedule) -> ExitCode: | |
| # Apply any purges now -- never before this point, so --dry-run (even with --resume) is a | |
| # pure read-only preview and can never delete a partial result dir. | |
| planner = ResumePlanner(self.config, self._ledger) | |
| for plan in plans: | |
| planner.apply(plan) | |
| supervisor = ProcessSupervisor() | |
| supervisor.install_signal_handlers() | |
| runner = CellRunner( | |
| config=self.config, | |
| env_builder=EnvBuilder(self.config), | |
| scorer=CellScorer(self.config), | |
| ledger=self._ledger, | |
| ports=PortAllocator(self.config.defaults.base_port, | |
| self.config.root / "logs" / ".ports"), | |
| supervisor=supervisor, | |
| allow_partial=self._args.allow_partial, | |
| ) | |
| def run_track(gpu: int, track_cells: Sequence[Cell]) -> list[ExitCode]: | |
| return [runner.run(cell, gpu) for cell in track_cells] | |
| # One worker thread per GPU, each running its LPT-assigned cells sequentially. Threads (not | |
| # processes) are fine: each cell's work is dominated by subprocess I/O wait. | |
| codes: list[ExitCode] = [] | |
| assignments = {g: c for g, c in schedule.assignments.items() if c} | |
| with ThreadPoolExecutor(max_workers=max(1, len(assignments))) as pool: | |
| futures = [pool.submit(run_track, gpu, cells) for gpu, cells in assignments.items()] | |
| for future in as_completed(futures): | |
| codes.extend(future.result()) | |
| if ExitCode.GATE_FAILED in codes: | |
| return ExitCode.GATE_FAILED | |
| if any(code is ExitCode.ERROR for code in codes): | |
| return ExitCode.ERROR | |
| return ExitCode.OK | |
| def _print_schedule(schedule: Schedule, gpus: Sequence[int]) -> None: | |
| print("\n=== schedule ===") | |
| for line in schedule.lines: | |
| cell = line.cell | |
| print(f" gpu{line.gpu}: {cell.tag:<45} n={cell.expected_n:<5} " | |
| f"mode={cell.mode:<16} ~{line.projected_min:6.1f} min") | |
| print(f"projected makespan: {schedule.makespan_min:.1f} min " | |
| f"({schedule.makespan_min / 60.0:.1f} h) across {len(gpus)} GPU(s)") | |
| def build_argparser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser( | |
| prog="run_sr.py", description="ONF LIBERO-Plus success-rate eval driver." | |
| ) | |
| parser.add_argument("--rung", help="ladder rung name (R1, R4)") | |
| parser.add_argument("--cells", help="comma-separated suite:axis pairs, " | |
| "e.g. goal:Background_Textures,long:Camera_Viewpoints") | |
| parser.add_argument("--gpus", help="comma-separated physical GPU indices, e.g. 0,1") | |
| parser.add_argument("--mode", help="mode override (onf_set_mode name)") | |
| parser.add_argument("--dry-run", action="store_true", help="print the schedule/projection; launch nothing") | |
| parser.add_argument("--resume", action="store_true", help="skip complete cells, purge+rerun partial ones") | |
| parser.add_argument("--score", action="store_true", help="score existing results only; launch nothing") | |
| parser.add_argument("--ledger", action="store_true", help="print the ledger as a table and exit") | |
| parser.add_argument("--check-env", action="store_true", help="GPU/load/LIBERO-Plus preflight report") | |
| parser.add_argument("--allow-partial", action="store_true", help="include incomplete cells in totals/gates") | |
| parser.add_argument("--ladder", default=str(LADDER_YAML), help="path to the ladder yaml") | |
| parser.add_argument("--results-root", default=None, help="override the results/ root (else score.py's default)") | |
| return parser | |
| def main(argv: Sequence[str] | None = None) -> int: | |
| args = build_argparser().parse_args(argv) | |
| return int(SRDriver(args).run()) | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |
Xet Storage Details
- Size:
- 91.4 kB
- Xet hash:
- e0b627c670e2c2ea45444b2d01a9a2dc18c637f6a8e4c0cccaba378bf525ba44
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.