twanghcmut's picture
download
raw
33.7 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
onf.graph.train.loop). This is the repo's only supported CLI surface for this subsystem.
WHY A CLI, NOT A PILE OF SCRIPTS
Both library functions this module calls already do the real work (onf.graph.build.from_demos.
build_graph, onf.graph.train.loop.train_graph) and already accept a 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
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 StageLogger repoints it (the default). train writes its checkpoint straight
into that SAME resolved directory (mirroring onf.graph.train.loop.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 StageLogger -- a
train run would otherwise silently orphan the graph it just extended from Paths.graph's point
of view.
SHAPE
SuiteCommand owns the whole per-suite lifecycle every subcommand shares -- resolve config,
--dry-run echo, precondition check, --force/skip guard, StageLogger, summary table -- and
BuildCommand / TrainCommand supply only what actually differs between them.
Adding a subcommand means one subclass plus one add_parser block, not a fourth copy of that
lifecycle.
EXIT CODES (every subcommand)
0 success -- 1 an exception / bad input. Neither subcommand has a pass/fail gate, so exit code 2
is unused by this module.
"""
from __future__ import annotations
import argparse
import dataclasses
import glob
import json
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, ClassVar
import numpy as np
import yaml
from onf.config import GraphConfig, Paths, default_paths
from onf.graph.core import schema
from onf.graph.build.from_demos import LoadLimits, _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.build.from_demos.build_graph /
# onf.graph.core.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", "nodes/virtual", "nodes/visual",
"temporal", "sibling", "align", "approach", "visual_align", "edges/summary",
)
DEFAULT_TRAIN_EPOCHS = 30 # a full curriculum epoch count for one stage (train.py's own runs use this)
# The only legal stages value -- see onf.graph.train.loop.train_graph.
TRAIN_STAGES: tuple[int, ...] = (1,)
class CLIError(Exception):
"""Raised by any subcommand to fail cleanly with a specific exit code (1 by default) instead of a
raw traceback -- 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.
Args:
path: Override for configs/suites.yaml.
Returns:
The suites: mapping keys, 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.
Raises:
CLIError: The file is missing or has no suites: mapping.
"""
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 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 _json_default(obj: Any) -> Any:
"""json.dumps fallback: numpy scalars/arrays and Path -> plain JSON, anything else -> str."""
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 one fully-resolved config block to stdout."""
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:
"""One scalar -> a fixed-precision display string; non-finite floats print verbatim."""
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)
def _read_json(path: Path) -> dict:
"""Read a JSON object, degrading to {} when the file is missing, truncated, or unreadable --
a half-written run record must never crash the scan that walks past it."""
if not path.exists():
return {}
try:
loaded = json.loads(path.read_text())
except (json.JSONDecodeError, OSError):
return {}
return loaded if isinstance(loaded, dict) else {}
# ======================================================================================================
# outputs/ run-directory scanning (the generic "skip if already done" check every subcommand uses)
# ======================================================================================================
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.
Args:
root: Run-bookkeeping root (outputs/).
suite: Suite short-name.
stage: StageLogger stage label (graph / train).
config: The fully-resolved config to match against.
Returns:
The newest COMPLETE run under <root>/<suite>/<stage>_* whose config.json matches
config -- ignoring the purely cosmetic name key, since two runs with the same knobs and
different --name labels are still the same work -- or None.
"""
base = root / suite
if not base.is_dir():
return None
norm_target = {k: v for k, v in config.items() if k != "name"}
for run_dir in sorted(base.glob(f"{stage}_*"), reverse=True):
if not run_dir.is_dir() or run_dir.is_symlink():
continue
if _read_json(run_dir / "manifest.json").get("status") != "complete":
continue
norm_got = {k: v for k, v in _read_json(run_dir / "config.json").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:
"""Load a prior StageLogger run's config.json for --resume layering.
Raises:
CLIError: run_dir has no config.json, or it is unreadable/empty.
"""
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?)")
config = _read_json(p)
if not config:
raise CLIError(f"--resume {run_dir}: unreadable or empty config.json")
return config
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
--resumed 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
# ======================================================================================================
# per-suite outcome
# ======================================================================================================
@dataclass
class SuiteResult:
"""What one subcommand did for one suite.
Attributes:
suite: Suite short-name.
status: dry-run | skip | the subclass's done_status | failed.
run_dir: The matching run directory, when this was a skip.
metrics: Whatever SuiteCommand.execute returned; drives the summary row.
"""
suite: str
status: str
run_dir: Path | None = None
metrics: dict[str, Any] = field(default_factory=dict)
@property
def ok(self) -> bool:
"""Whether this suite counts toward a zero exit code."""
return self.status != "failed"
# ======================================================================================================
# subcommands
# ======================================================================================================
class SuiteCommand:
"""The per-suite lifecycle every subcommand shares, owning all resolved CLI state.
One instance per main() invocation. run walks the selected suites; run_suite
is the fixed pipeline -- resolve config -> --dry-run echo -> validate ->
--force/skip guard -> StageLogger -> execute. Subclasses override only the hooks.
"""
# StageLogger stage label, and the <stage>_* run-dir prefix the skip guard scans.
stage: ClassVar[str] = ""
# Human-facing subcommand name used in progress/error lines.
label: ClassVar[str] = ""
# The summary-table status for a suite that actually ran.
done_status: ClassVar[str] = "done"
# Whether this stage's StageLogger repoints <suite>/latest (see the module docstring).
update_latest: ClassVar[bool] = True
# Summary-table header row; row must return this many cells.
table_headers: ClassVar[tuple[str, ...]] = ()
def __init__(self, args: argparse.Namespace) -> None:
self.args = args
self.paths: Paths = default_paths()
self._resumed = _load_resume_config(args.resume) if getattr(args, "resume", None) else {}
self.suites = _resolve_suites(args.suite, load_configured_suites())
# Paths.outputs() only RESOLVES a path (it never mkdirs), so computing this eagerly is still
# compatible with --dry-run's "touches NOTHING under outputs/" contract.
self.out_root = Path(args.out) if args.out else self.paths.outputs()
# ---- resolution ------------------------------------------------------------------------------
def opt(self, key: str, default: Any) -> Any:
"""One knob, resolved: explicit CLI flag > --resumed value > default (see _pick)."""
return _pick(getattr(self.args, key, None), self._resumed, key, default)
# ---- driver ----------------------------------------------------------------------------------
def run(self) -> int:
"""Run every selected suite, then print the summary table.
Returns:
The process exit code: 0 when every suite succeeded, else 1.
Raises:
CLIError: A precondition failed (bad input / missing artifact). Unlike an unexpected
exception -- which is reported per-suite and lets the remaining suites continue -- a
CLIError is a config-level problem, so it aborts the whole invocation for main to
turn into its own exit code.
"""
results: list[SuiteResult] = []
for suite in self.suites:
try:
results.append(self.run_suite(suite))
except CLIError:
raise
except Exception as e:
print(f"[{self.label}] {suite}: FAILED -- {type(e).__name__}: {e}", file=sys.stderr)
results.append(SuiteResult(suite, "failed"))
if self.args.dry_run:
return 0
_print_table(list(self.table_headers), [self.row(r) for r in results])
return 0 if all(r.ok for r in results) else 1
def run_suite(self, suite: str) -> SuiteResult:
"""Resolve, guard, and execute this subcommand for one suite."""
config = self.resolve_config(suite)
if self.args.dry_run:
_echo_config(f"{self.label}[{suite}]", config)
print(self.plan_line(suite, config))
return SuiteResult(suite, "dry-run")
self.validate(suite, config)
if not self.args.force:
hit = _find_matching_run(self.out_root, suite, self.stage, config)
if hit is not None and self.artifacts_present(suite, config):
print(f"[{self.label}] {suite}: SKIP -- {self.skip_note(hit, config)} "
f"(pass --force to re-run)")
return SuiteResult(suite, "skip", run_dir=hit)
_echo_config(f"{self.label}[{suite}]", config)
with self._logger(suite, config) as log:
metrics = self.execute(suite, config, log)
return SuiteResult(suite, self.done_status, metrics=metrics)
def _logger(self, suite: str, config: dict) -> StageLogger:
"""This run's StageLogger, built from the shared flags plus the per-command hooks."""
return StageLogger(
self.stage, suite, name=self.args.name, root=self.args.out, config=config,
n_stages=self.n_stages(config), inputs=self.stage_inputs(suite, config),
quiet=self.args.quiet, update_latest=self.update_latest,
)
# ---- hooks -----------------------------------------------------------------------------------
def resolve_config(self, suite: str) -> dict:
"""The fully-resolved config for one suite -- every default materialized, no bare placeholders.
This dict is what lands in config.json and what the skip guard compares against."""
raise NotImplementedError
def execute(self, suite: str, config: dict, log: StageLogger) -> dict:
"""Do the actual work inside an entered StageLogger. Returns the summary-row metrics."""
raise NotImplementedError
def row(self, result: SuiteResult) -> list[str]:
"""One SuiteResult -> one summary-table row matching table_headers."""
raise NotImplementedError
def plan_line(self, suite: str, config: dict) -> str:
"""The --dry-run "planned stages" line printed under the resolved config."""
return f"planned stages ({self.n_stages(config)})"
def n_stages(self, config: dict) -> int:
"""How many StageLogger.step calls this run is expected to make."""
return 0
def stage_inputs(self, suite: str, config: dict) -> list[str]:
"""Input artifact paths to fingerprint into the run manifest."""
return []
def validate(self, suite: str, config: dict) -> None:
"""Fail loudly, before any expensive work, when a precondition is unmet."""
def artifacts_present(self, suite: str, config: dict) -> bool:
"""Whether the on-disk artifacts a matching prior run should have left still exist. False
demotes a config match back to a real run -- a matching record whose output was deleted is not
work that is actually done."""
return True
def skip_note(self, hit: Path, config: dict) -> str:
"""The middle of the SKIP line: what was found, and where."""
return f"identical config already run at {hit}"
class BuildCommand(SuiteCommand):
"""HDF5 demos -> g_nodes.npz / g_edges.npz for one suite."""
stage = "graph"
label = "build"
done_status = "built"
update_latest = True # a build IS the "new graph" event latest exists to track
table_headers = ("suite", "status", "n_nodes", "n_edges", "n_stages")
def resolve_config(self, suite: str) -> dict:
cfg = GraphConfig(
coarsen=self.opt("coarsen", schema.COARSEN),
device=self.opt("device", ""),
virtual_per_entry=self.opt("virtual_per_entry", schema.VIRTUAL_PER_ENTRY),
)
hdf5_dir = self.opt("hdf5_dir", None)
hdf5_dir = os.fspath(hdf5_dir) if hdf5_dir else os.fspath(self.paths.hdf5(_hdf5_name(suite)))
limits = {
k: v for k, v in (
("limit_demos", self.opt("limit_demos", None)),
("limit_tasks", self.opt("limit_tasks", None)),
) if v is not None
}
# GraphConfig is splatted FIRST so the explicitly-resolved keys below always win. This is
# load-bearing, not cosmetic: GraphConfig carries its own graph_dir (onf.config's
# GR_GRAPH_DIR override) and TrainCommand resolves a key of the same name -- splatting last
# would overwrite the resolved path with GraphConfig's default "".
return {
**dataclasses.asdict(cfg),
"command": "build", "suite": suite, "hdf5_dir": hdf5_dir,
"seed": self.opt("seed", 0), "name": self.args.name, "limits": limits,
"visual": bool(self.opt("visual", False)),
}
def n_stages(self, config: dict) -> int:
return len(BUILD_STAGE_LABELS)
def plan_line(self, suite: str, config: dict) -> str:
return f"planned stages ({len(BUILD_STAGE_LABELS)}): {', '.join(BUILD_STAGE_LABELS)}"
def stage_inputs(self, suite: str, config: dict) -> list[str]:
return sorted(glob.glob(f"{config['hdf5_dir']}/*.hdf5"))
def skip_note(self, hit: Path, config: dict) -> str:
return f"identical config already built at {hit}"
def execute(self, suite: str, config: dict, log: StageLogger) -> dict:
cfg = GraphConfig(
coarsen=config["coarsen"], device=config["device"],
virtual_per_entry=config["virtual_per_entry"],
)
limits = config["limits"]
artifacts = build_graph(
suite, hdf5_dir=config["hdf5_dir"], out_dir=None, cfg=cfg, coarsen=cfg.coarsen,
device=(cfg.device or None), logger=log,
limits=LoadLimits(tasks=limits.get("limit_tasks"), demos=limits.get("limit_demos")),
seed=config["seed"], visual=config["visual"],
)
return artifacts.to_dict()
def row(self, result: SuiteResult) -> list[str]:
m = result.metrics
return [result.suite, result.status,
str(m.get("n_nodes", "-")), str(m.get("n_edges", "-")), str(m.get("n_stages", "-"))]
class TrainCommand(SuiteCommand):
"""Train GraphRetrieverNet on a built graph -> g_head.npz next to that graph."""
stage = "train"
label = "train"
done_status = "trained"
update_latest = False # a train run must not steal latest from the build it read
table_headers = ("suite", "status", "top1", "mrr", "crossed_top1")
def resolve_config(self, suite: str) -> dict:
# Imported here, not at module scope: python -m onf.graph --help and every build run would
# otherwise pay for importing torch through onf.graph.train.loop.
from onf.graph.train.loop import N_EVAL, N_QUERY
from onf.graph.train.loss import RETRIEVAL
cfg = GraphConfig(device=self.opt("device", ""))
graph_dir = self.opt("graph_dir", None)
graph_dir = Path(graph_dir) if graph_dir else Path(self.paths.graph(suite))
# asdict FIRST -- see BuildCommand.resolve_config: GraphConfig.graph_dir would otherwise
# overwrite the resolved graph_dir below with "".
return {
**dataclasses.asdict(cfg),
"command": "train", "suite": suite, "graph_dir": str(graph_dir),
"stages": list(TRAIN_STAGES), "epochs": self.opt("epochs", DEFAULT_TRAIN_EPOCHS),
"n_query": self.opt("n_query", N_QUERY), "n_eval": self.opt("n_eval", N_EVAL),
"objective": self.opt("objective", RETRIEVAL),
"seed": self.opt("seed", 0), "name": self.args.name,
}
def n_stages(self, config: dict) -> int:
return len(config["stages"])
def plan_line(self, suite: str, config: dict) -> str:
return f"planned stages: {tuple(config['stages'])} (graph_dir={config['graph_dir']})"
def stage_inputs(self, suite: str, config: dict) -> list[str]:
return [str(Path(config["graph_dir"]) / schema.EDGES_NPZ)]
def validate(self, suite: str, config: dict) -> None:
from onf.graph.train.loss import CHUNK, RETRIEVAL
graph_dir = Path(config["graph_dir"])
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")
# Checked here rather than left to argparse choices, which would need the two constants
# spelled as literals at parser-build time just to avoid importing torch.
if config["objective"] not in (RETRIEVAL, CHUNK):
raise CLIError(f"unknown --objective {config['objective']!r}; "
f"choices: {RETRIEVAL!r}, {CHUNK!r}")
def artifacts_present(self, suite: str, config: dict) -> bool:
return (Path(config["graph_dir"]) / schema.HEAD_NPZ).exists()
def skip_note(self, hit: Path, config: dict) -> str:
checkpoint = Path(config["graph_dir"]) / schema.HEAD_NPZ
return f"identical config already trained at {hit}, checkpoint={checkpoint}"
def execute(self, suite: str, config: dict, log: StageLogger) -> dict:
from onf.graph.train.loop import train_graph
from onf.graph.train.types import TrainGraphSpec
cfg = GraphConfig(device=config["device"])
result = train_graph(
Path(config["graph_dir"]),
TrainGraphSpec(stages=tuple(config["stages"]), epochs=config["epochs"],
seed=config["seed"], device=(config["device"] or None), save=True,
cfg=cfg, logger=log, n_query=config["n_query"],
n_eval=config["n_eval"], suite=suite,
objective=config["objective"]),
)
log.metric(checkpoint_path=result["checkpoint_path"], graph_hash=result["graph_hash"])
return {"checkpoint_path": result["checkpoint_path"], **self._final_epoch(result["history"])}
@staticmethod
def _final_epoch(history: dict[Any, list[Any]]) -> dict:
"""The last stage's epoch == "final" record out of a training history, or {}."""
final: dict = {}
for stage_hist in history.values():
if stage_hist and isinstance(stage_hist[-1], dict) and stage_hist[-1].get("epoch") == "final":
final = stage_hist[-1]
return final
def row(self, result: SuiteResult) -> list[str]:
m = result.metrics
return [result.suite, result.status,
_fmt_metric(m.get("top1", "-")), _fmt_metric(m.get("mrr", "-")),
_fmt_metric(m.get("crossed_top1", "-"))]
# ======================================================================================================
# top-level parser / dispatch
# ======================================================================================================
def _add_common_args(sub: argparse.ArgumentParser) -> None:
"""Attach the run-bookkeeping flags every subcommand shares."""
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/")
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 build_parser() -> argparse.ArgumentParser:
"""Build the top-level parser.
Returns:
A parser whose namespace carries command_cls -- the SuiteCommand subclass
main instantiates and runs.
"""
p = argparse.ArgumentParser(
prog="python -m onf.graph",
description="The demonstration-graph subsystem's offline pipeline (see onf.graph.core.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")
build = 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=14-relation "
"edge set (onf.graph.core.schema; the appended `approach`/`visual_align` are both empty unless "
"asked for). `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.",
)
build.add_argument("--suite", required=True, help="object | spatial | goal | long | all "
"(configs/suites.yaml)")
build.add_argument("--coarsen", type=int, default=None, help=f"raw frames per node "
f"(default: schema.COARSEN={schema.COARSEN})")
build.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)")
build.add_argument("--limit-demos", type=int, default=None, help="cap demos read per hdf5 file "
"(smoke-test a suite without the full 500 demos)")
build.add_argument("--limit-tasks", type=int, default=None, help="cap hdf5 files (tasks) read")
build.add_argument("--virtual-per-entry", type=int, default=None, help="synthesise M virtual "
f"nodes per entry-stratum node, joined by `approach` (default: "
f"schema.VIRTUAL_PER_ENTRY={schema.VIRTUAL_PER_ENTRY}, i.e. off)")
build.add_argument("--visual", action="store_true", help="extract g_visual.npz (DINOv2 over the "
"demo frames) and build the offline soft-DTW `visual_align` relation from it")
_add_common_args(build)
build.set_defaults(command_cls=BuildCommand)
train = sub.add_parser(
"train", help="train GraphRetrieverNet (onf.graph.train.loop) on a built graph",
description="Run the demo-graph retrieval curriculum (stage 1, temporal-advance pretraining, "
"the only stage) and write g_head.npz next to the graph it was trained on, or with "
"--objective chunk train the blend weight against a frozen retriever into g_alpha.npz. "
"Requires `build` to have run first.",
)
train.add_argument("--suite", required=True, help="object | spatial | goal | long | all")
train.add_argument("--epochs", type=int, default=None, help=f"epochs per stage "
f"(default: {DEFAULT_TRAIN_EPOCHS})")
train.add_argument("--n-query", type=int, default=None, help="training queries per stage "
"(default: onf.graph.train.N_QUERY)")
train.add_argument("--n-eval", type=int, default=None, help="held-out queries for per-epoch metrics "
"(default: onf.graph.train.N_EVAL)")
train.add_argument("--graph-dir", default=None, help="override the graph dir to train on/into "
"(default: Paths.graph(<suite>))")
train.add_argument("--objective", default=None, help="which loss to train: retrieval (stage 1's "
"WHEN/WHERE factorisation, the default) or chunk (stage 2's "
"mse(a_exec, a_corr) -- freezes the retriever, trains the blend weight and "
"writes g_alpha.npz; needs a_pi_raw.npz and action_scale.json beside the "
"graph)")
_add_common_args(train)
train.set_defaults(command_cls=TrainCommand)
return p
def main(argv: list[str] | None = None) -> int:
"""CLI entrypoint.
Args:
argv: Argument list; None uses sys.argv[1:].
Returns:
The process exit code (0 success, 1 failure).
"""
args = build_parser().parse_args(sys.argv[1:] if argv is None else argv)
try:
return args.command_cls(args).run()
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:
33.7 kB
·
Xet hash:
06a73a32e50a3b74c550155d30f387aaade777e60c3415e46d0220c2b6c8f61d

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