Buckets:
| """Tests for onf.graph.train.loop — the demonstration-graph retriever's training curriculum. | |
| No HDF5/suite dependency: every test builds a small, synthetic, fully in-memory graph (straight-line | |
| toy demos through onf.graph.core.nodes.NodeTable.from_demos + onf.graph.core.edges.EdgeSet.build, | |
| mirroring tests/test_retrieve.py's fixture style) so this runs fast and standalone in .venv-core. | |
| coarsen=1 throughout (rather than the production default) sidesteps a real landmine unrelated to | |
| this module: NodeTable.validate requires the table-wide node phase to reach >0.99, which needs | |
| a raw length congruent to 1 (mod coarsen) to land a coarse group of size 1 on the final raw frame | |
| (see tests/test_retrieve.py's N_FRAMES/COARSEN comment) — coarsen=1 makes every raw | |
| frame its own node and removes that constraint entirely, which is all these tests need. | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| import numpy as np | |
| import pytest | |
| import torch | |
| from onf.config import GraphConfig | |
| from onf.graph.core import schema | |
| from onf.graph.core.edges import EdgeSet | |
| from onf.graph.net.gnn import GraphRetrieverNet | |
| from onf.graph.core.nodes import NodeTable | |
| from onf.graph.train.types import QuerySpec, TrainGraphSpec | |
| from onf.graph.train.loop import save_checkpoint, train_graph | |
| from onf.graph.core.geometry import CleanlinessScorer | |
| from onf.graph.train.data import label_smooth_targets, make_queries, mine_negatives | |
| from onf.graph.train.metrics import evaluate | |
| D = 4 # small synthetic joint dim -- any works, node_encode/edges don't care | |
| # ====================================================================================================== | |
| # synthetic-graph fixtures | |
| # ====================================================================================================== | |
| def _make_demo(task_id: int, seed: int, n: int, dim: int = D, base: np.ndarray | None = None) -> dict: | |
| """A straight-line synthetic demo (mirrors tests/test_retrieve.py's _make_demo): joint | |
| config drifts linearly along a random unit direction from an optional per-task base offset, so | |
| different tasks occupy well-separated regions of joint space and different demos of one task are | |
| still geometrically distinguishable (different random direction).""" | |
| rng = np.random.RandomState(seed) | |
| start = (base if base is not None else np.zeros(dim)) + rng.randn(dim) * 0.01 | |
| direction = rng.randn(dim) | |
| direction /= np.linalg.norm(direction) | |
| t = np.arange(n, dtype=np.float32) | |
| q = (start[None, :] + 0.05 * t[:, None] * direction[None, :]).astype(np.float32) | |
| qdot = np.zeros_like(q) | |
| qdot[:-1] = q[1:] - q[:-1] | |
| qdot[-1] = qdot[-2] if n > 1 else 0.0 | |
| grip = np.zeros(n, dtype=np.uint8) | |
| stage = np.zeros(n, dtype=np.int16) | |
| return {"q": q, "qdot": qdot, "grip": grip, "stage": stage, "task_id": task_id} | |
| def _toy_graph(n_tasks: int = 2, demos_per_task: int = 6, length: int = 40, coarsen: int = 1, | |
| dim: int = D, seed: int = 0) -> tuple[NodeTable, EdgeSet]: | |
| rng = np.random.RandomState(seed) | |
| demos = [] | |
| for ti in range(n_tasks): | |
| base = rng.randn(dim) * 2.0 | |
| for di in range(demos_per_task): | |
| demos.append(_make_demo(ti, seed=1000 * ti + di + seed, n=length, dim=dim, base=base)) | |
| nodes = NodeTable.from_demos(demos, task_names=[f"task{i}" for i in range(n_tasks)], coarsen=coarsen) | |
| edges = EdgeSet.build(nodes, k_sib=4, k_align=2, device="cpu") | |
| return nodes, edges | |
| def _loop_demo(t: int, task_id: int, dim: int = D, seed: int = 0) -> dict: | |
| """An OUT-AND-BACK demo: q rises then returns EXACTLY to its start (a triangle profile), so the | |
| first and last raw frame are the identical config at maximally different phase (0.0 vs ~1.0) — the | |
| self-intersection geometry mine_negatives's bucket (b) exists to catch.""" | |
| rng = np.random.RandomState(seed) | |
| direction = rng.randn(dim) | |
| direction /= np.linalg.norm(direction) | |
| idx = np.arange(t) | |
| profile = np.minimum(idx, t - 1 - idx).astype(np.float32) # 0 .. peak .. 0 | |
| q = (0.05 * profile[:, None] * direction[None, :]).astype(np.float32) | |
| qdot = np.zeros_like(q) | |
| qdot[:-1] = q[1:] - q[:-1] | |
| qdot[-1] = qdot[-2] if t > 1 else 0.0 | |
| grip = np.zeros(t, dtype=np.uint8) | |
| stage = np.zeros(t, dtype=np.int16) | |
| return {"q": q, "qdot": qdot, "grip": grip, "stage": stage, "task_id": task_id} | |
| def _selfx_graph(t: int = 21, dim: int = D, seed: int = 0) -> tuple[NodeTable, int, int, int]: | |
| """demo0 = the out-and-back loop (node 0 and node t-1 are the SAME config at phase 0.0 / ~1.0 — | |
| bucket (b)/(c) material for positive = t-1); demo1 = a straight-line demo of the SAME length, | |
| offset far from the origin (guarantees a bucket-(a) same-t_idx different-strand match at | |
| t_idx=t-1, without ever contaminating bucket (b)/(c)). Returns (nodes, positive, expected_a, | |
| expected_bc). | |
| """ | |
| demo0 = _loop_demo(t, task_id=0, dim=dim, seed=seed) | |
| demo1 = _make_demo(task_id=0, seed=seed + 1, n=t, dim=dim, base=np.full(dim, 5.0)) | |
| nodes = NodeTable.from_demos([demo0, demo1], task_names=["task0"], coarsen=1) | |
| positive = t - 1 # last node of demo0: q == demo0's start, phase ~= 1.0 | |
| expected_a = 2 * t - 1 # demo1's t_idx == t-1 node (different strand, same t_idx) | |
| expected_bc = 0 # demo0's own start: q identical to positive, phase == 0.0 | |
| return nodes, positive, expected_a, expected_bc | |
| def _write_graph(tmp_path, nodes: NodeTable, edges: EdgeSet): | |
| d = tmp_path / "graph" | |
| d.mkdir(exist_ok=True) | |
| nodes.save(d) | |
| edges.save(d / schema.EDGES_NPZ) | |
| return d | |
| class _RowStubField: | |
| """Deterministic per-row f(q), keyed on call order -- lets a test dictate exactly which window | |
| rows are "dirty" without a real ONFField (mirrors tests/test_retrieve.py's _StubField).""" | |
| def __init__(self, f_by_row): | |
| self._f = list(f_by_row) | |
| self._i = 0 | |
| def f_value(self, q): | |
| v = self._f[self._i % len(self._f)] | |
| self._i += 1 | |
| return v | |
| class _NormStubField: | |
| """A field-shaped stub whose f_value is a pure function of q -- reusable indefinitely | |
| (unlike _RowStubField's call-order list), so it survives the many CleanlinessScorer.weights calls a | |
| full make_queries/evaluate pass makes.""" | |
| def f_value(self, q): | |
| return float(np.linalg.norm(np.asarray(q, dtype=np.float64))) | |
| # ====================================================================================================== | |
| # make_queries | |
| # ====================================================================================================== | |
| def test_make_queries_shapes_dtypes(): | |
| nodes, _edges = _toy_graph(n_tasks=2, demos_per_task=4, length=40) | |
| rng = np.random.RandomState(0) | |
| owners = np.arange(nodes.n_demos) | |
| out = make_queries(nodes, rng, n=25, owners=owners, spec=QuerySpec(window=8, advance=4)) | |
| assert out["q_hist"].shape == (25, 8, nodes.dim) | |
| assert out["qdot_hist"].shape == (25, 8, nodes.dim) | |
| assert out["grip_hist"].shape == (25, 8) | |
| assert out["w_hist"].shape == (25, 8) | |
| assert out["q_hist"].dtype == np.float32 | |
| assert out["qdot_hist"].dtype == np.float32 | |
| assert out["w_hist"].dtype == np.float32 | |
| assert out["tgt_node"].dtype == np.int64 | |
| assert out["src_node"].dtype == np.int64 | |
| assert out["src_owner"].dtype == np.int32 | |
| assert not np.any(np.isnan(out["q_hist"])) | |
| def test_make_queries_targets_ahead_same_demo(): | |
| nodes, _edges = _toy_graph(n_tasks=2, demos_per_task=4, length=40) | |
| rng = np.random.RandomState(1) | |
| owners = np.arange(nodes.n_demos) | |
| out = make_queries(nodes, rng, n=80, owners=owners, spec=QuerySpec(window=8, advance=4)) | |
| for i in range(80): | |
| src_v, tgt_v = int(out["src_node"][i]), int(out["tgt_node"][i]) | |
| assert nodes.owner[src_v] == nodes.owner[tgt_v] == out["src_owner"][i] | |
| assert nodes.t_idx[tgt_v] >= nodes.t_idx[src_v] | |
| assert nodes.t_raw[tgt_v] >= nodes.t_raw[src_v] | |
| def test_make_queries_restricted_to_requested_owners(): | |
| nodes, _edges = _toy_graph(n_tasks=2, demos_per_task=5, length=40) | |
| rng = np.random.RandomState(2) | |
| owners = np.array([0, 2, 4]) | |
| out = make_queries(nodes, rng, n=60, owners=owners, spec=QuerySpec(window=8, advance=4)) | |
| assert set(out["src_owner"].tolist()) <= set(owners.tolist()) | |
| assert set(nodes.owner[out["src_node"]].tolist()) <= set(owners.tolist()) | |
| assert set(nodes.owner[out["tgt_node"]].tolist()) <= set(owners.tolist()) | |
| # ====================================================================================================== | |
| # make_queries: variable-length history + episode-start (entry) query class | |
| # ====================================================================================================== | |
| def test_make_queries_variable_window_length_matches_edge_pad(): | |
| """The true history length W varies 1..window per TRAVERSAL query (entry_frac=0 isolates it), and | |
| the padding is byte-identical to onf.graph.core.geometry.edge_pad_hist -- the single source of truth | |
| shared with QueryPreprocessor.build.""" | |
| from onf.graph.core.geometry import edge_pad_hist | |
| nodes, _edges = _toy_graph(n_tasks=1, demos_per_task=3, length=60) | |
| rng = np.random.RandomState(0) | |
| owners = np.arange(nodes.n_demos) | |
| window = 8 | |
| out = make_queries(nodes, rng, n=300, owners=owners, spec=QuerySpec(window=window, advance=4, entry_frac=0.0)) | |
| assert np.all(out["is_clean"]) | |
| eff_len = np.array([len(np.unique(out["q_hist"][i], axis=0)) for i in range(300)]) | |
| assert eff_len.min() == 1, "no query drew the shortest (W=1) history" | |
| assert eff_len.max() == window, "no query drew the full (W=window) history" | |
| # padding parity: for a query whose effective length is w, edge_pad_hist(the last w raw rows, window) | |
| # must reproduce q_hist[i] exactly. | |
| for i in range(300): | |
| w = int(eff_len[i]) | |
| raw_tail = out["q_hist"][i, -w:] | |
| padded, _ = edge_pad_hist(raw_tail, window) | |
| assert np.array_equal(padded, out["q_hist"][i]) | |
| def test_make_queries_entry_class_targets_same_demo_opening(): | |
| """Entry (episode-start) queries: W ~ Uniform(1, window) REAL consecutive frames from the demo's | |
| own start (a constant offset added to all of them), target = advance raw frames past the window's | |
| OWN (unperturbed) end, on the SAME demo -- is_clean=False (provenance).""" | |
| nodes, _edges = _toy_graph(n_tasks=2, demos_per_task=5, length=40) | |
| rng = np.random.RandomState(3) | |
| owners = np.arange(nodes.n_demos) | |
| window = 8 | |
| out = make_queries(nodes, rng, n=200, owners=owners, spec=QuerySpec(window=window, advance=4, entry_frac=1.0)) | |
| assert not np.any(out["is_clean"]) | |
| # window length is ALSO variable for entry queries now (no longer pinned at W=1) -- some queries | |
| # must draw the shortest and some the longest history, exactly like TRAVERSAL. | |
| eff_len = np.array([len(np.unique(out["q_hist"][i], axis=0)) for i in range(200)]) | |
| assert eff_len.min() == 1, "no entry query drew the shortest (W=1) history" | |
| assert eff_len.max() == window, "no entry query drew the full (W=window) history" | |
| for i in range(200): | |
| owner = int(out["src_owner"][i]) | |
| src_v, tgt_v = int(out["src_node"][i]), int(out["tgt_node"][i]) | |
| lo = int(nodes.raw_ptr[owner]) | |
| assert nodes.owner[src_v] == nodes.owner[tgt_v] == owner | |
| # src sits within the demo's OWN opening window raw frames (the window is drawn from t=0) | |
| assert lo <= nodes.t_raw[src_v] <= lo + window - 1 + schema.COARSEN | |
| assert nodes.t_raw[tgt_v] >= nodes.t_raw[src_v] # target is ahead-or-tied | |
| def test_make_queries_entry_and_traversal_vh_distributions_overlap(): | |
| """THE FIX FOR THE STAGE-1 SHORTCUT: before this, every entry query had vh IDENTICALLY ZERO (a | |
| single perturbed frame edge-padded across the whole window), so stage 1's "abstain" (traversal) vs | |
| "fire" (entry) classes were trivially separable on "is this window flat" alone -- nothing to do | |
| with competence. Adding the perturbation as a CONSTANT offset over a REAL multi-frame window (see | |
| module docstring, "EPISODE-START AS A TRAINED CASE") removes that shortcut: the two classes' vh | |
| distributions must now overlap. | |
| """ | |
| nodes, _edges = _toy_graph(n_tasks=2, demos_per_task=8, length=60) | |
| owners = np.arange(nodes.n_demos) | |
| rng_e = np.random.RandomState(11) | |
| entry = make_queries(nodes, rng_e, n=300, owners=owners, spec=QuerySpec(window=8, advance=4, entry_frac=1.0)) | |
| rng_t = np.random.RandomState(12) | |
| trav = make_queries(nodes, rng_t, n=300, owners=owners, spec=QuerySpec(window=8, advance=4, entry_frac=0.0)) | |
| entry_norms = np.linalg.norm(entry["qdot_hist"], axis=-1).mean(axis=-1) # [n] mean ||vh|| per query | |
| trav_norms = np.linalg.norm(trav["qdot_hist"], axis=-1).mean(axis=-1) | |
| assert entry_norms.max() > 1e-8, "entry vh collapsed back to all-zero -- the shortcut is back" | |
| # IQRs overlap: NOT the old regime where entry sits at a single point (0) strictly below traversal. | |
| e_lo, e_hi = np.percentile(entry_norms, [25, 75]) | |
| t_lo, t_hi = np.percentile(trav_norms, [25, 75]) | |
| assert e_lo <= t_hi and t_lo <= e_hi, ( | |
| f"entry vh IQR=({e_lo:.4f},{e_hi:.4f}) and traversal vh IQR=({t_lo:.4f},{t_hi:.4f}) do not " | |
| "overlap -- window flatness is still a usable class tell" | |
| ) | |
| def test_make_queries_entry_perturbation_magnitude_matches_documented_range(): | |
| """The entry perturbation radius is Uniform(ENTRY_LO_MULT*p50, ENTRY_HI_MULT*p99) of the corpus's | |
| OWN leave-one-demo-out entry noise floor -- measured here, not asserted against a hand-picked | |
| constant. | |
| Uses a SMALL-magnitude toy graph (unlike _toy_graph's default base*2.0 scale) so every | |
| demo start sits well inside the real Panda joint limits _entry_perturb clips to -- with | |
| _toy_graph's normal scale some synthetic demo starts already sit OUTSIDE those limits (an | |
| artifact of "any magnitude works" toy data, unrelated to a real robot corpus), which would make | |
| clipping, not sampling, determine the observed radius and defeat this test's purpose. | |
| """ | |
| from onf.graph.train.drift import PANDA_JOINT_HI, PANDA_JOINT_LO | |
| from onf.graph.train.data import ENTRY_HI_MULT, ENTRY_LO_MULT, entry_perturbation_floor | |
| # Panda joint 4 (index 3) has an ASYMMETRIC range that excludes 0 ([-3.07, -0.07] rad) -- centering | |
| # every toy demo at each joint's OWN mid-range (rather than 0) keeps this small-amplitude synthetic | |
| # graph safely inside every real limit _entry_perturb clips to, so the observed radius reflects the | |
| # sampling distribution rather than an accidental per-joint clip. | |
| center = ((PANDA_JOINT_LO[:D] + PANDA_JOINT_HI[:D]) / 2.0).astype(np.float32) | |
| rng0 = np.random.RandomState(7) | |
| demos = [] | |
| for ti in range(2): | |
| base = rng0.randn(D) * 0.05 | |
| for di in range(6): | |
| demos.append(_make_demo(ti, seed=1000 * ti + di, n=40, dim=D, base=base)) | |
| for d in demos: # shrink the within-demo drift too (default 0.05*t) | |
| d["q"] = d["q"] * 0.05 + center | |
| d["qdot"] *= 0.05 | |
| nodes = NodeTable.from_demos(demos, task_names=["t0", "t1"], coarsen=1) | |
| floor = entry_perturbation_floor(nodes) | |
| lo, hi = ENTRY_LO_MULT * floor["p50"], ENTRY_HI_MULT * floor["p99"] | |
| assert 0.0 < lo < hi | |
| rng = np.random.RandomState(4) | |
| owners = np.arange(nodes.n_demos) | |
| window = 8 | |
| raw_ptr = np.asarray(nodes.raw_ptr) | |
| q_raw = np.asarray(nodes.q_raw, dtype=np.float64) | |
| n = 200 | |
| out = make_queries(nodes, rng, n=n, owners=owners, spec=QuerySpec(window=window, advance=4, entry_frac=1.0)) | |
| # the perturbation is now a CONSTANT offset over a REAL multi-frame window (module docstring, | |
| # "EPISODE-START AS A TRAINED CASE"), so recovering the sampled radius means reading the window's | |
| # OWN earliest row (which corresponds to raw frame lo, perturbed by delta) rather than always | |
| # q_hist[i, -1] -- the earliest real row sits at index window - w_use, w_use recovered the | |
| # same way test_make_queries_variable_window_length_matches_edge_pad does (unique-row count). | |
| radii = np.empty(n) | |
| for i in range(n): | |
| owner = int(out["src_owner"][i]) | |
| q0 = q_raw[raw_ptr[owner]] | |
| w_use = len(np.unique(out["q_hist"][i], axis=0)) | |
| q0_pert = out["q_hist"][i, window - w_use].astype(np.float64) | |
| radii[i] = np.linalg.norm(q0_pert - q0) | |
| assert radii.min() >= lo - 1e-6 | |
| assert radii.max() <= hi + 1e-6 | |
| # spans a real range, not collapsed to one corner by clipping | |
| assert radii.max() - radii.min() > 0.1 * (hi - lo) | |
| # ====================================================================================================== | |
| # make_queries: ADVANCE=0 and the DRIFT query class | |
| # ====================================================================================================== | |
| class _CountingRandomState: | |
| """np.random.RandomState proxy that counts rand() -- the class-mix draws -- and forwards the rest.""" | |
| def __init__(self, seed: int): | |
| self._rng = np.random.RandomState(seed) | |
| self.n_rand = 0 | |
| def rand(self, *args, **kwargs): | |
| self.n_rand += 1 | |
| return self._rng.rand(*args, **kwargs) | |
| def __getattr__(self, name): | |
| return getattr(self._rng, name) | |
| def test_make_queries_drift_frac_zero_is_bit_identical_noop(): | |
| """drift_frac=0.0 must be opt-in to the point of invisibility: identical arrays at the same seed, | |
| and -- the part an equality check alone would not catch -- NOT ONE rng.rand() spent on the drift | |
| check, so the pre-DRIFT stream is reproduced draw for draw (the same property | |
| tests/test_entry_static.py pins for entry_static_frac).""" | |
| nodes, _edges = _toy_graph(n_tasks=2, demos_per_task=4, length=40) | |
| owners = np.arange(nodes.n_demos) | |
| n = 200 | |
| spec = dict(window=8, entry_frac=1 / 3, entry_static_frac=0.15) | |
| off = make_queries(nodes, _CountingRandomState(0), n=n, owners=owners, | |
| spec=QuerySpec(drift_frac=0.0, **spec)) | |
| base = make_queries(nodes, np.random.RandomState(0), n=n, owners=owners, spec=QuerySpec(**spec)) | |
| for key in base: | |
| assert np.array_equal(off[key], base[key]), key | |
| assert not off["is_drift"].any() | |
| # one rand() per row for the static check, one more for every row the static check declined -- | |
| # and nothing else. | |
| rng_off = _CountingRandomState(0) | |
| off = make_queries(nodes, rng_off, n=n, owners=owners, spec=QuerySpec(drift_frac=0.0, **spec)) | |
| n_static = int(off["is_entry_static"].sum()) | |
| assert rng_off.n_rand == n + (n - n_static) | |
| # with the class on, exactly one further rand() per row that is neither static nor entry | |
| rng_on = _CountingRandomState(0) | |
| on = make_queries(nodes, rng_on, n=n, owners=owners, spec=QuerySpec(drift_frac=0.5, **spec)) | |
| n_static, n_drift, n_trav = (int(on[k].sum()) for k in ("is_entry_static", "is_drift", "is_clean")) | |
| assert rng_on.n_rand == n + (n - n_static) + (n_drift + n_trav) | |
| assert n_drift > 0 | |
| def test_make_queries_drift_rows_are_a_clean_prefix_then_a_ramped_tail(): | |
| """The property no other class has: a breakpoint INSIDE the window. Rows before it are the demo's | |
| own raw frames bit-for-bit; rows after it deviate by an offset that RAMPS -- equal increments per | |
| row, reaching the full radius at T -- rather than stepping, which would put the whole radius into | |
| one finite-difference step. Uses demos centred in the real Panda joint limits so _entry_perturb's | |
| clip is not what determines the offset (same reason as | |
| test_make_queries_entry_perturbation_magnitude_matches_documented_range).""" | |
| from onf.graph.core.geometry import edge_pad_hist | |
| from onf.graph.train.drift import PANDA_JOINT_HI, PANDA_JOINT_LO | |
| center = ((PANDA_JOINT_LO[:D] + PANDA_JOINT_HI[:D]) / 2.0).astype(np.float32) | |
| demos = [_make_demo(0, seed=di, n=60, dim=D) for di in range(6)] | |
| for d in demos: | |
| d["q"] = d["q"] * 0.05 + center | |
| d["qdot"] *= 0.05 | |
| nodes = NodeTable.from_demos(demos, task_names=["t0"], coarsen=1) | |
| q_raw = np.asarray(nodes.q_raw, dtype=np.float64) | |
| window, n = 8, 200 | |
| out = make_queries(nodes, np.random.RandomState(5), n=n, owners=np.arange(nodes.n_demos), | |
| spec=QuerySpec(window=window, entry_frac=0.0, drift_frac=1.0)) | |
| assert np.all(out["is_drift"]) and not np.any(out["is_clean"]) | |
| for i in range(n): | |
| t_end = int(nodes.t_raw[out["src_node"][i]]) | |
| w_use = len(np.unique(out["q_hist"][i], axis=0)) # every row of a drift window differs | |
| clean = q_raw[t_end - w_use + 1 : t_end + 1].astype(np.float32) | |
| ref = edge_pad_hist(clean, window)[0].astype(np.float64) | |
| deviation = np.linalg.norm(out["q_hist"][i].astype(np.float64) - ref, axis=1) | |
| breakpoint_row = int((deviation > 0).argmax()) | |
| assert 0 < breakpoint_row <= window - 2, (i, breakpoint_row) # >=1 clean and >=2 drifted rows | |
| assert np.all(deviation[:breakpoint_row] == 0.0), i | |
| step = np.diff(deviation[breakpoint_row - 1 :]) | |
| assert np.all(step > 0), i | |
| assert np.allclose(step, step[0], rtol=0.05), (i, step) # a ramp, not a step | |
| def test_make_queries_advance_zero_labels_the_windows_own_end(): | |
| """ADVANCE=0: the head answers "where am I NOW", so tgt_node IS the window's own end node for | |
| every class -- the deployed transition kernel owns the lookahead.""" | |
| nodes, _edges = _toy_graph(n_tasks=2, demos_per_task=4, length=40) | |
| owners = np.arange(nodes.n_demos) | |
| spec = dict(window=8, entry_static_frac=0.15, drift_frac=0.5) | |
| assert schema.ADVANCE == 0 | |
| now = make_queries(nodes, np.random.RandomState(6), n=150, owners=owners, spec=QuerySpec(**spec)) | |
| assert np.array_equal(now["src_node"], now["tgt_node"]) | |
| assert now["is_drift"].any() and now["is_entry_static"].any() and now["is_clean"].any() | |
| ahead = make_queries(nodes, np.random.RandomState(6), n=150, owners=owners, | |
| spec=QuerySpec(advance=4, **spec)) | |
| assert not np.array_equal(ahead["src_node"], ahead["tgt_node"]) | |
| # ====================================================================================================== | |
| # the abstain CONSEQUENCE label (measure_inter_demo_spacing / abstain_is_correct) and the shared | |
| # train/deploy velocity plumbing -- see module docstring, "THE ABSTAIN NODE" | |
| # ====================================================================================================== | |
| def test_measure_inter_demo_spacing_positive_and_requires_multiple_demos(): | |
| """INTER_DEMO_SPACING (module docstring, 'LABEL BY CONSEQUENCE') is a strictly positive, finite, | |
| corpus-DERIVED scale -- never a hand-picked constant -- and, like | |
| entry_perturbation_floor, needs >= 2 demos for a leave-one-demo-out floor to mean anything. | |
| """ | |
| from onf.graph.train.data import measure_inter_demo_spacing | |
| nodes, _edges = _toy_graph(n_tasks=2, demos_per_task=6, length=30) | |
| spacing = measure_inter_demo_spacing(nodes) | |
| assert np.isfinite(spacing) and spacing > 0.0 | |
| single_demo = _make_demo(task_id=0, seed=0, n=30, dim=D) | |
| single_nodes = NodeTable.from_demos([single_demo], task_names=["t"], coarsen=1) | |
| with pytest.raises(ValueError, match=">= 2 demos"): | |
| measure_inter_demo_spacing(single_nodes) | |
| def test_make_queries_abstain_is_correct_true_for_clean_traversal_windows(): | |
| """A genuinely CLEAN traversal window's last frame IS a raw corpus frame (distance ~0 to the | |
| nearest node), so its CONSEQUENCE label must read abstain-correct=True regardless of the | |
| (irrelevant here) generator-provenance is_clean flag -- the two happen to agree for TRAVERSAL, | |
| which is the point: the old is_clean label was already right for this class, just wrong for | |
| ENTRY/drift (see module docstring, 'LABEL BY CONSEQUENCE').""" | |
| nodes, _edges = _toy_graph(n_tasks=2, demos_per_task=6, length=40) | |
| rng = np.random.RandomState(9) | |
| owners = np.arange(nodes.n_demos) | |
| out = make_queries(nodes, rng, n=150, owners=owners, spec=QuerySpec(entry_frac=0.0)) | |
| assert "abstain_is_correct" in out | |
| assert out["abstain_is_correct"].dtype == bool | |
| assert np.mean(out["abstain_is_correct"]) > 0.95, ( | |
| "clean traversal windows should overwhelmingly label abstain-correct=True" | |
| ) | |
| def test_finite_diff_vel_shared_by_make_queries_and_retrieve(tmp_path): | |
| """Divergence 6 (module docstring, 'SHARED TRAIN/DEPLOY PLUMBING'): the query-construction path | |
| (make_queries, via onf.graph.core.geometry.finite_diff_vel) and | |
| onf.graph.run.retrieve.GraphRetriever.retrieve's own live path must compute the IDENTICAL | |
| velocity for the IDENTICAL raw window. Before the shared helper existed, train.py | |
| edge-extrapolated row 0 while retrieve() zero-padded it, so the SAME window produced two | |
| DIFFERENT query embeddings (and abstain decisions) depending on whether it was seen at train time | |
| or live. | |
| """ | |
| from onf.graph.run.retrieve import GraphRetriever | |
| from onf.graph.core.geometry import finite_diff_vel | |
| nodes, edges = _toy_graph(n_tasks=1, demos_per_task=2, length=30) | |
| net = _small_net(nodes) | |
| d = _write_graph(tmp_path, nodes, edges) | |
| save_checkpoint(net, nodes, edges, d / schema.HEAD_NPZ) | |
| cfg = GraphConfig(hidden=16, layers=2, seed_topk=32, device="cpu") | |
| retriever = GraphRetriever.load(d, cfg=cfg) | |
| window = cfg.hist | |
| q_window = nodes.q_raw[5 : 5 + window].astype(np.float64) # a real, non-padded raw window | |
| assert q_window.shape[0] == window | |
| v_shared = finite_diff_vel(q_window.astype(np.float32)) # (1) train's path | |
| _w = retriever.preprocessor.build(q_window, None) # (2) deploy's path | |
| q_pad, g_pad, padded = _w.q, _w.grip, _w.padded | |
| assert not padded | |
| v_deploy = finite_diff_vel(q_pad) | |
| assert np.array_equal(v_shared.astype(np.float64), v_deploy.astype(np.float64)) | |
| qh = torch.as_tensor(q_pad, dtype=torch.float32) | |
| gh = torch.zeros(window) | |
| w = torch.ones(window) | |
| e_q_train = retriever.net.enc(qh, torch.as_tensor(v_shared, dtype=torch.float32), gh, w) | |
| e_q_deploy = retriever.net.enc(qh, torch.as_tensor(v_deploy, dtype=torch.float32), gh, w) | |
| assert torch.equal(e_q_train, e_q_deploy) | |
| # regression guard: the RETIRED zero-padded convention must actually DISAGREE with the shared one, | |
| # proving this is a real parity check and not a tautology that would pass no matter what. | |
| v_old_zero_pad = torch.zeros_like(qh) | |
| v_old_zero_pad[1:] = qh[1:] - qh[:-1] | |
| e_q_old = retriever.net.enc(qh, v_old_zero_pad, gh, w) | |
| assert not torch.allclose(e_q_train, e_q_old) | |
| # ====================================================================================================== | |
| # cleanliness weighting: the property the whole query-pooling design rests on | |
| # ====================================================================================================== | |
| def test_cleanliness_batch_dirtier_frames_get_lower_weight(): | |
| window = 4 | |
| q_hist = np.zeros((1, window, D), dtype=np.float32) | |
| stub = _RowStubField([0.0, 1.0, 2.0, 3.0]) # monotonically dirtier across the window | |
| w = CleanlinessScorer(stub).weights_batch(q_hist) | |
| assert w.shape == (1, window) | |
| assert np.all(np.diff(w[0]) < 0.0) # strictly decreasing weight as the window gets dirtier | |
| def test_cleanliness_batch_uniform_when_no_field(): | |
| q_hist = np.zeros((3, 5, D), dtype=np.float32) | |
| w = CleanlinessScorer(None).weights_batch(q_hist) | |
| assert np.allclose(w, 1.0) | |
| # ====================================================================================================== | |
| # mine_negatives | |
| # ====================================================================================================== | |
| def test_mine_negatives_never_returns_positive(): | |
| nodes, positive, _a, _bc = _selfx_graph() | |
| query = nodes.q[positive] | |
| for trial in range(20): | |
| rng = np.random.RandomState(trial) | |
| negs = mine_negatives(nodes, query, positive, rng, n_neg=8) | |
| assert positive not in negs.tolist() | |
| def test_mine_negatives_returns_each_category_when_available(): | |
| nodes, positive, expected_a, expected_bc = _selfx_graph() | |
| query = nodes.q[positive] | |
| seen: set[int] = set() | |
| for trial in range(30): | |
| rng = np.random.RandomState(trial) | |
| negs = mine_negatives(nodes, query, positive, rng, n_neg=8) | |
| seen.update(negs.tolist()) | |
| assert expected_a in seen, "bucket (a): same t_idx, different strand, never sampled" | |
| assert expected_bc in seen, "bucket (b)/(c): near-q/far-phase + own-kNN node, never sampled" | |
| def test_mine_negatives_falls_back_when_selfx_bucket_empty(): | |
| """A single-demo, monotonic straight-line graph has NO other strand (bucket (a) structurally | |
| empty) and no self-intersection (bucket (b) empty: a straight line's nearest neighbours in q are | |
| its temporal neighbours, whose phase is close too). mine_negatives must still return n_neg | |
| negatives via the uniform-random fallback, never raise, never return the positive. | |
| """ | |
| demo = _make_demo(task_id=0, seed=0, n=30, dim=D) | |
| nodes = NodeTable.from_demos([demo], task_names=["t"], coarsen=1) | |
| positive = 15 | |
| rng = np.random.RandomState(0) | |
| negs = mine_negatives(nodes, nodes.q[positive], positive, rng, n_neg=6) | |
| assert len(negs) == 6 | |
| assert positive not in negs.tolist() | |
| assert len(np.unique(negs)) == 6 | |
| # ====================================================================================================== | |
| # label_smooth_targets | |
| # ====================================================================================================== | |
| def test_label_smooth_sums_to_one_peaks_at_positive_and_decays(): | |
| demo = _make_demo(task_id=0, seed=0, n=30, dim=D) | |
| nodes = NodeTable.from_demos([demo], task_names=["t"], coarsen=1) | |
| positive = 15 # mid-demo, so the +/- window is not clipped by the demo boundary | |
| cand, w = label_smooth_targets(nodes, positive) | |
| assert np.isclose(float(w.sum()), 1.0, atol=1e-5) | |
| assert int(cand[np.argmax(w)]) == positive | |
| dt = np.abs(nodes.t_idx[cand].astype(np.int64) - int(nodes.t_idx[positive])) | |
| order = np.argsort(dt) | |
| assert np.all(np.diff(w[order]) <= 1e-9) # weight non-increasing as |dt| grows | |
| def test_label_smooth_multi_positive_spans_other_strands_of_the_same_task(): | |
| """Track GR change 2: the candidate set is NO LONGER confined to positive's own strand -- every | |
| node sharing its task_id within +/- WHERE_PHASE_BAND phase is a valid answer (the old same-strand- | |
| only invariant this test used to check is exactly what the redesign replaces; see module docstring, | |
| "MULTI-POSITIVE WHERE LABELS").""" | |
| nodes, _edges = _toy_graph(n_tasks=2, demos_per_task=3, length=20) | |
| positive = 5 | |
| cand, w = label_smooth_targets(nodes, positive) | |
| assert np.isclose(float(w.sum()), 1.0, atol=1e-5) | |
| assert int(cand[np.argmax(w)]) == positive | |
| assert np.all(nodes.task_id[cand] == nodes.task_id[positive]) | |
| assert np.all(np.abs(nodes.phase[cand] - nodes.phase[positive]) <= schema.WHERE_PHASE_BAND + 1e-6) | |
| assert len(set(nodes.owner[cand].tolist())) > 1, ( | |
| "candidate set should span more than one strand of the same task" | |
| ) | |
| def test_label_smooth_never_leaks_onto_another_task(): | |
| """The one invariant the multi-positive redesign KEEPS: task_id never leaks -- an off-task node is | |
| never a valid WHERE answer no matter how close its phase happens to be.""" | |
| nodes, _edges = _toy_graph(n_tasks=2, demos_per_task=3, length=20) | |
| positive = 5 | |
| cand, _w = label_smooth_targets(nodes, positive) | |
| assert np.all(nodes.task_id[cand] == nodes.task_id[positive]) | |
| def test_label_smooth_movement_cost_weighting_prefers_the_closer_candidate(): | |
| """When q_now is supplied, two same-task/same-phase candidates at different distances from | |
| q_now must NOT get equal weight -- the closer one (lower movement cost) gets more mass, and | |
| dominates a same-magnitude farther peer even when both sit exactly as close to positive in | |
| phase.""" | |
| nodes, _edges = _toy_graph(n_tasks=1, demos_per_task=4, length=30) | |
| positive = 15 # owner 0 | |
| # a same-task/phase-band peer node on a different strand, and its own q | |
| cand0, _w0 = label_smooth_targets(nodes, positive) | |
| peers = [v for v in cand0 if v != positive] | |
| assert peers, "need at least one cross-strand peer for this test to mean anything" | |
| peer = peers[0] | |
| q_now_near_peer = nodes.q[peer].astype(np.float64) # q_now coincides with the peer -> peer cost 0 | |
| cand, w = label_smooth_targets(nodes, positive, q_now_near_peer) | |
| w_by_node = dict(zip(cand.tolist(), w.tolist())) | |
| # positive still wins (true_bonus), but the peer must clearly outweigh other, farther candidates | |
| assert w_by_node[int(peer)] > 0.0 | |
| other_far = [v for v in cand if v not in (positive, peer)] | |
| if other_far: | |
| assert w_by_node[int(peer)] >= max(w_by_node[v] for v in other_far) | |
| def test_label_smooth_true_continuation_stays_highest_even_when_a_peer_is_closer(): | |
| """The TRUE continuation must remain the single highest-weighted candidate (WHERE_TRUE_BONUS) even | |
| when q_now is chosen to make some OTHER same-task/phase candidate's raw movement cost lower than | |
| the true continuation's own -- module docstring, "nothing is lost where the strand IS | |
| identifiable".""" | |
| nodes, _edges = _toy_graph(n_tasks=1, demos_per_task=4, length=30) | |
| positive = 15 | |
| cand0, _w0 = label_smooth_targets(nodes, positive) | |
| peers = [v for v in cand0 if v != positive] | |
| assert peers | |
| peer = peers[0] | |
| q_now_at_peer = nodes.q[peer].astype(np.float64) # peer's OWN raw cost is 0 -- strictly closer | |
| cand, w = label_smooth_targets(nodes, positive, q_now_at_peer) | |
| assert int(cand[np.argmax(w)]) == positive | |
| # ====================================================================================================== | |
| # train_graph | |
| # ====================================================================================================== | |
| def test_gnn_metrics_flags_constant_abstain_decision_as_separation_failure(): | |
| """MAKING COLLAPSE IMPOSSIBLE TO MISS (module docstring): a COLLAPSED decision (pred_abstain | |
| constant across the whole held-out batch -- exactly the original bug's symptom, checkpoint | |
| abstain_rate == 1.000 on both clean AND fire-should windows) must NOT read as healthy. Forcing | |
| the net's abstain head to a constant logit collapses BOTH abstain_false_fire_rate and | |
| abstain_recovery_recall to the SAME value (0 or 1), so the separation check | |
| false_fire < recall this module's behavioural test relies on genuinely FAILS on a collapsed | |
| net -- proof this harness would have caught the original bug rather than reading | |
| (0.000, 0.000) as a perfect score. Uses ENTRY (not the deleted drift-generator stage-2 arm) as | |
| the "should fire" class -- entry_frac=1.0 forces every query to be a perturbed ENTRY window. | |
| """ | |
| nodes, edges = _toy_graph(n_tasks=2, demos_per_task=6, length=32) | |
| net = _small_net(nodes) | |
| with torch.no_grad(): | |
| net.abstain[2].bias.data.fill_(1e6) # abstain always wins -- forced constant collapse | |
| rng = np.random.RandomState(0) | |
| owners = np.arange(nodes.n_demos) | |
| clean = make_queries(nodes, rng, n=40, owners=owners, spec=QuerySpec(entry_frac=0.0)) | |
| fire = make_queries(nodes, rng, n=40, owners=owners, spec=QuerySpec(entry_frac=1.0)) | |
| cfg = GraphConfig(hidden=16, layers=2, seed_topk=32, device="cpu") | |
| clean_metrics = evaluate(net, nodes, edges, clean, cfg=cfg, device="cpu") | |
| fire_metrics = evaluate(net, nodes, edges, fire, cfg=cfg, device="cpu") | |
| assert clean_metrics["abstain_rate"] == 1.0 | |
| assert fire_metrics["abstain_rate"] == 1.0 | |
| # the collapse-detection contract this module relies on: separation must fail loudly on a | |
| # collapsed net, not read as "(0.000, 0.000) == perfect". | |
| assert not (clean_metrics["abstain_false_fire_rate"] < fire_metrics["abstain_recovery_recall"]) | |
| # the raw rate alone already makes it unambiguous, independent of how the two class-conditional | |
| # rates happen to read. | |
| assert clean_metrics["abstain_rate"] == fire_metrics["abstain_rate"] == 1.0 | |
| def test_train_graph_survives_zero_reached_pathological_seeding(tmp_path): | |
| """Integration-level companion to | |
| test_query_loss_handles_zero_reached_via_fallback_anchor: the whole curriculum (stage 1, the | |
| only stage left) must run to completion, with finite losses throughout, even when EVERY query | |
| seeds zero nodes. | |
| """ | |
| nodes, edges = _toy_graph(n_tasks=2, demos_per_task=4, length=28) | |
| d = _write_graph(tmp_path, nodes, edges) | |
| cfg = GraphConfig(hidden=12, layers=1, seed_topk=0, device="cpu") | |
| result = train_graph( | |
| d, TrainGraphSpec(stages=(1,), epochs=2, seed=0, device="cpu", save=False, cfg=cfg, n_query=40, n_eval=10), | |
| ) | |
| for entry in result["history"]["stage1"]: | |
| if entry["epoch"] != "final": | |
| assert np.isfinite(entry["loss"]) | |
| def test_train_graph_records_inter_demo_spacing(tmp_path): | |
| """train_graph computes INTER_DEMO_SPACING ONCE per run and records it (module docstring, "LABEL BY | |
| CONSEQUENCE": "computed once per graph ... and recorded in the run's metrics").""" | |
| from onf.graph.train.data import measure_inter_demo_spacing | |
| nodes, edges = _toy_graph(n_tasks=2, demos_per_task=6, length=32) | |
| d = _write_graph(tmp_path, nodes, edges) | |
| cfg = GraphConfig(hidden=12, layers=1, seed_topk=32, device="cpu") | |
| result = train_graph( | |
| d, TrainGraphSpec(stages=(1,), epochs=1, seed=0, device="cpu", save=False, cfg=cfg, n_query=40, n_eval=10), | |
| ) | |
| assert "inter_demo_spacing" in result | |
| assert np.isclose(result["inter_demo_spacing"], measure_inter_demo_spacing(nodes)) | |
| def test_train_graph_determinism_same_seed(tmp_path): | |
| """Same seed -> bit-identical final weights. Forced single-threaded: GraphRetrieverNet's message | |
| passing (a sibling module, onf.graph.net.gnn) uses index_add_ scatter-accumulation, whose summation | |
| ORDER (and hence exact floating-point result) is a known PyTorch non-determinism under multi- | |
| threaded CPU execution -- not a bug in this module, just a precondition for exact reproducibility | |
| that RandomState(seed)/torch.manual_seed(seed) alone do not cover. | |
| """ | |
| prev_threads = torch.get_num_threads() | |
| torch.set_num_threads(1) | |
| try: | |
| nodes, edges = _toy_graph(n_tasks=2, demos_per_task=5, length=32) | |
| d = _write_graph(tmp_path, nodes, edges) | |
| cfg = GraphConfig(hidden=12, layers=2, seed_topk=32, device="cpu") | |
| spec = TrainGraphSpec( | |
| stages=(1,), epochs=2, seed=0, device="cpu", save=False, cfg=cfg, n_query=60, n_eval=10, | |
| ) | |
| r1 = train_graph(d, spec) | |
| r2 = train_graph(d, spec) | |
| finally: | |
| torch.set_num_threads(prev_threads) | |
| sd1, sd2 = r1["net"].state_dict(), r2["net"].state_dict() | |
| assert set(sd1) == set(sd2) | |
| for k in sd1: | |
| assert torch.equal(sd1[k], sd2[k]), k | |
| # ====================================================================================================== | |
| # checkpoint round-trip | |
| # ====================================================================================================== | |
| def _small_net(nodes: NodeTable) -> GraphRetrieverNet: | |
| net = GraphRetrieverNet(dim=nodes.dim, hidden=16, layers=2, n_rel=schema.N_RELATIONS, agg="sum") | |
| mean, std = nodes.feature_stats() | |
| net.set_stats(mean, std) | |
| net.eval() | |
| return net | |
| def test_checkpoint_roundtrip(tmp_path): | |
| """save_checkpoint writes a state dict GraphRetrieverNet.load_npz can read back | |
| bit-for-bit -- the graph_hash STAMP itself (and refusing a mismatched one) is | |
| onf.graph.run.retrieve.GraphRetriever.load's job, tested alongside that class.""" | |
| nodes, edges = _toy_graph(n_tasks=2, demos_per_task=4, length=30) | |
| net = _small_net(nodes) | |
| path = save_checkpoint(net, nodes, edges, tmp_path / "g_head.npz") | |
| loaded = GraphRetrieverNet.load_npz(path) | |
| sd1, sd2 = net.state_dict(), loaded.state_dict() | |
| assert set(sd1) == set(sd2) | |
| for k in sd1: | |
| assert torch.allclose(sd1[k], sd2[k]) | |
| # ====================================================================================================== | |
| # evaluate | |
| # ====================================================================================================== | |
| def test_evaluate_returns_documented_keys(): | |
| nodes, edges = _toy_graph(n_tasks=2, demos_per_task=6, length=40) | |
| net = _small_net(nodes) | |
| rng = np.random.RandomState(0) | |
| owners = np.arange(nodes.n_demos) | |
| queries = make_queries(nodes, rng, n=15, owners=owners, spec=QuerySpec(field=_NormStubField())) | |
| cfg = GraphConfig(hidden=16, layers=2, seed_topk=32, device="cpu") | |
| out = evaluate(net, nodes, edges, queries, field=_NormStubField(), cfg=cfg, device="cpu") | |
| for key in ( | |
| "top1", "top10", "mrr", "when_mae_steps", "when_mae_phase", "where_rmse", "own_task_frac", | |
| "move_dist_mean", "move_dist_median", | |
| "when_phase_argmax_corr", "when_phase_argmax_mae", "when_phase_expect_corr", "when_phase_expect_mae", | |
| "crossed_n", "crossed_top1", "crossed_top10", "crossed_mrr", "crossed_when_mae_steps", | |
| "crossed_where_rmse", | |
| ): | |
| assert key in out, key | |
| assert np.isfinite(out["move_dist_mean"]) | |
| assert np.isfinite(out["when_phase_expect_mae"]) | |
| def test_evaluate_transfer_across_differently_sized_graph(): | |
| """The inductive claim, made executable: one trained net runs unmodified on a graph built from a | |
| different suite with a different node count.""" | |
| nodes_a, _edges_a = _toy_graph(n_tasks=2, demos_per_task=4, length=30, seed=1) | |
| nodes_b, edges_b = _toy_graph(n_tasks=3, demos_per_task=7, length=50, seed=2) | |
| assert len(nodes_a) != len(nodes_b) | |
| assert nodes_a.dim == nodes_b.dim | |
| net = _small_net(nodes_a) | |
| rng = np.random.RandomState(0) | |
| queries_b = make_queries(nodes_b, rng, n=10, owners=np.arange(nodes_b.n_demos)) | |
| cfg = GraphConfig(hidden=16, layers=2, seed_topk=32, device="cpu") | |
| out = evaluate(net, nodes_b, edges_b, queries_b, cfg=cfg, device="cpu") | |
| assert "top1" in out and np.isfinite(out["top1"]) | |
| # ====================================================================================================== | |
| # CLASS BALANCE, PART 2 -- the abstain-loss inverse-frequency weighting (module docstring, "CLASS | |
| # BALANCE"; PART 1, the primary mechanism, is onf.graph.train.drift's generator calibration -- see | |
| # tests/test_drift.py -- and is out of scope for this module's own tests). | |
| # ====================================================================================================== | |
| def test_query_loss_batch_abstain_weight_scales_gradient_linearly(): | |
| """abstain_weight multiplies the margin term BEFORE it enters the total loss | |
| (loss_abstain = abstain_weight * softplus(...)), so the gradient it contributes to the abstain | |
| bias must scale EXACTLY linearly with abstain_weight -- unlike the softplus nonlinearity itself, | |
| a constant multiplier outside it passes straight through the chain rule. This is what makes | |
| inverse-frequency reweighting ( w_c = n / (2*n_c) ) an honest per-class GRADIENT rescaling, not | |
| an approximation. Single-row batch, since _query_loss_batch is the only training-step | |
| implementation left.""" | |
| from onf.graph.train.data import _node_feature_matrix | |
| from onf.graph.train.loss import _query_loss_batch | |
| from onf.graph.train.types import GraphTensors, QueryBatch | |
| nodes, edges = _toy_graph(n_tasks=1, demos_per_task=2, length=30) | |
| net = _small_net(nodes) | |
| cfg = GraphConfig(hidden=16, layers=2, seed_topk=32, device="cpu") | |
| node_x_t = torch.as_tensor(_node_feature_matrix(nodes)) | |
| et = edges.torch("cpu") | |
| src, dst, rel, log_idf = et["src"].long(), et["dst"].long(), et["rel"], et["log_idf"] | |
| phase_t = torch.as_tensor(nodes.phase.astype(np.float32)) | |
| bin_idx_t = torch.clamp((phase_t * schema.NBINS_ALIGN).long(), max=schema.NBINS_ALIGN - 1) | |
| gt = GraphTensors(nodes=nodes, edges=edges, node_x_t=node_x_t, src=src, dst=dst, rel=rel, | |
| log_idf=log_idf, phase_t=phase_t, bin_idx_t=bin_idx_t) | |
| window = cfg.hist | |
| q_hist = np.repeat(nodes.q_raw[10][None, :], window, axis=0).astype(np.float32) | |
| qh = torch.as_tensor(q_hist)[None] | |
| vh = torch.zeros_like(qh) | |
| gh, w = torch.zeros(1, window), torch.ones(1, window) | |
| tgt = np.array([int(nodes.demo_nodes(0)[5])]) | |
| src_v = np.array([int(nodes.demo_nodes(0)[2])]) | |
| def grad_at(weight: float, label: bool) -> float: | |
| net.zero_grad() | |
| rng = np.random.RandomState(0) # same draw for mine_negatives -- isolates weight's own effect | |
| qb = QueryBatch(qh=qh, vh=vh, gh=gh, w=w, tgt=tgt, src_v=src_v, | |
| abstain_is_correct=np.array([label]), abstain_weight=np.array([weight]), | |
| is_entry_static=np.array([False]), is_drift=np.array([False]), | |
| break_row=np.array([-1]), perturb_radius=np.array([0.0])) | |
| loss = _query_loss_batch(net, gt, qb, rng, cfg) | |
| loss.backward() | |
| return float(net.abstain[2].bias.grad.item()) | |
| for label in (True, False): | |
| g1 = grad_at(1.0, label) | |
| g3 = grad_at(3.0, label) | |
| assert g3 == pytest.approx(3.0 * g1, rel=1e-4), (label, g1, g3) | |
| # ====================================================================================================== | |
| # PHASE-PRIMARY OBJECTIVE (Track GR change 1) -- gradients must reach the message-passing TRUNK, not | |
| # just the (auxiliary) node-ranking head, from the phase-CE/phase-expectation terms alone. | |
| # ====================================================================================================== | |
| def test_query_loss_batch_phase_terms_alone_reach_message_passing_trunk(): | |
| """Isolate the PRIMARY phase terms (cfg.phase_ce_w/cfg.phase_expect_w) from every other | |
| term (node-identity cfg.node_w=0, and the SRC/ABSTAIN auxiliaries zeroed via a custom | |
| onf.graph.train.types.LossSpec) and confirm gradient still reaches | |
| net.rel/net.node/net.upd -- the shared message-passing trunk | |
| onf.graph.net.gnn.GraphRetrieverNet.propagate uses -- not merely the readout head. If this | |
| failed, the phase objective would be training a decorative loss term that never actually shapes | |
| retrieval, silently defeating the whole redesign.""" | |
| from onf.graph.train.data import _node_feature_matrix | |
| from onf.graph.train.loss import DEFAULT_LOSS_SPEC, _query_loss_batch | |
| from onf.graph.train.types import GraphTensors, QueryBatch | |
| import dataclasses | |
| zeroed_spec = dataclasses.replace(DEFAULT_LOSS_SPEC, src_aux_w=0.0, abstain_w=0.0) | |
| nodes, edges = _toy_graph(n_tasks=1, demos_per_task=2, length=30) | |
| net = _small_net(nodes) | |
| cfg = GraphConfig(hidden=16, layers=2, seed_topk=32, device="cpu", node_w=0.0, | |
| phase_ce_w=1.0, phase_expect_w=1.0) | |
| node_x_t = torch.as_tensor(_node_feature_matrix(nodes)) | |
| et = edges.torch("cpu") | |
| src, dst, rel, log_idf = et["src"].long(), et["dst"].long(), et["rel"], et["log_idf"] | |
| phase_t = torch.as_tensor(nodes.phase.astype(np.float32)) | |
| bin_idx_t = torch.clamp((phase_t * schema.NBINS_ALIGN).long(), max=schema.NBINS_ALIGN - 1) | |
| gt = GraphTensors(nodes=nodes, edges=edges, node_x_t=node_x_t, src=src, dst=dst, rel=rel, | |
| log_idf=log_idf, phase_t=phase_t, bin_idx_t=bin_idx_t) | |
| window = cfg.hist | |
| q_hist = np.repeat(nodes.q_raw[10][None, :], window, axis=0).astype(np.float32) | |
| qh = torch.as_tensor(q_hist)[None] | |
| vh = torch.zeros_like(qh) | |
| gh, w = torch.zeros(1, window), torch.ones(1, window) | |
| tgt = np.array([int(nodes.demo_nodes(0)[5])]) | |
| src_v = np.array([int(nodes.demo_nodes(0)[2])]) | |
| rng = np.random.RandomState(0) | |
| net.zero_grad() | |
| qb = QueryBatch(qh=qh, vh=vh, gh=gh, w=w, tgt=tgt, src_v=src_v, | |
| abstain_is_correct=np.array([False]), abstain_weight=np.array([1.0]), | |
| is_entry_static=np.array([False]), is_drift=np.array([False]), | |
| break_row=np.array([-1]), perturb_radius=np.array([0.0])) | |
| loss = _query_loss_batch(net, gt, qb, rng, cfg, zeroed_spec) | |
| loss.backward() | |
| for mod_name in ("rel", "node", "upd"): | |
| mod = getattr(net, mod_name) | |
| grads = [p.grad for p in mod.parameters()] | |
| assert all(g is not None for g in grads), f"{mod_name}: no gradient reached at all" | |
| assert any(float(g.abs().sum()) > 0 for g in grads), f"{mod_name}: gradient is all-zero" | |
| def test_stage_trainer_weights_abstain_loss_inversely_to_realised_class_frequency(monkeypatch): | |
| """StageTrainer (module docstring, "CLASS BALANCE", PART 2) must compute, from the query set's | |
| OWN realised abstain_is_correct frequency, w_c = n / (2 * n_c) per class and thread it into | |
| every _query_loss_batch call as its abstain_weight ARRAY -- verified here by monkeypatching | |
| _query_loss_batch with a spy that records the per-query (label, weight) pairs it was called | |
| with (zipping the batched abstain_is_correct/abstain_weight arrays back into per-query | |
| values), on a query set DELIBERATELY skewed 90/10 so the two classes' weights are clearly | |
| distinguishable (not both ~1.0, which a no-op bug could pass by accident). qbatch=1 forces one | |
| query per batched call, so this also doubles as a coverage check that every query is visited | |
| exactly once per epoch under the new batched loop, same as the old per-query one.""" | |
| import onf.graph.train.loop as train_mod | |
| from onf.graph.train.loss import DEFAULT_LOSS_SPEC | |
| from onf.graph.train.types import EvalQueries, QueryData, StageSpec | |
| nodes, edges = _toy_graph(n_tasks=2, demos_per_task=6, length=32) | |
| net = _small_net(nodes) | |
| net.train() | |
| cfg = GraphConfig(hidden=16, layers=2, seed_topk=32, device="cpu", qbatch=1) | |
| opt = torch.optim.AdamW(net.parameters(), lr=1e-3) | |
| rng = np.random.RandomState(0) | |
| owners = np.arange(nodes.n_demos) | |
| n_q = 20 | |
| queries = make_queries(nodes, rng, n=n_q, owners=owners, spec=QuerySpec(entry_frac=0.0)) | |
| # force a KNOWN 90/10 abstain/fire split, independent of whatever make_queries happened to draw -- | |
| # this test is about the WEIGHT FORMULA, not about make_queries' own realised balance. | |
| abstain_is_correct = np.zeros(n_q, dtype=bool) | |
| abstain_is_correct[: round(0.9 * n_q)] = True | |
| queries["abstain_is_correct"] = abstain_is_correct | |
| held_queries = make_queries(nodes, rng, n=10, owners=owners, spec=QuerySpec(entry_frac=0.0)) | |
| n_abstain, n_fire = int(abstain_is_correct.sum()), int((~abstain_is_correct).sum()) | |
| expected_w_abstain = n_q / (2.0 * n_abstain) | |
| expected_w_fire = n_q / (2.0 * n_fire) | |
| seen_weights: dict[bool, list[float]] = {True: [], False: []} | |
| real_query_loss_batch = train_mod._query_loss_batch | |
| def spy(*args, **kwargs): | |
| # StageTrainer calls _query_loss_batch(net, gt, qb, rng, cfg) positionally -- qb (a | |
| # QueryBatch) is the 3rd positional arg (index 2) and carries abstain_is_correct/ | |
| # abstain_weight as its own fields; read it back out defensively (falls back to kwargs in | |
| # case a future refactor passes it by keyword instead). | |
| qb = args[2] if len(args) > 2 else kwargs["qb"] | |
| for label, weight in zip(np.asarray(qb.abstain_is_correct), np.asarray(qb.abstain_weight)): | |
| seen_weights[bool(label)].append(float(weight)) | |
| return real_query_loss_batch(*args, **kwargs) | |
| monkeypatch.setattr(train_mod, "_query_loss_batch", spy) | |
| gt = train_mod._prepare_tensors(nodes, edges, "cpu") | |
| spec = StageSpec(epochs=1, rng=rng, dev="cpu", cfg=cfg, logger=None, stage_name="stageT", field=None, loss_spec=DEFAULT_LOSS_SPEC) | |
| evalq = EvalQueries(held=QueryData.from_dict(held_queries)) | |
| train_mod.StageTrainer(net, opt, gt, train_mod.QuerySet.from_queries(queries, "cpu"), evalq, spec).run() | |
| assert len(seen_weights[True]) == n_abstain | |
| assert len(seen_weights[False]) == n_fire | |
| for wv in seen_weights[True]: | |
| assert wv == pytest.approx(expected_w_abstain) | |
| for wv in seen_weights[False]: | |
| assert wv == pytest.approx(expected_w_fire) | |
| # the point of inverse-frequency balancing: the MINORITY class (fire, 10%) gets the LARGER weight. | |
| assert expected_w_fire > expected_w_abstain | |
| # ================================================================================================== | |
| # STAGE 2 -- the chunk-blend target (onf.blend.kinematics, onf.blend.target) and the loss over it. | |
| # ================================================================================================== | |
| ARTIFACT_DIR = Path(__file__).resolve().parents[1] / "outputs/long/latest/artifacts" | |
| skip_no_corpus = pytest.mark.skipif( | |
| not (ARTIFACT_DIR / "g_nodes.npz").exists(), | |
| reason=f"needs the recorded corpus at {ARTIFACT_DIR} to check FK against stored obs/ee_*", | |
| ) | |
| def _real_nodes() -> NodeTable: | |
| return NodeTable.load(ARTIFACT_DIR) | |
| def test_forward_kinematics_round_trips_the_recorded_end_effector_pose(): | |
| """The FK model behind ee_now, checked against the poses LIBERO actually recorded. | |
| Three claims, because three different things could be wrong. (1) The absolute position model | |
| ee_pos = base_t + fk_pos + R @ tool_d holds, with tool_d the rigid grip-site offset -- fitted | |
| per TASK, because base_t is a property of the SCENE and not of the robot: it is | |
| [-0.660, 0, 0.912] on the kitchen scenes, [-0.510, 0, 0.420] on the living-room ones and | |
| [-0.750, 0, 0.912] on the study one, so one pooled fit lands 26 cm out. (2) The absolute | |
| orientation reproduces obs/ee_ori in ITS unnormalized, unwrapped convention, not merely up to | |
| that convention. (3) The claim NodeTable.ee_base rests on: world minus base is a PURE | |
| TRANSLATION, constant within a scene and large across scenes. If it were not, moving the | |
| reference segment into the base frame would rotate it, and the action deltas built from it | |
| would no longer be the world-frame deltas the policy's chunk is stated in. | |
| """ | |
| pytest.importorskip("pytorch_kinematics") | |
| from onf.blend.ee_track import matrix_to_rotvec, rotvec_to_matrix | |
| from onf.blend.kinematics import PandaKinematics, matrix_to_ee_rotvec | |
| nodes = _real_nodes() | |
| kin = PandaKinematics() | |
| demo_task = np.array([int(nodes.task_id[nodes.demo_ptr[j]]) for j in range(nodes.n_demos)]) | |
| # Several demos per task, not one: base_t and R @ tool_d are only separable across a spread of | |
| # wrist orientations, and a single demo does not span enough of them to condition the fit. | |
| demos = [int(j) for t in np.unique(demo_task) for j in np.flatnonzero(demo_task == t)[:5]] | |
| frames = np.concatenate( | |
| [np.arange(nodes.raw_ptr[j], nodes.raw_ptr[j + 1]) for j in demos] | |
| ) | |
| pose = kin.flange(np.asarray(nodes.q_raw, dtype=np.float64)[frames]) | |
| stored = np.asarray(nodes.ee_raw, dtype=np.float64)[frames] | |
| # (1) absolute position, one [I | R] least-squares fit per task. | |
| owner = np.searchsorted(nodes.raw_ptr, frames, side="right") - 1 | |
| for t in np.unique(demo_task): | |
| rows = np.isin(owner, np.flatnonzero(demo_task == t)) | |
| flange = pose.position[rows] - np.einsum("nij,j->ni", pose.rotation[rows], kin.tool_offset) | |
| design = np.concatenate( | |
| [np.tile(np.eye(3), (int(rows.sum()), 1, 1)), pose.rotation[rows]], axis=2 | |
| ).reshape(-1, 6) | |
| target = (stored[rows, :3] - flange).reshape(-1) | |
| fit, *_ = np.linalg.lstsq(design, target, rcond=None) | |
| residual = np.linalg.norm((design @ fit - target).reshape(-1, 3), axis=1) | |
| assert fit[3:] == pytest.approx(kin.tool_offset, abs=5e-4), (t, fit) | |
| assert np.sqrt((residual ** 2).mean()) < 5e-4, (t, residual.max()) | |
| # (2) absolute orientation, in obs/ee_ori's own convention. | |
| ori_err = np.linalg.norm(matrix_to_ee_rotvec(pose.rotation) - stored[:, 3:], axis=1) | |
| assert np.sqrt((ori_err ** 2).mean()) < 3e-3 | |
| assert ori_err.max() < 3e-2 | |
| # (3) world minus base, per scene. The position offset is a constant within a task and the | |
| # rotation between the two frames is the identity, so the offset drops out of every DIFFERENCE | |
| # the tracking chunk takes -- but only once both operands are on the same side of it. | |
| base = kin.pose(np.asarray(nodes.q_raw, dtype=np.float64)[frames]) | |
| rot_err = np.linalg.norm( | |
| matrix_to_rotvec( | |
| rotvec_to_matrix(base[:, 3:]) @ np.swapaxes(rotvec_to_matrix(stored[:, 3:]), -1, -2) | |
| ), | |
| axis=1, | |
| ) | |
| assert np.sqrt((rot_err ** 2).mean()) < 3e-3 and rot_err.max() < 3e-2 | |
| offsets = [] | |
| for t in np.unique(demo_task): | |
| rows = np.isin(owner, np.flatnonzero(demo_task == t)) | |
| delta = stored[rows, :3] - base[rows, :3] | |
| assert delta.std(axis=0).max() < 5e-4, (t, delta.std(axis=0)) | |
| offsets.append(delta.mean(axis=0)) | |
| spread = np.linalg.norm(np.asarray(offsets)[:, None] - np.asarray(offsets)[None, :], axis=-1) | |
| assert spread.max() > 0.4, spread # the teleport ee_base exists to remove | |
| def test_a_corr_on_an_unperturbed_window_is_the_pose_derived_demo_track(): | |
| """On a clean window the target must reduce to the demo's OWN pose-derived track. | |
| a_corr is EeTrack.chunk(demo segment at the label node, the window's current pose), and on a | |
| TRAVERSAL row that current pose is a frame the corpus recorded. So a_corr has to equal the same | |
| chunk built straight off that frame's own base-frame pose -- if it does not, ee_now has drifted | |
| off the frame the reference segment lives in and every row of the chunk is measured against the | |
| wrong origin. | |
| """ | |
| pytest.importorskip("pytorch_kinematics") | |
| from onf.blend.ee_track import ACTION_LIMIT, ActionScale, EeTrack | |
| from onf.blend.kinematics import PandaKinematics | |
| from onf.blend.kinematics import PandaKinematics | |
| from onf.blend.target import ChunkTargetBuilder, PolicyActionCache | |
| nodes = _real_nodes() | |
| scale = ActionScale.from_json(ARTIFACT_DIR / "action_scale.json") | |
| builder = ChunkTargetBuilder( | |
| nodes=nodes, scale=scale, policy=PolicyActionCache.load(ARTIFACT_DIR).check_against(nodes), | |
| kinematics=PandaKinematics(), chunk_len=schema.SEG_K, | |
| ) | |
| queries = make_queries( | |
| nodes, np.random.RandomState(0), 64, np.arange(nodes.n_demos), QuerySpec(entry_frac=0.0) | |
| ) | |
| assert queries["perturb_radius"].max() == 0.0, "entry_frac=0 must give a wholly clean set" | |
| targets = builder.build(queries) | |
| track = EeTrack(scale, bound=ACTION_LIMIT) | |
| for i, (tgt, t_now) in enumerate(zip(queries["tgt_node"], queries["t_raw_now"])): | |
| expected = track.chunk( | |
| nodes.ee_segment(int(tgt), schema.SEG_K), | |
| np.asarray(nodes.ee_base, dtype=np.float64)[int(t_now)], | |
| ) | |
| # The tolerance is float32's: reference and live pose now come from the SAME forward | |
| # kinematics, and ee_base is stored f32, so nothing else separates the two. | |
| assert targets.a_corr[i] == pytest.approx(expected, abs=1e-3), i | |
| # a_policy must be the frozen policy's chunk at the window's OWN frame, not the label node's. | |
| assert targets.a_policy.shape == (64, schema.SEG_K, 7) | |
| assert targets.a_policy[:, 0, :] == pytest.approx( | |
| np.load(ARTIFACT_DIR / "a_pi_raw.npz")["a_pi_raw"][queries["t_raw_now"]] | |
| ) | |
| def _query_batch(queries, qh, window, n_q): | |
| """One onf.graph.train.types.QueryBatch off a make_queries result, chunk fields left unset.""" | |
| from onf.graph.train.types import QueryBatch | |
| return QueryBatch( | |
| qh=qh, vh=torch.zeros_like(qh), gh=torch.zeros(n_q, window), w=torch.ones(n_q, window), | |
| tgt=queries["tgt_node"], src_v=queries["src_node"], | |
| abstain_is_correct=queries["abstain_is_correct"], abstain_weight=np.ones(n_q), | |
| is_entry_static=queries["is_entry_static"], is_drift=queries["is_drift"], | |
| break_row=queries["break_row"], perturb_radius=queries["perturb_radius"], | |
| ) | |
| def test_chunk_loss_reaches_both_the_blend_weight_and_the_message_passing_trunk(): | |
| """The whole point of the objective: mse(a_exec, a_corr) must train BOTH branches of the blend. | |
| alpha decides how much of the tracking chunk is executed and the retriever's posterior decides | |
| what that chunk is, so a gradient reaching only one of them would leave the other free. The | |
| trunk (net.rel/node/upd) is checked rather than the readout head alone for the same reason | |
| test_query_loss_batch_phase_terms_alone_reach_message_passing_trunk checks it. | |
| Also pins the torch tracking chunk against onf.blend.ee_track.EeTrack, the numpy one the deploy | |
| path runs: they are two statements of one construction and are allowed to differ only in dtype. | |
| """ | |
| from onf.blend.alpha import AlphaNet | |
| from onf.blend.ee_track import ActionScale, EeTrack | |
| from onf.blend.target import track_chunk | |
| from onf.graph.train.loss import CHUNK, DEFAULT_LOSS_SPEC, ChunkLossComputer | |
| from onf.graph.train.data import _node_feature_matrix | |
| from onf.graph.train.types import GraphTensors | |
| import dataclasses | |
| nodes, edges = _toy_graph(n_tasks=1, demos_per_task=2, length=30) | |
| net = _small_net(nodes) | |
| cfg = GraphConfig(hidden=16, layers=2, seed_topk=32, device="cpu") | |
| seg_k, n_q = 4, 3 | |
| scale = ActionScale(pos=0.0116, rot=0.1092, r2_pos=1.0, r2_rot=1.0) | |
| rng = np.random.RandomState(0) | |
| ee_table = rng.randn(len(nodes), seg_k, 6).astype(np.float32) * 0.1 | |
| ee_now = rng.randn(n_q, 6).astype(np.float32) * 0.1 | |
| # The torch and numpy tracking chunks must agree before anything is asserted about gradients, | |
| # both unbounded and saturated, and both on and far off the reference -- under pursuit every | |
| # row is a function of the rows before it, so a disagreement in one compounds through the rest. | |
| from onf.blend.ee_track import ACTION_LIMIT | |
| off_path = ee_now + np.array([0.09, -0.04, 0.02, 0.0, 0.0, 0.0], dtype=np.float32) | |
| for bound in (None, ACTION_LIMIT): | |
| for live in (ee_now, off_path): | |
| expected = np.stack( | |
| [EeTrack(scale, bound=bound).chunk(ee_table[i], live[i]) for i in range(n_q)] | |
| ) | |
| got = track_chunk(torch.as_tensor(ee_table[:n_q]), torch.as_tensor(live), scale, bound) | |
| assert got.numpy() == pytest.approx(expected, abs=1e-5), bound | |
| got = track_chunk(torch.as_tensor(ee_table[:n_q]), torch.as_tensor(ee_now), scale) | |
| et = edges.torch("cpu") | |
| phase_t = torch.as_tensor(nodes.phase.astype(np.float32)) | |
| gt = GraphTensors( | |
| nodes=nodes, edges=edges, node_x_t=torch.as_tensor(_node_feature_matrix(nodes)), | |
| src=et["src"].long(), dst=et["dst"].long(), rel=et["rel"], log_idf=et["log_idf"], | |
| phase_t=phase_t, | |
| bin_idx_t=torch.clamp((phase_t * schema.NBINS_ALIGN).long(), max=schema.NBINS_ALIGN - 1), | |
| ee_table=torch.as_tensor(ee_table), action_scale=scale, | |
| ) | |
| window = cfg.hist | |
| qh = torch.as_tensor(np.repeat(nodes.q_raw[:n_q][:, None, :], window, axis=1)) | |
| queries = make_queries(nodes, np.random.RandomState(1), n_q, np.arange(nodes.n_demos), | |
| QuerySpec(entry_frac=0.0, window=window)) | |
| qb = dataclasses.replace( | |
| _query_batch(queries, qh, window, n_q), | |
| a_policy=torch.as_tensor(rng.randn(n_q, seg_k, 7).astype(np.float32)), | |
| a_corr=torch.as_tensor(rng.randn(n_q, seg_k, 6).astype(np.float32)), | |
| ee_now=torch.as_tensor(ee_now), | |
| ) | |
| alpha_net = AlphaNet(embed_dim=cfg.hidden, chunk_len=seg_k, hidden=8) | |
| spec = dataclasses.replace(DEFAULT_LOSS_SPEC, objective=CHUNK) | |
| net.zero_grad() | |
| alpha_net.zero_grad() | |
| ChunkLossComputer(net, alpha_net, cfg, spec)(gt, qb).backward() | |
| for name in ("rel", "node", "upd"): | |
| grads = [p.grad for p in getattr(net, name).parameters()] | |
| assert all(g is not None for g in grads), f"{name}: no gradient reached at all" | |
| assert any(float(g.abs().sum()) > 0 for g in grads), f"{name}: gradient is all-zero" | |
| # The blend weight AND the saturation amplitude, which is the row-0 magnitude bound. | |
| assert float(alpha_net.mlp[-1].weight.grad.abs().sum()) > 0, "no gradient reached alpha" | |
| # A batch missing any chunk input must say so rather than quietly training something else. | |
| with pytest.raises(ValueError, match="a_corr"): | |
| ChunkLossComputer(net, alpha_net, cfg, spec)(gt, dataclasses.replace(qb, a_corr=None)) | |
| # ====================================================================================================== | |
| # Stage 2 -- the CHUNK objective's wiring through QuerySet / StageTrainer / GraphTrainer | |
| # ====================================================================================================== | |
| def _chunk_stage(nodes, edges, net, cfg, seg_k, n_q, n_held, seed=0): | |
| """One CHUNK-objective StageTrainer over a toy graph, with synthetic chunk targets. | |
| ChunkTargetBuilder needs a recorded corpus (ee_raw + a policy-action cache), which a toy graph | |
| has none of, so the targets are drawn at random here: this exercises the WIRING, and | |
| test_chunk_loss_reaches_both_the_blend_weight_and_the_message_passing_trunk already pins the | |
| numerics against onf.blend.ee_track. | |
| Args: | |
| nodes: Toy node table. | |
| edges: Its edge set. | |
| net: The retriever, already frozen by the caller. | |
| cfg: Graph configuration. | |
| seg_k: Chunk length. | |
| n_q: Training queries. | |
| n_held: Held-out queries. | |
| seed: Seed for the synthetic targets and the query draw. | |
| Returns: | |
| (StageTrainer, AlphaNet, optimiser). | |
| """ | |
| import dataclasses | |
| from onf.blend.alpha import AlphaNet | |
| from onf.blend.ee_track import ActionScale | |
| from onf.blend.target import ChunkTargets | |
| from onf.graph.train import loop as train_mod | |
| from onf.graph.train.corrupt import RetrievalCorruptor | |
| from onf.graph.train.loss import CHUNK, DEFAULT_LOSS_SPEC | |
| from onf.graph.train.data import _node_feature_matrix | |
| from onf.graph.train.types import EvalQueries, GraphTensors, QueryData, StageSpec | |
| rng = np.random.RandomState(seed) | |
| scale = ActionScale(pos=0.0116, rot=0.1092, r2_pos=1.0, r2_rot=1.0) | |
| et = edges.torch("cpu") | |
| phase_t = torch.as_tensor(nodes.phase.astype(np.float32)) | |
| gt = GraphTensors( | |
| nodes=nodes, edges=edges, node_x_t=torch.as_tensor(_node_feature_matrix(nodes)), | |
| src=et["src"].long(), dst=et["dst"].long(), rel=et["rel"], log_idf=et["log_idf"], | |
| phase_t=phase_t, | |
| bin_idx_t=torch.clamp((phase_t * schema.NBINS_ALIGN).long(), max=schema.NBINS_ALIGN - 1), | |
| ee_table=torch.as_tensor(rng.randn(len(nodes), seg_k, 6).astype(np.float32) * 0.1), | |
| action_scale=scale, | |
| ) | |
| def make_set(n): | |
| queries = make_queries(nodes, rng, n, np.arange(nodes.n_demos), | |
| QuerySpec(entry_frac=0.5, window=cfg.hist)) | |
| qs = train_mod.QuerySet.from_queries( | |
| queries, "cpu", None, RetrievalCorruptor(nodes), rng | |
| ) | |
| targets = ChunkTargets( | |
| a_policy=rng.randn(n, seg_k, 7).astype(np.float32) * 0.1, | |
| a_corr=rng.randn(n, seg_k, 6).astype(np.float32) * 0.1, | |
| ee_now=rng.randn(n, 6).astype(np.float32) * 0.1, | |
| ee_ref=rng.randn(n, seg_k, 6).astype(np.float32) * 0.1, | |
| ) | |
| return queries, dataclasses.replace(qs, targets=targets) | |
| train_queries, train_set = make_set(n_q) | |
| held_queries, held_set = make_set(n_held) | |
| alpha_net = AlphaNet(embed_dim=cfg.hidden, chunk_len=seg_k, hidden=8) | |
| opt = torch.optim.AdamW(alpha_net.parameters(), lr=1e-2) | |
| stage = train_mod.StageTrainer( | |
| net=net, opt=opt, gt=gt, queries=train_set, | |
| evalq=EvalQueries(held=QueryData.from_dict(held_queries), held_set=held_set), | |
| spec=StageSpec(epochs=1, rng=rng, dev="cpu", cfg=cfg, logger=None, stage_name="stage2", | |
| field=None, loss_spec=dataclasses.replace(DEFAULT_LOSS_SPEC, objective=CHUNK)), | |
| alpha_net=alpha_net, | |
| ) | |
| return stage, alpha_net, opt | |
| def test_chunk_stage_trains_alpha_alone_and_reports_the_gate(capsys): | |
| """The Stage 2 smoke: one CHUNK epoch runs, the loss is finite, ONLY alpha moves, gate prints. | |
| The three claims are one claim. A finite loss says the corruption, the reference segment and the | |
| DTW teacher all reached the batch in the shapes the loss expects; an unchanged retriever says | |
| the freeze held; and the stratified gate line is the number the whole stage exists to produce, | |
| so a run that trains without printing it cannot be judged. | |
| """ | |
| from onf.graph.train.corrupt import KIND_NAMES | |
| nodes, edges = _toy_graph(n_tasks=2, demos_per_task=4, length=30) | |
| cfg = GraphConfig(hidden=16, layers=2, seed_topk=32, device="cpu", qbatch=8) | |
| net = _small_net(nodes) | |
| net.requires_grad_(False) | |
| net.eval() | |
| stage, alpha_net, _ = _chunk_stage(nodes, edges, net, cfg, seg_k=4, n_q=16, n_held=12) | |
| before_net = {k: v.detach().clone() for k, v in net.state_dict().items()} | |
| before_alpha = {k: v.detach().clone() for k, v in alpha_net.state_dict().items()} | |
| history = stage.run() | |
| assert np.isfinite(history[0]["loss"]), history[0]["loss"] | |
| assert np.isfinite(history[0]["chunk_mse"]), "model selection could not score the blended chunk" | |
| for k, v in net.state_dict().items(): | |
| assert torch.equal(v, before_net[k]), f"the frozen retriever's {k} moved" | |
| assert any( | |
| not torch.equal(v, before_alpha[k]) for k, v in alpha_net.state_dict().items() | |
| ), "one epoch left the blend weight untouched" | |
| strata = stage.report_alpha_strata() | |
| assert sum(strata.counts.values()) == 12 | |
| assert all(0.0 < strata.mean_alpha[n] < 1.0 for n in KIND_NAMES if strata.counts[n]), strata | |
| assert "alpha strata: clean" in capsys.readouterr().out | |
| def test_chunk_objective_freezes_the_trained_head_and_saves_g_alpha(tmp_path): | |
| """GraphTrainer under CHUNK freezes Stage 1's head, optimises alpha alone, and writes g_alpha.npz. | |
| Three ways this silently produces a useless artifact: the retriever is randomly initialised | |
| rather than loaded, so alpha fits a posterior nobody deploys; the optimiser still holds | |
| retriever parameters; or the run rewrites the head it froze. All three look like success. | |
| """ | |
| from onf.blend.alpha import ALPHA_NPZ, AlphaNet | |
| from onf.blend.ee_track import ActionScale | |
| from onf.blend.target import ChunkTargetBuilder | |
| from onf.graph.train.loop import GraphTrainer | |
| from onf.graph.train.loss import CHUNK | |
| nodes, edges = _toy_graph(n_tasks=2, demos_per_task=4, length=30) | |
| cfg = GraphConfig(hidden=16, layers=2, device="cpu") | |
| head = _small_net(nodes) | |
| head_bytes = Path( | |
| save_checkpoint(head, nodes, edges, tmp_path / schema.HEAD_NPZ) | |
| ).read_bytes() | |
| trainer = GraphTrainer(tmp_path, TrainGraphSpec(cfg=cfg, objective=CHUNK, device="cpu")) | |
| trainer.nodes, trainer.edges = nodes, edges | |
| # Only chunk_len is read below; the builder's own inputs belong to the recorded corpus. | |
| trainer.chunk = ChunkTargetBuilder( | |
| nodes=nodes, scale=ActionScale(pos=0.01, rot=0.1, r2_pos=1.0, r2_rot=1.0), | |
| policy=None, kinematics=None, chunk_len=5, | |
| ) | |
| trainer._build_model() | |
| assert not any(p.requires_grad for p in trainer.net.parameters()) | |
| for k, v in head.state_dict().items(): | |
| assert torch.allclose(v, trainer.net.state_dict()[k]), f"{k}: not the trained head" | |
| owned = {id(p) for group in trainer.opt.param_groups for p in group["params"]} | |
| assert owned == {id(p) for p in trainer.alpha.parameters()} | |
| trainer.save() | |
| assert (tmp_path / schema.HEAD_NPZ).read_bytes() == head_bytes, "the frozen head was rewritten" | |
| loaded = AlphaNet.load(tmp_path / ALPHA_NPZ) | |
| assert (loaded.embed_dim, loaded.chunk_len) == (cfg.hidden, 5) | |
| for k, v in trainer.alpha.state_dict().items(): | |
| assert torch.equal(v, loaded.state_dict()[k]), k | |
| def test_a_policy_corruptor_actually_reaches_the_cached_chunks(): | |
| """The wiring, not the sampler: PolicyCorruptor -> QuerySet -> ChunkTargets.a_policy. | |
| Every piece of this can be correct while the corruption never reaches the array the loss reads, | |
| and the symptom would be an artifact that trains cleanly and contains no change at all. | |
| """ | |
| from onf.blend.ee_track import ActionScale | |
| from onf.blend.kinematics import PandaKinematics | |
| from onf.blend.target import ChunkTargetBuilder, PolicyActionCache | |
| from onf.graph.train.corrupt import P_CLEAN, PolicyCorruptor, RetrievalCorruptor | |
| from onf.graph.train.loop import QuerySet | |
| # dim=7: ChunkTargetBuilder reads nodes.ee_segment, which forward-kinematicks a Panda. | |
| nodes, _edges = _toy_graph(n_tasks=3, demos_per_task=3, length=40, dim=7) | |
| n_raw = int(nodes.n_raw) | |
| rng = np.random.RandomState(0) | |
| cache = PolicyActionCache( | |
| a_pi_raw=np.tile(np.arange(n_raw, dtype=np.float32)[:, None], (1, 7)), | |
| raw_ptr=np.asarray(nodes.raw_ptr, dtype=np.int64), | |
| ) | |
| builder = ChunkTargetBuilder( | |
| nodes=nodes, scale=ActionScale(pos=0.0116, rot=0.1092, r2_pos=1.0, r2_rot=1.0), | |
| policy=cache, kinematics=PandaKinematics(), chunk_len=4, | |
| ) | |
| queries = make_queries(nodes, rng, 256, np.arange(nodes.n_demos), QuerySpec(window=8)) | |
| corruptor = PolicyCorruptor(nodes, RetrievalCorruptor(nodes)) | |
| clean = QuerySet.from_queries(queries, torch.device("cpu"), builder) | |
| dirty = QuerySet.from_queries( | |
| queries, torch.device("cpu"), builder, rng=np.random.default_rng(0), | |
| policy_corruptor=corruptor, | |
| ) | |
| kind = dirty.policy_corrupt.kind | |
| assert (kind != P_CLEAN).sum() > 40, "the sampler produced nothing to check" | |
| moved = ~np.all(clean.targets.a_policy == dirty.targets.a_policy, axis=(1, 2)) | |
| # Every corrupted row moved, and no clean row did. A GAIN of ~1.0 is possible but vanishingly | |
| # unlikely over a log-uniform draw across two decades. | |
| assert np.array_equal(moved, kind != P_CLEAN), (moved.sum(), int((kind != P_CLEAN).sum())) | |
| # The TARGET is untouched: that is what makes a corrupted-policy row push alpha UP. | |
| assert np.array_equal(clean.targets.a_corr, dirty.targets.a_corr) | |
Xet Storage Details
- Size:
- 74.7 kB
- Xet hash:
- 50535210e479467f5e8e71aaf433e506b9deeba40f20fba57a3a06456c112d09
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.