twanghcmut's picture
|
download
raw
10.5 kB
# Method
One method, two regimes. A **demonstration graph** built from the training demos is the only
model of "where the arm should be"; a small trained network turns a window of joint states into a
distribution over its nodes. At **t=0** that distribution is used once, to rewind an out-of-
distribution start pose. At **t>0** it is filtered over time and used to decide when the rollout has
left the demonstrated manifold and where to put it back.
Nothing here is policy-specific: the graph and its head are trained once per suite from demo data
alone, and the same artifacts drive both a frozen StableVLA and a frozen GR00T-N1.7 with no
retraining of either policy.
---
## 1. The demonstration graph
**Nodes.** Raw demo frames are coarsened `COARSEN = 5` frames to a node, with a forced split at every
gripper-state change (`onf/graph/nodes.py`). Each node carries its joint configuration `q`, finite-
differenced velocity, gripper flag, and three labels used everywhere downstream:
| label | meaning |
|---|---|
| `task_id` | which base task the demo belongs to — the **task lane** |
| `owner` | which demo (strand) it came from |
| `phase` | position along the episode, `arange(T)/(T-1)`, so `0.0` = first frame, `1.0` = last |
The `long` suite's graph has **28,476 nodes** over 500 demos / 10 tasks.
**Edges** (`onf/graph/edges.py`, `schema.RELATIONS`): temporal successors at dilations
`next1, next2, next4, next8, next16` (plus their reverses), `sibling` (kNN across demos of the same
task, degree `K_SIBLING = 8`), and `align` (kNN within a phase bin, degree `K_ALIGN = 4`).
**Head** (`onf/graph/gnn.py`, trained by `onf/graph/train.py``g_head.npz`). Input is a window of
`HIST_H = 8` consecutive joint states. A query encoder seeds node states with a joint-space kernel
(bandwidth `KERNEL_BW = 0.15`, sparsified to `SEED_TOPK = 256` nodes), `LAYERS = 3` rounds of
relation-typed message passing follow, and a logit head scores every node. Nodes never reached by
message passing get `-inf`, which is the head's *reachability* mask.
The objective is **phase-primary**: cross-entropy on the phase-bin marginal `p(T|Q)`
(`PHASE_CE_W = 1.0`) plus a phase-expectation regression (`PHASE_EXPECT_W = 0.5`), with node identity
only auxiliary (`NODE_W = 0.1`). This ordering is deliberate — which *demo* a window came from is
close to unlearnable and barely matters (same-task, same-phase demos sit ~0.05–0.6 rad apart), while
*how far along* is both learnable and decisive (a phase error of 0.5 costs ~0.5 rad of target error).
Training queries also include an `ENTRY_STATIC` class: windows of the form `repeat(q0, 8)`, i.e.
identically zero velocity. Ordinary training windows are consecutive demo frames and always carry
some velocity, so without this class the t=0 query shape never appears in training at all.
---
## 2. t = 0 — `entry`
**WHEN.** Purely geometric, no learning: at episode start compute `qerr0 = ||q_0 − q*||`, and act only
if `qerr0 > JQ_GATE = 0.08` rad. An unperturbed start is left completely untouched.
**WHERE.** The head's distribution is masked down to the **entry stratum**
```
task lane ∩ phase ≤ JQ_GRAPH_ENTRY_BAND (0.05) ∩ reachable (logit > −inf)
```
and the survivors are averaged **uniformly** in joint space (`EucRawReadout`):
`q* = Σ_v w_v · q_v` with `w_v = 1/n`. On the `long` graph this is ~229 of 28,476 nodes.
So at t=0 the trained head contributes exactly one thing — reachability — and the mask, not the
weighting, determines the answer. This was measured directly: reading the head's posterior over the
same masked pool instead of averaging uniformly moves `q*` by 0.124 rad (larger than the gate itself)
and yet does not improve success rate. The fixed rule is kept because it is simpler and does not
depend on which head is loaded.
**How it moves.** A joint-space PD servo (`onf/recovery/controller.py`, `JQ_MODE=pd`, `JQ_KP = 80`)
drives the arm to `q*` with tolerance `JQ_EPS = 0.03` rad and a hard budget of `JQ_MAX = 60` control
steps, then hands control to the frozen policy. The target is a **segment** (`SEG_K = 8` frames), not
a point, so the policy receives a state that is in-distribution in both `q` and `qdot`.
**`JQ_POSTURE=target` is load-bearing.** The operational-space controller keeps a null-space posture
reference. If it is left at its default the servo lands correctly and then the null-space torque drags
the arm back toward the perturbed pose within ~2 s of handoff, undoing the recovery. Pinning the
reference to the pose the servo reached is what makes the whole regime work.
---
## 3. t > 0 — `sentinel`
A per-worker `GraphTracker` (`onf/graph/track.py`) runs a belief filter over the same graph, one
check every `SN_GRAPH_STRIDE = 1` window (matching the 8-raw-frame training window):
```
b_t ∝ [ (1 − ε) · P b_{t−1} + ε · L_t ] · L_t
```
- `L_t = softmax(logits)` — the **same trained head** as t=0, no separate model.
- `P` is built from the graph's own relations, restricted to
`TRACK_KERNEL_RELATIONS = (next1, next2, next4, next8, next16, sibling)`.
Advances are mixed over `TRACK_ADVANCE_SET = (0,1,2,4,6,8,12,16)` raw frames because a check
advances ~1.65 nodes, which integer node hops cannot express, and rollouts run slower than demos.
`align` and backward relations are deliberately excluded: the arm does not change task mid-episode,
and a backward jump must pay transition cost.
- The restart term `ε · L_t` restarts toward the **current observation**, not toward uniform — a
uniform restart puts mass on impossible phases and discards the aliasing suppression being bought.
Why filter at all: the retriever is memoryless, and `long` tasks are two-object ("put both X and Y"),
so reaching for object 2 looks kinematically like reaching for object 1. Over 1,372 measured
consecutive checks only 67% of the memoryless phase transitions are physically plausible
(`|Δphase| ≤ 0.05`); 8.8% teleport by >0.3 and 8.0% jump *backwards* by >0.1.
**WHEN** (`FilterRule`, `onf/sentinel/when.py`). Two e-processes accumulated by the tracker — a
**basin** test (is the arm inside the certified radius of the demo manifold?) and a **progress** test
(is phase still advancing?) — each fire when `log E ≥ −log(α_half)` (Ville's inequality). The rule
fires on their union, subject to three gates:
- `α_half = α / 2 / max_interventions` (α = `TRACK_ALPHA_EVIDENCE` = 0.05). Resetting `E_t` after a
fire refunds spent α, so the per-episode false-alarm guarantee only holds if the budget is split
across the allowed fires. Without this split the certified claim is simply false.
- The progress test is suppressed once `phase_hat ≥ 1 − 1/NBINS_ALIGN`: a task that legitimately
finished has stopped advancing, and that is not a stall.
- No fire while the gripper is closed — interrupting mid-grasp drops the object. The evidence
bookkeeping keeps running; only the fire is suppressed.
**WHERE** (`BasinReadout`). The belief `b_t` is projected onto the basin geometry fitted per
`(task, phase-bin)` from held-out clean demo replays: certified radius `r` = p95 and KDE bandwidth
`h` = p50 of leave-one-demo-out nearest-neighbour distances. The same PD servo then runs to that
target.
**Artifacts** are fit offline by `scripts/build_sentinel_artifacts.py` into `g_track.npz`
(`π`, `β`, `leak`, the three null banks, `basin_r`, `basin_h`). The file carries a `graph_hash` stamp
and is **refused on mismatch**: the advance operator is node-id positional, so a foreign file would
advance the belief along the wrong strands with no shape error and no exception.
---
## 4. Constants: chosen vs derived
| constant | value | how |
|---|---|---|
| `JQ_GATE` | 0.08 rad | CHOSEN |
| `JQ_GRAPH_ENTRY_BAND` | 0.05 phase | CHOSEN (one `NBINS_ALIGN` bin) |
| `JQ_KP` / `JQ_EPS` / `JQ_MAX` | 80 / 0.03 / 60 | CHOSEN |
| `HIST_H`, `SEG_K` | 8, 8 | CHOSEN — one policy action chunk |
| `COARSEN` | 5 | CHOSEN; splits forced at gripper changes |
| `TRACK_ALPHA_EVIDENCE/PROGRESS` | 0.05 | CHOSEN (false-alarm budget) |
| `TRACK_LAM` | 0.8 | CHOSEN |
| `α_half` | `α/2/max_interventions` | DERIVED (union bound over fires) |
| `BASIN_RADIUS_QUANTILE` / `BANDWIDTH` | p95 / p50 | DERIVED from LODO demo replays |
| `π`, `β`, `leak` | per suite | DERIVED — fit by `build_sentinel_artifacts.py` |
| `TRACK_ADVANCE_SET` upper end | 16 raw frames | DERIVED (~2× nominal advance rate) |
---
## 5. Results
Every number below is on LIBERO-Plus. Nothing else in this repo carries measured numbers.
| cell | StableVLA | GR00T-N1.7 |
|---|---|---|
| **t=0 `entry`**, `long` × Robot_Initial_States (n=393) | 378/393 = **96.2%** | 353/393 = **89.8%** |
| **t>0 `sentinel`**, `long` × Objects_Layout (n=312) | not measured | 208/312 = **66.7%** |
Read with the following caveats, all of which matter:
- **The 66.7% cell has no same-session `base` baseline.** The nearest comparator (204/312) was run
four days earlier; the difference is +1.3 pp. GR00T-N1.7 is flow-matching and **stochastic**
~11.2% episode churn, ±2.4 pp on n≈393 — so +1.3 pp is inside noise, and cross-session GR00T
comparisons are not valid at all. Only same-session arms may be compared.
- StableVLA is deterministic (0 episode churn), so its arms can be compared paired and exactly, even
across sessions.
- **On GR00T at Robot-Init, a fixed-home baseline (94.7%) still beats this method (89.8%).** The
graph-based entry target wins on StableVLA but not on GR00T.
### Not measured
- `sentinel` has exactly **one** measured cell (GR00T, `long` × Objects_Layout) and **no** full-suite
number on StableVLA. `configs/sr_ladder.yaml` nevertheless makes it the default for all six t>0
axes — that is an extrapolation beyond what has been measured, stated here rather than hidden.
- Only the `long` suite currently has a trained `g_head.npz` and a fitted `g_track.npz`. `object`,
`spatial` and `goal` have `g_nodes.npz`/`g_edges.npz` only, so the 4-suite benchmark is complete in
code but cannot run on three of four suites until those artifacts are trained
(see `docs/usage.md`).
- `ENTRY_STATIC`'s effect on the t>0 regime has never been ablated. It is retained because the
shipped `g_head.npz` was trained with it.
- `src/onf/field/` (`ONFField`) is a **training-time dependency only** — it supplies the cleanliness
weighting in `onf/graph/train.py`. It is on no inference path in either regime.

Xet Storage Details

Size:
10.5 kB
·
Xet hash:
689c50d651eb808a7959cdd0597166cd0194cdb5c97b14c5c0548a3abe9d062c

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