twanghcmut's picture
download
raw
25.5 kB
"""``python -m onf.graph`` — the one entrypoint for the demonstration-graph subsystem's offline
pipeline: ``build`` (hdf5 -> g_nodes/g_edges) and ``train`` (the retrieval-network curriculum, see
:mod:`onf.graph.train`).
This module used to also carry ``probe`` (the falsification battery), ``eval-offline`` (rung R0 of the
evaluation ladder), ``probe-head`` (a trained checkpoint's abstain-decision diagnostic), ``report`` and
``list`` (run-bookkeeping inspection). All five are now CLOSED ABLATIONS / retired tooling and have been
deleted along with the helpers that only served them (``onf.graph.probes`` -- their sole non-CLI
dependency -- was deleted first; the three subcommands importing it already raised
``ModuleNotFoundError`` before this cut). The repo's supported CLI surface is exactly ``build`` and
``train``.
WHY A CLI, NOT A PILE OF SCRIPTS
Both library functions this module calls already do the real work (:func:`onf.graph.builders.
build_graph`, :func:`onf.graph.train.train_graph`) and already accept a :class:`onf.graph.report.
StageLogger`. What was missing was a single, reproducible, un-hardcoded front door: every subcommand
here resolves its FULL configuration (suite -> hdf5 dir -> ``GraphConfig``, never a literal path),
can be inspected with ``--dry-run`` before anything expensive runs, and always leaves a
config/manifest/metrics/log record under ``outputs/`` -- the project's own reproducibility bar.
WHY EVERY SUBCOMMAND OWNS A StageLogger, BUT ONLY ``build`` REPOINTS ``latest``
:meth:`onf.config.Paths.graph` resolves to ``outputs/<suite>/latest/artifacts`` -- "the graph a
suite's train run should read". A graph BUILD is the event that pointer exists to track, so
``build``'s :class:`StageLogger` repoints it (the default). ``train`` writes its checkpoint straight
into that SAME resolved directory (mirroring :func:`onf.graph.train.train_graph`'s own contract: the
checkpoint lives next to the ``g_nodes.npz``/``g_edges.npz`` it was fitted against, not off in its
own run dir) and passes ``update_latest=False`` to its own bookkeeping :class:`StageLogger` -- a
`train` run would otherwise silently orphan the graph it just extended from ``Paths.graph``'s point
of view.
EXIT CODES (every subcommand)
0 success -- 1 an exception / bad input. Neither subcommand has a GATE (exit code 2 is unused by
this module now; it belonged to the deleted probe/eval-offline/probe-head gates).
"""
from __future__ import annotations
import argparse
import dataclasses
import glob
import json
import os
import sys
from pathlib import Path
from typing import Any
import numpy as np
import yaml
from onf.config import GraphConfig, default_paths
from onf.graph import schema
from onf.graph.builders import _hdf5_name, build_graph
from onf.graph.report import StageLogger
# ---- repo-relative paths (the ONE place this module resolves a filesystem literal; everything else
# goes through onf.config.Paths / configs/suites.yaml, per the module docstring). Mirrors onf.config's
# own "parents[N] == repo root" idiom. ---------------------------------------------------------------
_REPO_ROOT = Path(__file__).resolve().parents[3] # src/onf/graph/cli.py -> repo root
_SUITES_YAML = _REPO_ROOT / "configs" / "suites.yaml"
#: ``build``'s internal pipeline steps, in order (matches onf.graph.builders.build_graph /
#: onf.graph.edges.EdgeSet.build's own step calls -- no ``boundary`` step any more, since there is no
#: `stage_next`/`stage_prev` relation to build; see schema.RELATIONS's module note). Used both to
#: print "planned stages" under --dry-run and as this CLI's own StageLogger's ``n_stages``; the real
#: run reports the same labels itself via StageLogger.step().
BUILD_STAGE_LABELS: tuple[str, ...] = (
"load/hdf5", "nodes/coarsen", "temporal", "sibling", "align", "edges/summary",
)
DEFAULT_TRAIN_EPOCHS = 30 # a full curriculum epoch count for one stage (train.py's own runs use this)
class CLIError(Exception):
"""Raised by any subcommand to fail cleanly with a specific exit code (1 by default) instead of a
raw traceback -- :func:`main` is the only place this is caught."""
def __init__(self, message: str, exit_code: int = 1):
super().__init__(message)
self.exit_code = exit_code
# ======================================================================================================
# suites.yaml -- the single source of "which suites this CLI knows about" (never a literal list)
# ======================================================================================================
def load_configured_suites(path: str | os.PathLike | None = None) -> list[str]:
"""The suite names ``--suite all`` expands to: the ``suites:`` mapping keys of
``configs/suites.yaml``, in file order. Never hardcoded here -- add a suite to that YAML and this
CLI (and every downstream launcher that already reads it) picks it up unchanged."""
p = Path(path) if path is not None else _SUITES_YAML
if not p.exists():
raise CLIError(f"suites config not found: {p}")
with open(p) as f:
doc = yaml.safe_load(f) or {}
suites = doc.get("suites") or {}
if not suites:
raise CLIError(f"{p}: no `suites:` mapping found")
return list(suites.keys())
def _resolve_suites(suite_arg: str, configured: list[str]) -> list[str]:
"""``--suite`` -> the list of suites to act on. ``"all"`` expands to every configured suite;
anything else must be a name FROM that list -- an unrecognised suite fails loudly here rather than
reaching :meth:`onf.config.Paths.hdf5` and failing with a confusing "no such directory"."""
if suite_arg == "all":
return list(configured)
if suite_arg not in configured:
raise CLIError(
f"unknown suite {suite_arg!r}; choices: {sorted(configured)} or 'all' "
f"(from {_SUITES_YAML})"
)
return [suite_arg]
# ======================================================================================================
# small shared parsing / formatting helpers
# ======================================================================================================
def _csv(value: str) -> list[str]:
return [v.strip() for v in value.split(",") if v.strip()]
def _csv_int(value: str) -> list[int]:
return [int(v) for v in _csv(value)]
def _json_default(obj: Any) -> Any:
if isinstance(obj, np.generic):
return obj.item()
if isinstance(obj, np.ndarray):
return obj.tolist()
if isinstance(obj, Path):
return os.fspath(obj)
return str(obj)
def _echo_config(label: str, config: dict) -> None:
print(f"===== {label}: fully-resolved config =====")
print(json.dumps(config, indent=2, default=_json_default, sort_keys=True))
def _print_table(headers: list[str], rows: list[list[str]]) -> None:
"""A minimal aligned text table -- no external dependency, used by ``build``/``train``. Column
widths are the max of the header and every row's cell in that column."""
if not rows:
print(" (no rows)")
return
widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
widths[i] = max(widths[i], len(str(cell)))
line = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers))
print(line)
print(" ".join("-" * w for w in widths))
for row in rows:
print(" ".join(str(cell).ljust(widths[i]) for i, cell in enumerate(row)))
def _fmt_num(x: Any, nd: int = 4) -> str:
if isinstance(x, bool):
return str(x)
if isinstance(x, float):
if not np.isfinite(x):
return str(x)
return f"{x:.{nd}f}"
return str(x)
def _fmt_metric(v: Any) -> str:
"""A single metrics.json leaf -> a compact display string. A bootstrap CI dict
(``{"lo":...,"point":...,"hi":...}``) prints as ``point [lo, hi]`` -- never just the point estimate
alone, so a report never hides the uncertainty the probe itself computed."""
if isinstance(v, dict) and {"lo", "point", "hi"} <= set(v.keys()):
return f"{_fmt_num(v['point'])} [{_fmt_num(v['lo'])}, {_fmt_num(v['hi'])}]"
if isinstance(v, dict):
return "{...}" # nested, non-CI dict -- not flattened at this level
if isinstance(v, list):
return f"[{len(v)} items]" if len(v) > 6 else str(v)
return _fmt_num(v)
# ======================================================================================================
# outputs/ run-directory scanning (the generic "skip if already done" check every subcommand uses)
# ======================================================================================================
@dataclasses.dataclass
class _RunInfo:
suite: str
stage: str
run_dir: Path
manifest: dict
metrics: dict
config: dict
def _load_run(run_dir: Path) -> _RunInfo | None:
manifest_p, metrics_p, config_p = (run_dir / n for n in ("manifest.json", "metrics.json", "config.json"))
if not manifest_p.exists():
return None
try:
manifest = json.loads(manifest_p.read_text())
except (json.JSONDecodeError, OSError):
return None
metrics = {}
if metrics_p.exists():
try:
metrics = json.loads(metrics_p.read_text())
except (json.JSONDecodeError, OSError):
metrics = {}
config = {}
if config_p.exists():
try:
config = json.loads(config_p.read_text())
except (json.JSONDecodeError, OSError):
config = {}
return _RunInfo(
suite=str(manifest.get("suite", run_dir.parent.name)),
stage=str(manifest.get("stage", run_dir.name.rsplit("_", 1)[0])),
run_dir=run_dir, manifest=manifest, metrics=metrics, config=config,
)
def _find_matching_run(root: Path, suite: str, stage: str, config: dict) -> Path | None:
"""The generic "already did this exact work" check every subcommand's ``--force`` guards: the
newest COMPLETE run under ``outputs/<suite>/<stage>_*`` whose ``config.json`` matches ``config``
(ignoring the purely cosmetic ``name`` key -- two runs with the same knobs and different
``--name`` labels are still the same work)."""
base = root / suite
if not base.is_dir():
return None
norm_target = {k: v for k, v in config.items() if k != "name"}
candidates = sorted(base.glob(f"{stage}_*"), reverse=True)
for run_dir in candidates:
if not run_dir.is_dir() or run_dir.is_symlink():
continue
info = _load_run(run_dir)
if info is None or info.manifest.get("status") != "complete":
continue
norm_got = {k: v for k, v in info.config.items() if k != "name"}
if norm_got == norm_target:
return run_dir
return None
# ======================================================================================================
# --resume: inherit a prior run's resolved config for any flag the user did not explicitly pass
# ======================================================================================================
def _load_resume_config(run_dir: str | os.PathLike) -> dict:
p = Path(run_dir) / "config.json"
if not p.exists():
raise CLIError(f"--resume {run_dir}: no config.json found there (not a StageLogger run dir?)")
try:
return json.loads(p.read_text())
except (json.JSONDecodeError, OSError) as e:
raise CLIError(f"--resume {run_dir}: unreadable config.json ({e})") from e
def _pick(explicit: Any, resumed: dict, key: str, default: Any) -> Any:
"""``explicit`` (a CLI value, or argparse's ``None`` sentinel meaning "not passed") wins; else the
``--resume``d run's own resolved value for ``key``; else ``default``. The layering that makes
``--resume`` meaningful: a caller can reproduce a prior run exactly, or reproduce it while
overriding exactly the flags they pass this time."""
if explicit is not None:
return explicit
if key in resumed:
return resumed[key]
return default
# ======================================================================================================
# common argparse plumbing shared by every subcommand
# ======================================================================================================
def _add_common_args(sub: argparse.ArgumentParser, *, resume: bool = True) -> None:
sub.add_argument("--out", default=None, help="root run-bookkeeping dir (default: Paths.outputs(), "
"i.e. $ONF_OUTPUTS or <repo>/outputs)")
sub.add_argument("--name", default="", help="label appended to this run's directory name")
sub.add_argument("--seed", type=int, default=None, help="RNG seed (default: 0, or the resumed "
"run's seed under --resume)")
sub.add_argument("--device", default=None, help='torch device, e.g. "cuda:0" or "cpu" '
'(default: auto -- cuda if available else cpu)')
sub.add_argument("--quiet", action="store_true", help="suppress the mirrored stdout progress "
"lines (log.txt under outputs/ still gets every line)")
sub.add_argument("--dry-run", action="store_true", help="print the fully-resolved config and the "
"planned stages, then exit 0 -- touches NOTHING under outputs/, so an expensive "
"run can be inspected before it starts")
sub.add_argument("--force", action="store_true", help="re-run even if a completed run with the "
"identical resolved config already exists under outputs/")
if resume:
sub.add_argument("--resume", default=None, metavar="RUN_DIR", help="inherit RUN_DIR's "
"resolved config.json as defaults for any flag not explicitly passed here")
def _stage_logger(args: argparse.Namespace, stage: str, suite: str, config: dict, *,
n_stages: int = 0, inputs: list[str] | None = None, update_latest: bool = True) -> StageLogger:
return StageLogger(
stage, suite, name=args.name, root=args.out, config=config, n_stages=n_stages,
inputs=inputs or [], quiet=args.quiet, update_latest=update_latest,
)
# ======================================================================================================
# build
# ======================================================================================================
def _add_build_parser(sub: argparse._SubParsersAction) -> None:
p = sub.add_parser(
"build", help="HDF5 demos -> g_nodes.npz / g_edges.npz",
description="Build the demonstration graph for one suite (or --suite all): coarsen raw demo "
"frames into nodes (splitting only at gripper-state changes), then build the R=12-relation "
"edge set (onf.graph.schema). `align` is different task, same phase bin, same gripper state "
"-- there is deliberately no `stage` constraint here (see schema.RELATIONS's module note); a "
"bad build here silently degrades `align`/`sibling` for every downstream stage.",
)
p.add_argument("--suite", required=True, help="object | spatial | goal | long | all "
"(configs/suites.yaml)")
p.add_argument("--coarsen", type=int, default=None, help=f"raw frames per node "
f"(default: schema.COARSEN={schema.COARSEN})")
p.add_argument("--hdf5-dir", default=None, help="override the demo hdf5 directory (default: "
"Paths.hdf5(<suite>) -- pass this to build from a fixture/test dataset)")
p.add_argument("--limit-demos", type=int, default=None, help="cap demos read per hdf5 file "
"(smoke-test a suite without the full 500 demos)")
p.add_argument("--limit-tasks", type=int, default=None, help="cap hdf5 files (tasks) read")
_add_common_args(p)
p.set_defaults(_run=_cmd_build)
def _build_one_suite(args: argparse.Namespace, suite: str, paths, resumed: dict) -> dict:
cfg = GraphConfig(
coarsen=_pick(args.coarsen, resumed, "coarsen", schema.COARSEN),
device=_pick(args.device, resumed, "device", ""),
)
hdf5_dir = _pick(args.hdf5_dir, resumed, "hdf5_dir", None)
hdf5_dir = os.fspath(hdf5_dir) if hdf5_dir else os.fspath(paths.hdf5(_hdf5_name(suite)))
seed = _pick(args.seed, resumed, "seed", 0)
limits = {
k: v for k, v in (
("limit_demos", _pick(args.limit_demos, resumed, "limit_demos", None)),
("limit_tasks", _pick(args.limit_tasks, resumed, "limit_tasks", None)),
) if v is not None
}
config = {
"command": "build", "suite": suite, "hdf5_dir": hdf5_dir, "seed": seed, "name": args.name,
"limits": limits, **dataclasses.asdict(cfg),
}
if args.dry_run:
_echo_config(f"build[{suite}]", config)
print(f"planned stages ({len(BUILD_STAGE_LABELS)}): {', '.join(BUILD_STAGE_LABELS)}")
return {"suite": suite, "dry_run": True}
root = Path(args.out) if args.out else paths.outputs()
if not args.force:
hit = _find_matching_run(root, suite, "graph", config)
if hit is not None:
print(f"[build] {suite}: SKIP -- identical config already built at {hit} "
f"(pass --force to rebuild)")
return {"suite": suite, "skipped": True, "run_dir": str(hit)}
_echo_config(f"build[{suite}]", config)
input_files = sorted(glob.glob(f"{hdf5_dir}/*.hdf5"))
with _stage_logger(args, "graph", suite, config, n_stages=len(BUILD_STAGE_LABELS),
inputs=input_files, update_latest=True) as log:
metrics = build_graph(
suite, hdf5_dir=hdf5_dir, out_dir=None, cfg=cfg, coarsen=cfg.coarsen,
device=(cfg.device or None), logger=log, **limits,
)
return {"suite": suite, "skipped": False, **metrics}
def _cmd_build(args: argparse.Namespace) -> int:
paths = default_paths()
configured = load_configured_suites()
suites = _resolve_suites(args.suite, configured)
resumed = _load_resume_config(args.resume) if getattr(args, "resume", None) else {}
ok, results = True, []
for suite in suites:
try:
results.append(_build_one_suite(args, suite, paths, resumed))
except Exception as e:
ok = False
print(f"[build] {suite}: FAILED -- {type(e).__name__}: {e}", file=sys.stderr)
if args.dry_run:
return 0
rows = [
[r["suite"], "skip" if r.get("skipped") else "built",
str(r.get("n_nodes", "-")), str(r.get("n_edges", "-")), str(r.get("n_stages", "-"))]
for r in results
]
_print_table(["suite", "status", "n_nodes", "n_edges", "n_stages"], rows)
return 0 if ok else 1
# ======================================================================================================
# train
# ======================================================================================================
def _add_train_parser(sub: argparse._SubParsersAction) -> None:
p = sub.add_parser(
"train", help="train GraphRetrieverNet (onf.graph.train) on a built graph",
description="Run the demo-graph retrieval curriculum (stage 1, temporal-advance pretraining -- "
"the only stage left; stage 2's drift finetuning was a closed ablation and has been deleted, "
"see onf.graph.train's module docstring) and write g_head.npz next to the graph it was trained "
"on. Requires `build` to have run first.",
)
p.add_argument("--suite", required=True, help="object | spatial | goal | long | all")
p.add_argument("--epochs", type=int, default=None, help=f"epochs per stage "
f"(default: {DEFAULT_TRAIN_EPOCHS})")
p.add_argument("--n-query", type=int, default=None, help="training queries per stage "
"(default: onf.graph.train.N_QUERY)")
p.add_argument("--n-eval", type=int, default=None, help="held-out queries for per-epoch metrics "
"(default: onf.graph.train.N_EVAL)")
p.add_argument("--graph-dir", default=None, help="override the graph dir to train on/into "
"(default: Paths.graph(<suite>))")
_add_common_args(p)
p.set_defaults(_run=_cmd_train)
def _train_one_suite(args: argparse.Namespace, suite: str, paths, resumed: dict) -> dict:
from onf.graph.train import N_EVAL, N_QUERY
stages = (1,) # only legal value -- see onf.graph.train.train_graph
epochs = _pick(args.epochs, resumed, "epochs", DEFAULT_TRAIN_EPOCHS)
n_query = _pick(args.n_query, resumed, "n_query", N_QUERY)
n_eval = _pick(args.n_eval, resumed, "n_eval", N_EVAL)
seed = _pick(args.seed, resumed, "seed", 0)
device = _pick(args.device, resumed, "device", "")
graph_dir_arg = _pick(args.graph_dir, resumed, "graph_dir", None)
graph_dir = Path(graph_dir_arg) if graph_dir_arg else Path(paths.graph(suite))
cfg = GraphConfig(device=device)
config = {
"command": "train", "suite": suite, "graph_dir": str(graph_dir), "stages": list(stages),
"epochs": epochs, "n_query": n_query, "n_eval": n_eval, "seed": seed, "name": args.name,
**dataclasses.asdict(cfg),
}
if args.dry_run:
_echo_config(f"train[{suite}]", config)
print(f"planned stages: {stages} (graph_dir={graph_dir})")
return {"suite": suite, "dry_run": True}
if not (graph_dir / schema.NODES_NPZ).exists():
raise CLIError(f"no built graph for suite {suite!r} at {graph_dir} -- run "
f"`python -m onf.graph build --suite {suite}` first")
root = Path(args.out) if args.out else paths.outputs()
checkpoint_path = graph_dir / schema.HEAD_NPZ
if not args.force:
hit = _find_matching_run(root, suite, "train", config)
if hit is not None and checkpoint_path.exists():
print(f"[train] {suite}: SKIP -- identical config already trained at {hit}, "
f"checkpoint={checkpoint_path} (pass --force to retrain)")
return {"suite": suite, "skipped": True, "run_dir": str(hit)}
_echo_config(f"train[{suite}]", config)
from onf.graph.train import train_graph
with _stage_logger(args, "train", suite, config, n_stages=len(stages),
inputs=[str(graph_dir / schema.EDGES_NPZ)], update_latest=False) as log:
result = train_graph(
graph_dir, stages=stages, epochs=epochs, seed=seed, device=(device or None), save=True,
cfg=cfg, logger=log, n_query=n_query, n_eval=n_eval, suite=suite,
)
log.metric(checkpoint_path=result["checkpoint_path"], graph_hash=result["graph_hash"])
final_metrics = {}
for stage_hist in result["history"].values():
if stage_hist and isinstance(stage_hist[-1], dict) and stage_hist[-1].get("epoch") == "final":
final_metrics = stage_hist[-1]
return {"suite": suite, "skipped": False, "checkpoint_path": result["checkpoint_path"],
"final": final_metrics}
def _cmd_train(args: argparse.Namespace) -> int:
paths = default_paths()
configured = load_configured_suites()
suites = _resolve_suites(args.suite, configured)
resumed = _load_resume_config(args.resume) if getattr(args, "resume", None) else {}
ok, results = True, []
for suite in suites:
try:
results.append(_train_one_suite(args, suite, paths, resumed))
except CLIError:
raise
except Exception as e:
ok = False
print(f"[train] {suite}: FAILED -- {type(e).__name__}: {e}", file=sys.stderr)
if args.dry_run:
return 0
rows = []
for r in results:
final = r.get("final", {})
rows.append([
r["suite"], "skip" if r.get("skipped") else "trained",
_fmt_metric(final.get("top1", "-")), _fmt_metric(final.get("mrr", "-")),
_fmt_metric(final.get("crossed_top1", "-")),
])
_print_table(["suite", "status", "top1", "mrr", "crossed_top1"], rows)
return 0 if ok else 1
# ======================================================================================================
# top-level parser / dispatch
# ======================================================================================================
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="python -m onf.graph",
description="The demonstration-graph subsystem's offline pipeline (see onf.graph.schema for "
"the full WHY): build the graph, then train the retrieval network on it. Every subcommand "
"resolves its full configuration through onf.config.Paths / configs/suites.yaml (never a "
"hardcoded path) and leaves a reproducible run under outputs/ (onf.graph.report.StageLogger).",
)
sub = p.add_subparsers(dest="command", required=True, metavar="command")
_add_build_parser(sub)
_add_train_parser(sub)
return p
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
parser = build_parser()
args = parser.parse_args(argv)
try:
return int(args._run(args))
except CLIError as e:
print(f"error: {e}", file=sys.stderr)
return e.exit_code
except FileNotFoundError as e:
print(f"error: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

Xet Storage Details

Size:
25.5 kB
·
Xet hash:
bfad931e5531a389f8503b61ee3fae11f402c3d06534cda323a7c623350d88d2

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.