twanghcmut's picture
download
raw
20.4 kB
"""Tests for onf.graph.run.readout — synthetic-only, runs in .venv-core (numpy + torch, no HDF5,
no trained GNN). The retrieval distribution p(v|Q) is hand-built for every test; the module under
test only cares about aggregating a GIVEN p, never about how p was produced.
This repo ships exactly two registered arms: euc_raw (deployed t=0 ENTRY) and basin (deployed
t>0 SENTINEL); every test below exercises only these two.
"""
from __future__ import annotations
import numpy as np
import pytest
import torch
from onf.graph.core import schema
from onf.graph.core.nodes import NodeTable
from onf.graph.core.segments import ee_segment_table, expected_ee_segment
from onf.graph.run.readout import (
BasinGeometry,
ARMS,
TOPK_K_MAX,
TOPK_K_MIN,
ReadoutContext,
ReadoutResult,
make_readout,
)
D = 5 # joint-space dim used throughout
# ======================================================================================================
# synthetic-context helpers
# ======================================================================================================
def _ee_from_q(q: np.ndarray) -> np.ndarray:
"""[T, 6] end-effector track built as q's first 3 columns twice, so a posterior-weighted ee_seg
is comparable column-for-column against the q_seg carrying the same weights."""
return np.concatenate([q[:, :3], q[:, :3]], axis=1).astype(np.float32)
def _declare_ee_base(nodes: NodeTable) -> NodeTable:
"""Stand in for NodeTable.ee_base on a corpus that is not a 7-DoF Panda.
ee_base is normally forward-kinematicked from q_raw, which needs a real robot; these demos are
D=5 random walks. The arms under test only ever ask whether the SAME table flows through the
same support with the same weights, so the demos' own declared ee is the right stand-in."""
nodes.ee_base = nodes.ee_raw
return nodes
def _make_demo(t: int, seed: int, task_id: int = 0, offset: float = 0.0) -> dict:
"""A deterministic, smoothly-moving demo: single stage, gripper open throughout (keeps
coarsen=1 node count exactly equal to frame count, so node indices are hand-traceable)."""
rng = np.random.RandomState(seed)
steps = rng.randn(t, D).astype(np.float32) * 0.01
q = (np.cumsum(steps, axis=0) + offset).astype(np.float32)
qdot = np.zeros_like(q)
qdot[:-1] = q[1:] - q[:-1]
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,
"ee": _ee_from_q(q)}
def _flat_context(n_demos: int = 3, t: int = 12, topm: int = 32) -> tuple[NodeTable, ReadoutContext]:
"""A plain multi-demo table — enough for euc_raw/basin."""
demos = [_make_demo(t, seed=i) for i in range(n_demos)]
nodes = _declare_ee_base(NodeTable.from_demos(demos, task_names=["taskA"], coarsen=1))
return nodes, ReadoutContext(nodes=nodes, topm=topm)
def _fork_context(fork: int = 4, t: int = 12, topm: int = 32) -> tuple[NodeTable, ReadoutContext, int, int]:
"""Two demos sharing an IDENTICAL trunk (t=0..fork) then diverging in joint space
(t=fork+1..T-1) — enough geometric structure to exercise "mass spread over two distinct configs"
without needing any latent embedding (the arms that consumed one -- hyp/euc_embed -- are
gone; see the module docstring).
Returns (nodes, ctx, tip0, tip1) — the two branch-tip node indices mass gets put on.
"""
demos = [_make_demo(t, seed=0, offset=0.0), _make_demo(t, seed=1, offset=5.0)]
nodes = _declare_ee_base(NodeTable.from_demos(demos, task_names=["taskA"], coarsen=1))
ctx = ReadoutContext(nodes=nodes, topm=topm)
tip0 = int(nodes.demo_ptr[0]) + t - 1
tip1 = int(nodes.demo_ptr[1]) + t - 1
return nodes, ctx, tip0, tip1
def _concentrated_p(v: int, n: int) -> np.ndarray:
"""Mass ~entirely on node v, with a tiny positive floor everywhere else so weighted_topk's
k_min filler slots are well-defined (zero weight, but not literally absent probabilities)."""
p = np.full(n, 1e-9)
p[v] = 1.0
return p / p.sum()
def _fixed_demo(q_row: np.ndarray, task_id: int = 0) -> dict:
"""A 2-identical-frame demo holding EXACTLY the given joint config at every frame -- lets a test
hand-pick a candidate q-value precisely (unlike the random walk in _make_demo), while still
giving NodeTable.from_demos enough frames (T=2, so phase spans exactly {0, 1}) to
satisfy the table's own phase-convention invariant (NodeTable.validate requires table-wide
phase max > 0.99 -- a single-frame demo's phase is always exactly 0 and fails that check)."""
q = np.tile(np.asarray(q_row, dtype=np.float32)[None, :], (2, 1))
qdot = np.zeros_like(q)
grip = np.zeros(2, dtype=np.uint8)
stage = np.zeros(2, dtype=np.int16)
return {"q": q, "qdot": qdot, "grip": grip, "stage": stage, "task_id": task_id,
"ee": _ee_from_q(q)}
def _two_node_context(
q0: np.ndarray, q1: np.ndarray, topm: int = 32
) -> tuple[NodeTable, ReadoutContext, int, int]:
"""A graph built from two 2-frame demos (coarsen=1, so each frame is its own node): the LAST
frame of demo 0/1 (phase == 1.0, table indices 1 and 3) carries q0/q1 exactly and is what
every test below puts probability mass on; the FIRST frame of each demo (phase == 0.0) exists only
to satisfy the phase-convention invariant and always gets EXACTLY ZERO probability in these tests,
so it never perturbs a weighted mean (0 * anything == 0) -- lets a test solve for the exact
resulting anchor by hand.
Returns (nodes, ctx, idx0, idx1) -- the node indices carrying q0/q1."""
demos = [_fixed_demo(q0), _fixed_demo(q1)]
nodes = _declare_ee_base(NodeTable.from_demos(demos, task_names=["taskA"], coarsen=1))
ctx = ReadoutContext(nodes=nodes, topm=topm)
return nodes, ctx, 1, 3
# the two arms this repo deploys (module docstring) -- checked directly against ARMS (matches
# schema.READOUT_ARMS).
LIVE_ARMS = ("euc_raw", "basin")
# ======================================================================================================
# registry
# ======================================================================================================
def test_registry_contains_all_arms():
assert set(ARMS) == set(LIVE_ARMS)
assert len(ARMS) == len(LIVE_ARMS)
def test_make_readout_unknown_name_raises_helpful_error():
with pytest.raises(KeyError) as exc:
make_readout("not_a_real_arm")
msg = str(exc.value)
for name in LIVE_ARMS:
assert name in msg
def test_arms_are_stateless_singletons():
assert make_readout("euc_raw") is make_readout("euc_raw") is ARMS["euc_raw"]
assert make_readout("basin") is make_readout("basin") is ARMS["basin"]
# ======================================================================================================
# well-formed results
# ======================================================================================================
def test_well_formed_result_euc_raw():
nodes, ctx = _flat_context()
p = np.full(len(nodes), 1.0 / len(nodes))
res = make_readout("euc_raw")(p, ctx)
assert res.node is None
assert res.q_star.shape == (D,)
assert res.q_seg.shape == (ctx.seg_k, D)
assert res.qdot_seg.shape == (ctx.seg_k, D)
assert res.ee_seg.shape == (ctx.seg_k, 6)
assert np.isfinite(res.q_star).all()
assert np.isfinite(res.q_seg).all() and np.isfinite(res.qdot_seg).all()
assert np.isfinite(res.ee_seg).all()
assert np.isfinite(res.off_manifold)
def test_well_formed_result_basin():
nodes, ctx = _flat_context()
p = np.full(len(nodes), 1.0 / len(nodes))
ctx.q_now = np.asarray(nodes.q[0], dtype=np.float64)
ctx.basin = BasinGeometry(r=np.full((nodes.n_tasks, schema.NBINS_ALIGN), 0.5, dtype=np.float64), h=np.full((nodes.n_tasks, schema.NBINS_ALIGN), 0.1, dtype=np.float64))
ctx.task_id = 0
res = make_readout("basin")(p, ctx)
assert res.node is None
assert res.q_star.shape == (D,)
assert res.q_seg.shape == (ctx.seg_k, D)
assert res.qdot_seg.shape == (ctx.seg_k, D)
assert res.ee_seg.shape == (ctx.seg_k, 6)
assert np.isfinite(res.q_star).all()
assert np.isfinite(res.q_seg).all() and np.isfinite(res.qdot_seg).all()
assert np.isfinite(res.ee_seg).all()
assert np.isfinite(res.off_manifold)
# ======================================================================================================
# euc_raw does not snap
# ======================================================================================================
def test_euc_raw_does_not_snap_on_spread_mass():
nodes, ctx, tip0, tip1 = _fork_context()
n = len(nodes)
p = np.full(n, 1e-9)
p[tip0] = 0.5
p[tip1] = 0.5
p = p / p.sum()
res_raw = make_readout("euc_raw")(p, ctx)
assert res_raw.node is None
q_all = np.asarray(nodes.q, dtype=np.float64)
dists = np.linalg.norm(q_all - res_raw.q_star[None, :], axis=1)
assert dists.min() > 1e-6, "euc_raw's q_star must not coincide with any single node's q"
assert res_raw.off_manifold > 1e-6
def test_euc_raw_concentrated_mass_recovers_the_node():
"""When p concentrates entirely on one node, the p-weighted mean IS that node's own q (up
to the tiny floor mass on every other node) -- the free-form blend degenerates to the single-strand
answer, same as any other aggregation would under concentrated mass."""
nodes, ctx, tip0, _ = _fork_context()
p = _concentrated_p(tip0, len(nodes))
res = make_readout("euc_raw")(p, ctx)
assert res.node is None
np.testing.assert_allclose(res.q_star, np.asarray(nodes.q[tip0], dtype=np.float64), atol=1e-6)
assert res.depth == pytest.approx(float(nodes.phase[tip0]), abs=1e-6)
# ======================================================================================================
# ee_seg — the same posterior, the same support, the same weights as q_seg
# ======================================================================================================
def test_ee_seg_carries_the_same_posterior_weights_as_q_seg():
"""The ee track is blended by the identical p-weighted tensordot q_seg gets, over the identical
truncated support: with ee built as q's first 3 columns twice, the two must agree exactly (not
approximately) on mass spread across two demos."""
nodes, ctx, tip0, tip1 = _fork_context()
p = np.full(len(nodes), 1e-9)
p[tip0], p[tip1] = 0.5, 0.5
p = p / p.sum()
res = make_readout("euc_raw")(p, ctx)
np.testing.assert_array_equal(res.ee_seg[:, :3], res.q_seg[:, :3])
np.testing.assert_array_equal(res.ee_seg[:, 3:], res.q_seg[:, :3])
# ======================================================================================================
# weighted_topk truncation honoured
# ======================================================================================================
def test_weighted_topk_truncation_honoured_euc_raw():
nodes, ctx, tip0, tip1 = _fork_context()
n = len(nodes)
p = np.full(n, 1e-9)
p[tip0] = 0.5
p[tip1] = 0.5
p = p / p.sum()
res = make_readout("euc_raw")(p, ctx)
assert res.conf >= 0.99
assert TOPK_K_MIN <= res.info["K"] <= min(ctx.topm, TOPK_K_MAX)
# ======================================================================================================
# determinism
# ======================================================================================================
def test_determinism_both_arms():
nodes, ctx, tip0, tip1 = _fork_context()
ctx.q_now = np.asarray(nodes.q[0], dtype=np.float64)
ctx.basin = BasinGeometry(r=np.full((nodes.n_tasks, schema.NBINS_ALIGN), 0.5, dtype=np.float64), h=np.full((nodes.n_tasks, schema.NBINS_ALIGN), 0.1, dtype=np.float64))
ctx.task_id = 0
n = len(nodes)
p = np.full(n, 1e-9)
p[tip0] = 0.5
p[tip1] = 0.5
p = p / p.sum()
for name in LIVE_ARMS:
arm = make_readout(name)
r1 = arm(p, ctx)
r2 = arm(p, ctx)
assert r1.node == r2.node
assert r1.depth == r2.depth
assert r1.conf == r2.conf
assert r1.off_manifold == r2.off_manifold
np.testing.assert_array_equal(r1.q_star, r2.q_star)
np.testing.assert_array_equal(r1.q_seg, r2.q_seg)
np.testing.assert_array_equal(r1.qdot_seg, r2.qdot_seg)
# ======================================================================================================
# basin -- minimal projection into the corpus-derived good-enough region (see BasinReadout's docstring)
# ======================================================================================================
def test_basin_registered_and_resolves():
assert "basin" in LIVE_ARMS
assert make_readout("basin") is ARMS["basin"]
def test_basin_requires_q_now():
nodes, ctx = _flat_context()
p = _concentrated_p(0, len(nodes))
ctx.basin = BasinGeometry(r=np.full((nodes.n_tasks, schema.NBINS_ALIGN), 0.5, dtype=np.float64), h=np.full((nodes.n_tasks, schema.NBINS_ALIGN), 0.1, dtype=np.float64))
ctx.task_id = 0
with pytest.raises(ValueError, match="ctx.q_now"):
make_readout("basin")(p, ctx)
def test_basin_requires_corpus_geometry():
"""Missing basin_r/basin_h/task_id must raise a message pointing at the missing corpus geometry --
this arm has no measurement of its own to fall back to (BasinReadout's docstring)."""
nodes, ctx = _flat_context()
p = _concentrated_p(0, len(nodes))
ctx.q_now = np.zeros(D, dtype=np.float64)
with pytest.raises(ValueError, match="ctx.basin"):
make_readout("basin")(p, ctx)
def test_basin_exact_no_op_when_inside():
"""||Delta|| <= r_eff: q_star must equal q_now EXACTLY (assert_array_equal, not approx) and
info['no_op'] must be True -- the arm takes the q_now.copy() branch directly rather than relying on
the projection formula to happen to land on q_now (BasinReadout's docstring, "THE MECHANISM")."""
q0 = np.zeros(D, dtype=np.float64)
q1 = np.full(D, 1.0, dtype=np.float64)
nodes, ctx, idx0, idx1 = _two_node_context(q0, q1)
p = np.zeros(len(nodes), dtype=np.float64)
p[idx0], p[idx1] = 0.5, 0.5
ctx.q_now = q0.copy() # mu is ~0.5*ones(D); a huge r_eff certifies "inside" regardless
ctx.basin = BasinGeometry(r=np.full((nodes.n_tasks, schema.NBINS_ALIGN), 10.0, dtype=np.float64), h=np.full((nodes.n_tasks, schema.NBINS_ALIGN), 1.0, dtype=np.float64))
ctx.task_id = 0
res = make_readout("basin")(p, ctx)
assert res.node is None
assert res.info["no_op"] is True
assert res.info["moved_norm"] == 0.0
np.testing.assert_array_equal(res.q_star, ctx.q_now)
def test_basin_outside_lands_on_segment_at_r_eff_from_anchor():
"""||Delta|| > r_eff: q_star must be the point on segment q_now -> q_b sitting EXACTLY r_eff short
of q_b -- a closed-form check, not merely "moved some amount" (BasinReadout's docstring, "THE
MECHANISM"). q_b is read off euc_raw's q_star on the SAME (p, ctx.topm), since both arms compute the
identical p-weighted anchor."""
q0 = np.zeros(D, dtype=np.float64)
q1 = np.full(D, 10.0, dtype=np.float64)
nodes, ctx, idx0, idx1 = _two_node_context(q0, q1)
p = np.zeros(len(nodes), dtype=np.float64)
p[idx0], p[idx1] = 0.5, 0.5
q_b = make_readout("euc_raw")(p, ctx).q_star
ctx.q_now = q0.copy()
ctx.basin = BasinGeometry(r=np.full((nodes.n_tasks, schema.NBINS_ALIGN), 1.0, dtype=np.float64), h=np.full((nodes.n_tasks, schema.NBINS_ALIGN), 0.2, dtype=np.float64))
ctx.task_id = 0
res = make_readout("basin")(p, ctx)
r_eff = 1.0 - 0.2
assert res.node is None
assert res.info["no_op"] is False
assert res.info["r_eff"] == pytest.approx(r_eff)
dist_to_anchor = float(np.linalg.norm(res.q_star - q_b))
assert dist_to_anchor == pytest.approx(r_eff, abs=1e-9)
direction = q_b - ctx.q_now
diff = res.q_star - ctx.q_now
cos = float(np.dot(diff, direction) / (np.linalg.norm(diff) * np.linalg.norm(direction)))
assert cos == pytest.approx(1.0, abs=1e-9), "q_star must stay ON the segment q_now -> q_b"
def test_basin_segment_is_translated_not_shrunk():
"""q_seg* = q_seg_blend + (q_star - q_b), qdot unchanged -- the translation this arm performs
(BasinReadout's docstring, "THE SEGMENT IS TRANSLATED, NOT SHRUNK"). ee_seg is deliberately
exempt: the blend's alpha supplies the magnitude, so a basin projection on top double-corrects."""
q0 = np.zeros(D, dtype=np.float64)
q1 = np.full(D, 10.0, dtype=np.float64)
nodes, ctx, idx0, idx1 = _two_node_context(q0, q1)
p = np.zeros(len(nodes), dtype=np.float64)
p[idx0], p[idx1] = 0.5, 0.5
res_raw = make_readout("euc_raw")(p, ctx)
ctx.q_now = q0.copy()
ctx.basin = BasinGeometry(r=np.full((nodes.n_tasks, schema.NBINS_ALIGN), 1.0, dtype=np.float64), h=np.full((nodes.n_tasks, schema.NBINS_ALIGN), 0.2, dtype=np.float64))
ctx.task_id = 0
res = make_readout("basin")(p, ctx)
expected_q_seg = res_raw.q_seg + (res.q_star - res_raw.q_star)[None, :]
np.testing.assert_allclose(res.q_seg, expected_q_seg, atol=1e-9)
np.testing.assert_allclose(res.qdot_seg, res_raw.qdot_seg, atol=1e-9)
assert not np.allclose(res.q_star, res_raw.q_star) # sanity: a translation really happened
np.testing.assert_array_equal(res.ee_seg, res_raw.ee_seg)
# ======================================================================================================
# the differentiable torch expectation — must optimise the SAME quantity the numpy arms deploy
# ======================================================================================================
def test_ee_segment_table_rows_match_node_table_including_demo_boundaries():
"""Every row of the materialised table must be NodeTable.ee_segment exactly -- same clip at
raw_ptr[owner+1], same edge padding. The final nodes of each demo are the ones that clip, so the
test asserts at least one row really was padded rather than passing vacuously."""
nodes, ctx = _flat_context()
table = ee_segment_table(nodes, ctx.seg_k)
assert table.shape == (len(nodes), ctx.seg_k, 6)
for v in range(len(nodes)):
np.testing.assert_array_equal(
table[v].double().numpy(), nodes.ee_segment(v, ctx.seg_k)
)
padded = [v for v in range(len(nodes))
if (nodes.raw_ptr[nodes.owner[v] + 1] - nodes.t_raw[v]) < ctx.seg_k]
assert padded, "no node's segment was clipped: the boundary case is untested"
def test_expected_ee_segment_matches_the_numpy_aggregate():
"""The torch expectation and the deployed numpy _aggregate must be the SAME quantity, or Stage 2
optimises something the rollout never computes. p is built so weighted_topk's 0.99-mass support
is EXACTLY K nodes (asserted via info['K']), which is the only condition under which the two
differ -- the torch arm uses a fixed k by design (expected_ee_segment's Note)."""
k = 10
nodes, ctx = _flat_context()
# Distinct masses, so no sort/topk tie-break can pick a different support; the top k sum to
# 0.9995 while the top k-1 sum to 0.9000, bracketing weighted_topk's 0.99 target at exactly k.
p = np.full(len(nodes), 1e-9)
p[:k] = 0.0995 + 1e-4 * np.arange(k)
p = p / p.sum()
res = make_readout("euc_raw")(p, ctx)
assert res.info["K"] == k, "supports differ; the comparison below would be meaningless"
got = expected_ee_segment(torch.as_tensor(p), ee_segment_table(nodes, ctx.seg_k), k=k)
np.testing.assert_allclose(got.numpy(), res.ee_seg, rtol=1e-9, atol=1e-12)
def test_expected_ee_segment_gradient_reaches_p():
"""The whole point of the torch arm: an action-space loss must be able to move p. The topk
INDICES are a hard selection, so nodes outside the support correctly get exactly zero grad."""
v, seg_k, k = 6, 3, 3
table = torch.arange(v * seg_k * 6, dtype=torch.float64).reshape(v, seg_k, 6)
p = torch.tensor([0.30, 0.25, 0.20, 0.13, 0.08, 0.04], dtype=torch.float64, requires_grad=True)
assert torch.autograd.gradcheck(lambda x: expected_ee_segment(x, table, k=k), (p,))
expected_ee_segment(p, table, k=k).sum().backward()
assert p.grad[:k].abs().sum() > 0
assert torch.count_nonzero(p.grad[k:]) == 0

Xet Storage Details

Size:
20.4 kB
·
Xet hash:
6fadf1c752b3f43cdc4b187510bf0944a95d954cb98c3b6c287f4c90aff15dc1

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