Skip to content

Aniketh/arc - #573

Open
AnikethCheluva wants to merge 100 commits into
aniketh/abcfrom
aniketh/arc
Open

Aniketh/arc#573
AnikethCheluva wants to merge 100 commits into
aniketh/abcfrom
aniketh/arc

Conversation

@AnikethCheluva

Copy link
Copy Markdown
Collaborator

No description provided.

@AnikethCheluva
AnikethCheluva changed the base branch from main to bf/7-configs August 26, 2026 14:54
@AnikethCheluva
AnikethCheluva changed the base branch from bf/7-configs to graphite-base/573 September 3, 2026 18:31
@AnikethCheluva
AnikethCheluva changed the base branch from graphite-base/573 to aniketh/abc September 3, 2026 18:31

AnikethCheluva commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude Code Review

Review of PR #573 — "Aniketh/arc"

Summary

This PR is enormous, unfocused, and its title/description ("Aniketh/arc", no description) tell reviewers nothing. The diff mixes at least four unrelated concerns: a versioned Tsimulation package, a packed-episode dataloading pipeline, an H-Net algorithm stack with CUDA-kernel install scripts, and a "BATCHFLOW" refactor manifesto — plus doc rewrites and .gitignore churn.

Key concerns

  1. PR scope is unreviewable. This looks like several weeks of work from a branch dumped into one PR. Each of the following should be its own PR with its own tests and description:

    • Tsimulation/sim_v1+sim_v2 versioning (new top-level package with import-time env-var side effects)
    • Packed episode dataloader (ZarrEpisodePackedDataset, pack_collate)
    • H-Net algo, stages, CUDA kernel install/build scripts
    • BATCHFLOW.md — describes a repo (EgoVerse-batchflow) and branch that don't match this one; unclear what's actually being proposed here
    • .gitignore and doc path fixes
  2. Tsimulation/__init__.py uses TSIM_VERSION env var at import time to alias submodules. This is a footgun: two processes in the same shell with different env can silently import different simulators; any tool that imports Tsimulation.pushshapes before the env is set will bind to the default and stay bound. Prefer explicit from Tsimulation.sim_v2 import ... at call sites, and drop the sys.modules aliasing.

  3. New embodiment pushshapes_sim (enum id 15) and new embodiment string. I can't see the enum diff in the truncated portion — please confirm the enum entry, the SQL/DB enum, and any downstream EMBODIMENT validators are all in sync. Also confirm no existing embodiment holds id 15.

  4. Data integrity — ZarrDemoWriter.

    • Good: it wraps ZarrWriter.create_and_write (matches the "always use ZarrWriter" rule).
    • Episode hash convention says UTC timestamp YYYY-MM-DD-HH-MM-SS-ffffff. This writer uses episode_<obj>_<pusher>_obsN_<idx>.zarr. Is this an intentional deviation for sim data, or does it violate the convention? Please justify in the PR description.
    • No mention of Cloudflare R2 upload path for these episodes.
  5. ZarrDataset._read_span ignores keymap horizon. AGENTS.md notes this is intentional to keep padded/packed consistent, but this is a subtle semantic divergence between two read paths on the same class — worth a docstring warning on __getitem__ and _read_span themselves, not just AGENTS.md.

  6. Multi-frame JPEG decode bug fix is real and load-bearing (__getitem__ used to crash on horizon>1 image keys). This deserves its own small PR with a regression test — right now it's buried inside a 5000-line change.

  7. Training regression risk from MultiDataset changes. Changes to _iter_leaves, populate_from_datasets, infer_norm_from_dataset, and set_norm_stats_from affect every existing training config, not just packed. Are norm stats for existing runs bit-identical? Any change to sample_frac semantics (now frame-budget in packed mode) needs a comparison against a known-good norm_stats file.

  8. HNet algo removes legacy data_schematic ctor arg. AGENTS.md says this "referenced a class that didn't exist in this repo" — confirm no Hydra config in the repo still passes it, or Hydra will fail with a cryptic error on unrelated runs.

  9. CUDA kernel install scripts (scripts/install_cuda_kernels.sh, build_cuda_exts.sh, recover_torch_cu124.sh) modify the venv globally. These should not be run as part of any automated flow. Add a big warning in the script header, and confirm they're gated behind explicit user invocation only.

  10. BATCHFLOW.md contradicts the code being added. It describes a pipeline.algo.PipelineAlgo and stages_* module layout that doesn't appear in the diff. It references a different repo (EgoVerse-batchflow) and says "backward ckpt compatibility explicitly NOT required." If this is aspirational/design doc, mark it as such. If it's the target for this PR, the code doesn't match.

  11. .gitignore additions include out/, osmo/val_videos/. Fine, but out/ is generic enough to hide accidentally-untracked results from other experiments. Consider a more specific pattern.

  12. No new tests visible in the diff for the algo path. AGENTS.md lists a tests/ table (test_hnet_nets.py, test_packed_pipeline.py, test_training_recipe.py) but I can't confirm they're in the (truncated) diff. If they exist, good; if AGENTS.md is aspirational, this is a blocker.

Suggestions

  • Split this PR. Minimum viable split:
    1. .gitignore + doc path fixes (trivial, land first).
    2. ZarrDataset.__getitem__ multi-frame JPEG bug fix + regression test.
    3. Tsimulation versioning (with a proper design discussion of the env-var mechanism).
    4. Packed dataloader (_read_span, ZarrEpisodePackedDataset, pack_collate, MultiDataset changes, tests).
    5. H-Net algo + configs + smoke scripts.
    6. CUDA kernel install docs/scripts.
    7. BATCHFLOW.md (or drop it

Reviewed by Claude · Review workflow

AnikethCheluva added a commit to GaTech-RL2/EgoVerse-graph that referenced this pull request Sep 8, 2026
Port of GaTech-RL2#573's arc-tokenizer work onto the graph fork, reduced to the part that
belongs in this architecture: the tokenizer as a pair of pipeline nodes.

The Planar arc tokenizer already existed as a loader-side transform, and that
placement is invisible to the graph -- a stage list shows the model consuming
`target` with no sign the target is an arc token rather than a time-indexed
chunk, and tools/config_graph.py cannot lint the boundary because nothing
declares it. These two stages move the boundary into the graph:

`ArcTokenizeStage` takes ActionTargetBuilder's PLACE rather than following it.
Two writers of `target` would be a duplicate-writer lint error and would leave
the graph ambiguous about which one the denoiser models; as the sole writer, it
leaves the rest of a DP or flow chain untouched. It tokenizes per sample through
the same TokenizePlanarArcLength the loader transform uses, so both paths
produce identical targets -- there is a test pinning that.

`ArcDetokenizeStage` walks the waypoint polyline at `speed * dt * k`, the
inverse of how the tokenizer lays waypoints out uniformly in arc length. It
saturates at the window end rather than extrapolating past it, holds position
for a zero-speed token (matching the tokenizer's degenerate branch), and
interpolates heading as (cos, sin) so a chunk that wraps past +/-pi does not
unwind through zero.

Adds `inference_only` to core as the mirror of `train_only`. It is needed, not
cosmetic: this runner treats a stage with an unsatisfied read as a
configuration error and raises, so a stage that legitimately exists in only one
mode has to say so. Without it the detokenizer -- whose `pred_action` input
exists only at inference -- would abort every training run. config_graph.py
reports the new restriction alongside the existing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcPCNAW3GKERANrqvfSCY1
ElmoPA and others added 13 commits September 8, 2026 14:34
The reconciliation layer. Every file where main and the batchflow lineage
genuinely disagreed lands here, so everything above it is bulk-new code.

embodiment.py takes main's collapsed enum (HUMAN_* 1-3, EVA_* 4-6) and re-adds
PUSHSHAPES_SIM 15 / _STICK 16 / _SMALL_CIRCLE 17 -- pinned because trained
checkpoints and collected datasets encode those IDs.

zarr_dataset_multi.py is a 3-way merge against the fork point: main's
SafeS3EpisodeResolver, EvenStrideDataset, _evenly_spaced_indices and intrinsics
property, plus batchflow's _read_span, _annotations_for_span and
LocalEpisodeResolverWithEmbodimentOverride. action_chunk_transforms.py keeps
batchflow's DeltaAction alongside main's PadGripperZeros.

The batchflow repo notes land in AGENTS.md rather than a second CLAUDE.md, so
the repo keeps one conventions file. DESIGN.md is not carried over: it was a
2026-06-06 restructure proposal written against EgoVerse-pact-2, still marked
"awaiting approval", describing a move that has since happened here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The JPEG-decodability probe at _probe_image_key calls
simplejpeg.decode_jpeg(...) but the module was never imported in this file.

The call sits inside a try/except Exception, so instead of crashing it made
the probe report EVERY image as undecodable -- a silent false negative rather
than an error. _common.py in the same package already imports it the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AGENTS.md still documented egomimic/models/hnet_nets/, egomimic/algo/hnet.py and
egomimic/eval/eval_hnet.py. None of those paths exist: the packages are
models/hnet/, algo/hnet/ and eval/core/eval_hnet.py. Anyone -- human or agent --
following the doc went looking for files that are not there, and AGENTS.md is the
first thing an agent reads.

Section headings renamed hnet_nets -> hnet to match. test_hnet_nets.py is left
alone: that file genuinely still has that name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oader

This PR introduces the packed subsystem -- ZarrEpisodePackedDataset and
pack_collate -- but MultiDataModuleWrapper, which is inherited unchanged from
main, hardcoded annotation_collate for every dataset. annotation_collate ends in
default_collate, which tries to torch.stack ragged packed samples, so every
packed_episode config died on its first batch with

    RuntimeError: Trying to resize storage that is not resizable

pack_collate needs two call sites. The other one -- MultiDataset's norm-stat
inference in zarr_dataset_multi.py -- already had it (collate_fn = pack_collate
if is_packed else None) and is covered by test_packed_pipeline. The training
dataloader had neither the wiring nor a test, and main has no packed configs at
all, so nothing exercised it.

_collate_fn_for is ported from EgoVerse-gmm-dualstream / EgoVerse2, where this
dispatch already backs the live H-Net runs, rather than written fresh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two standalone simulator packages, each with its own pushshapes/, collect/,
examples/ and tests/. There is no v3: what was labelled v3 is the socket fix
that ships AS v2 -- the intermediate all-faces-grip build was a bug.

__init__.py aliases the active version's submodules to the top level so existing
'from Tsimulation.pushshapes import X' call sites keep working; TSIM_VERSION
selects the version per process.

Placed before eval because eval calls into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hing on them

Adding the u_socket was done by editing the environment. Its latch, friction and
penetration guards -- 12 methods, ~470 lines -- went into env.py as
`if pusher_shape == "u_socket"` branches, and its 3-DOF action became a hardcoded
`expected_shape = (3,) if self.pusher_shape == "u_socket" else (2,)`. That is the
single largest reason the two sims diverged: sim_v1's env.py has ZERO socket
references, sim_v2's had 115, and env.py's step loop called nine socket-specific
guards in sequence.

An Agent now owns the three things the environment should not know about:

  * ACTION SPACE  -- action_dim (2 for a free-moving pusher, 3 when the agent
    also controls orientation) and target_pose() to decode a raw action;
  * BODY          -- build() in the pymunk space;
  * CONTACT MODEL -- pre_substep()/post_substep() hooks around each physics
    substep, plus on_reset() for per-episode state.

env.step is agent-agnostic:

    captured = self.agent.pre_substep(self)
    self._drive_pusher_toward(tx, ty, dt_sub, target_angle)
    self._space.step(dt_sub)
    self._clamp_pusher_to_static()
    self.agent.post_substep(self, captured)

Agent (circle, circle_small, stick, L) implements the hooks as no-ops.
USocketAgent owns all the latch/guard logic and the socket geometry constants,
and its solid_pusher / socket_inside_friction_only flags become constructor
arguments rather than environment state. A new agent with an unusual action
space is a new class plus one line in make_agent(), not another branch in the
simulator.

env.py 1292 -> 795 lines.

sim_v1 IS DELIBERATELY UNTOUCHED. It is frozen so pre-rewrite data replays
exactly; refactoring it would put that at risk for no benefit, since it has no
socket to abstract in the first place.

VERIFIED BY REPLAY EQUIVALENCE, not by inspection. Baselined the unmodified sim
with the identical harness first, then compared:

    u_socket_3000_v2         100.0% -> 100.0%   (p50 0.0033 -> 0.0033)
    circle_3000_plus_gen_v2   89.7% ->  89.7%
    circle_v2_obstonly        17.1% ->  25.7%

The gate caught two real bugs that inspection did not: the moved
_socket_contact_is_on_inner_face call site lost its env argument, and
socket_latched -- a property DERIVED from `_socket_constraints is not None` --
had become a plain attribute nothing updated, initialised to [] so it would have
read as permanently latched. Both fixed; the socket went 0% -> 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012V58H37tmcvgDthELMd5Xk
New subsystems with no counterpart on main: models/hnet (stages, blocks, the
scan and register chunk interfaces, routing) and models/diffusion (DiT3D and
spatial backbones, sampling, image VAE), plus cores and stems.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
widths=[512] in hpt_heads/hpt_stems and down_dims=[256,512,1024] in
denoising_nets are evaluated once at import, so every caller that omits the
argument shares one list object. None of the three currently mutates it, so
nothing is broken today -- this removes the footgun before something does.

Each becomes None with the original value restored inside the function, so the
behaviour for an omitted argument is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
obs_encoder.py and cond_encoders.py each carried a private _mlp() of the same
shape -- but ObsEncoder's used ReLU and CondEncoderModule's used GELU. Both are
live: ObsEncoder backs nine bc_rnn model configs (base/tx/tx_chunk8/hnet/
tx_cotrain_*), CondEncoderModule backs the H-Net firstend/cotrain/bf_rh configs.

They collapse into one build_mlp() whose 'act' argument is keyword-only with no
default, so every call site states its activation. That is the point of the
change: activations hold no parameters, so a checkpoint trained under one
activation loads into the other with no error and merely produces different
numbers -- exactly the kind of silent divergence two near-identical private
helpers invite.

Behaviour is unchanged: ReLU stays at both ObsEncoder call sites and GELU at both
CondEncoderModule ones. Verified by building both encoders from the old and new
trees -- identical activation lists, identical parameter keys, identical forward
output hashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…thms

algo/hnet, algo/diffusion (with its outer-stage variants) and algo/bc. Builds on
the model zoo in the previous commit.

Also carries egomimic/pipeline/ -- the batchflow stage framework, its runner
PipelineAlgo and the stage implementations -- plus the three H-Net lightning
callbacks (random_attn_dropout, chunker_residual_scheduler, ratio_loss_scheduler)
and BATCHFLOW.md.

These sit here rather than in the infra commit because they import
egomimic.models.hnet and egomimic.models.diffusion from the model zoo below,
and pipeline/algo.py additionally imports egomimic.algo.hnet.episode_transforms
from this commit. Carrying them lower left the infra commit unable to import
six of its own modules when checked out on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HNetPolicy.step referenced embodiment_id at two places in its body but never
declared it -- not a parameter, local, or attribute. The method failed two ways:

  * PackedAlgoBase.inference_step already calls
    policy.step(..., embodiment_id=self.domain_by_id.get(emb_id)), which raised
    TypeError: unexpected keyword argument;
  * called without it, the body raised NameError at the action_out lookup.

Either way the AR single-step path used for closed-loop sim rollout could not
run. The sibling policy step() in this same file already declares
embodiment_id: Optional[str] = None; this matches it, so the existing callers
work unchanged and single-embodiment models keep the None default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rpunamiya and others added 26 commits September 8, 2026 14:34
hpt_bc_keypoints_base sets lr=5e-5 with CosineAnnealingLR(T_max=1400).
scheduler_interval defaults to "step", so T_max is 1400 STEPS ~ 14 epochs
at the observed 100.8 steps/epoch -- and CosineAnnealingLR keeps evaluating
cos(pi*T_cur/T_max) past T_max, so the LR climbs back up rather than
stopping. Over 600 epochs that is ~22 sawtooth cycles between 1e-5 and
5e-5. Predicted 1.44e-05 at step 12299 vs 1.8e-05 observed on wandb.

Every arc-cartesian run used constant lr 3e-4 with scheduler: null, so the
keypoint runs as launched were neither constant nor at the same LR, and
not comparable. Add const-LR variants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
viz/keypoints.yaml and viz/keypoints_wrist.yaml both use a bare
front_img_1 image_key, but the batch carries observations.images.front_img_1
(confirmed by probe) -- so neither can run. Add viz/keypoints_human.yaml
with the correct key, no eva entry (these runs are single-domain), and no
annotation_key, since arc_tests has no annotations.

The eval sweep hardcoded annotation_key=null for four cartesian viz
entries; on a keypoint evaluator those keys do not exist and hydra errors.
Drive them from an ANNOT_EMBODIMENTS list instead, same fix as the
dataloader overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same bug as the training workflow, fixed there but not here: the sweep set
data.valid_dataloader_params.eva_bimanual.*, so any human-only run died
with 'Key eva_bimanual is not in struct'. Drive from EMBODIMENTS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third instance of the same assumption in this file. Drive it from
EMBODIMENTS like the others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Walks a real frame pair through the computation: two hand poses with
per-joint displacement, the 21 distances as bars with L-inf/L2/L1-mean
marked, the reduction to a scalar, the rotation term, and accumulation
into tokens at D.

Uses frames 2731->2737 where the index fingertip moves 37.9mm while the
slowest joint moves 6.9mm, so the choice of norm is visible: L1-sum is
8.3x L-inf on that single step, which is what compounds into the ~22x
path inflation over an episode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r_training

zarr_key_to_keyname returns None for any batch key that isn't a registered action/proprio zarr key (intrinsics, episode_hash, image keys). The old 'if key is not None' guard checked the wrong variable — 'key' is a string from _batch.items() and never None, so the branch always fired and wrote every unregistered key under a single None dict slot. Later writes clobbered earlier ones, and 'intrinsics' was silently dropped.

Downstream _intrinsics_from_batch(batch, i) then returned None, so Human.viz / Eva.viz fell back to the hardcoded class INTRINSICS constant. For episodes whose per-episode K disagrees with the aria default (mecka fx=fy≈251, cy≈184 vs ARIA cy=240), this projected the GT trajectory ~55px vertically offset — the visible wrist-vs-palm misalignment reported against arc_tests mecka fold_clothes val-videos.

Fix: fall back to the original key when zarr_key_to_keyname is None so unregistered keys survive the rekey. Also fixes downstream access to episode_hash and per-episode image side-channel keys.
Layers on top of arc-length-nv-eval only what wasn't already there:

data configs (D=0.20m / M=15 hardcoded to match target convention):
- aria_train_mecka_val{,_arctok}: cross-domain (train aria fold, valid
  mecka fold_clothes)
- eva_only_fold{,_arctok}: eva-only robot-baseline runs
- mecka_folding_clothes{,_arctok}: mecka folding_clothes (note "ing"
  variant of task name — distinct from arc_tests fold_clothes)
- mecka_folding_eva_fold_cotrain{,_arctok}: cotrain across mismatched
  task names (mecka folding_clothes + eva fold_clothes)

hydra launchers (mirror target's submitit_pace_l40s.yaml convention):
- submitit_pace_a100 / _blackwell / _h100

viz + diagnostic scripts:
- egomimic/visualization/arc_tok_viz.py: detokenize+overlay helper for
  notebooks (mirrors ArcTokEvalVideo viz path)
- scripts/pixel_check.py + .sbatch: verify projected GT dots match
  val-video mp4 frame 0
- scripts/visualize_trunk_latents.py + .sbatch: t-SNE + HDBSCAN over
  HPTModel.forward_features, emits arc_embedding_sweep's tabbed HTML

small UI fix in scripts/arc_embedding_sweep.py: image-panel close
button now has type=button + inline onclick fallback.

Dropped from the pre-rebase stash because target already had them
(and more evolved): eval_arctok/eval_hpt/eval_video changes,
arc_tests_cotrain* config edits, D40_M100 model/evaluator configs.
Dropped as no-longer-wanted per user note: FMPolicyWithVelDecoder /
WithVelReadout wrappers, associated hpt.py / denoising_policy.py /
hpt_nets.py / model config _veldec / _velreadout variants. Also
dropped an accidental LocalFolderEpisodeResolver -> S3EpisodeResolver
revert and a stray _LEGACY_EMBODIMENT_ALIASES removal.
…configs

Ports the D=40cm / M=100 arc-tok model configs and required class code
onto the bf/7-configs config layout so the in-flight training jobs can
launch from this branch:

- Restore hpt_cotrain_enc_dec_base.yaml with _target_ paths pointing at
  bf's module layout (algo.hpt.algo.HPT, models.stems.hpt_stems.*,
  models.heads.hpt_heads.MultiBlockTransformerDecoder,
  models.diffusion.denoising_nets.CrossTransformer).
- Point hpt_cotrain_mecka_flow_shared_head_arc.yaml back at
  hpt_cotrain_enc_dec_base and update its _target_ paths.
- New: hpt_cotrain_mecka_flow_shared_head_arc_D40_M100{,_veldec,_velreadout}.yaml.
- Add MLPVelocityDecoder to egomimic/models/heads/hpt_heads.py and
  FMPolicyWithVelDecoder / FMPolicyWithVelReadout to
  egomimic/models/heads/fm_policy.py.
- Fix eval_arctok.yaml viz path (cartesian → cartesian/base).
- Add ``arc_tokenizer`` config group defaults to train_zarr_cartesian.yaml
  so ``arc_tokenizer.min_distance_unit=…`` CLI overrides resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- pushshapes arc-length tokenizer (egomimic/rldb/zarr/pushshapes_arc_tokenizer.py)
  and pushshapes.get_keymap_hpt_arc for reading (M+1, 2) arc-tok windows.
- HPT closed-loop inference: expand_arc_chunk_to_time so the (M+1, D) chunk is
  played back as a time-uniform buffer against the env; replan_at tracks the
  variable expanded-buffer length.
- hydra config groups for the pusht arc-tok stack:
  data/pushshapes/pusht/{circle,circle_arc}.yaml, evaluator/hpt/{pusht,pusht_arc}.yaml,
  model/pusht/*, model/pusht_arc/*.
- Restored hpt_cotrain_mecka_flow_shared_head.yaml (non-arc baseline) with
  bf/7-configs _target_ paths so mecka baselines resume from aniketh/arc.
- logger/wandb/base.yaml default project: zarr_test -> arc so arc-tok runs land
  in rl2-group/arc automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adapts robot/rollout.py for arc-tokenized policies. Subclasses PolicyRollout
to reuse the loader / obs transform / safety pipeline; overrides only the
predict step to detokenize the model's (M+1, 8) arc-tok output into a
(H, 14) time-uniform chunk before handing it to the controller.
Rewrites TokenizeBimanualArcLengthCartesian to produce (M+1, 14) with full
xyz + ypr + grip per arm (was (M+1, 8) dropping rotation). Rotation is
unconditionally supervised — no opt-out. Gripper padding for human aria
data preserved as an existing transform option.

- arc_length_tokenizer.py: ARC_TOK_PER_ARM_DIM=7, ARC_TOK_BIMANUAL_DIM=14;
  SLERP for waypoint ypr resample; vel row extended to 14 dims with
  per-axis mean angular velocity in ypr slots.
- Model configs (arc, D20_M15, D40_M25, D40_M100, M15/M25/M50/M100 +
  veldec + velreadout): act_dim 8 -> 14, infer_ac_dims 8 -> 14, veldec
  output_dim 8 -> 14.
- FMPolicyWithVelDecoder / FMPolicyWithVelReadout: act_dim default 14.
- eval_arctok.py, rollout-arc.py, visualization/arc_tok_viz.py: shape
  asserts 8 -> 14; removed zero-fill of ypr; detokenize returns 14-dim
  including model-predicted rotation.
- Data configs (arc_sweep_*, arc_tests_cotrain_arctok*, folding_clothes
  _arctok, folding_eva_fold_cotrain_arctok, eva_only_fold_arctok,
  aria_train_mecka_val_arctok): comment updates.
- Embodiment keymap/transforms: docstring updates only; routing intact.
Ports the 9-run rotation-fixed setup into a first-class doc:
- What each of the 9 runs is (arc-tok, veldec, velreadout,
  eva_only_arctok, three non-arc baselines).
- Exact common overrides + arc-tok specifics + baseline specifics.
- Description strings for wandb id resolution.
- Launcher script paths and how to swap partitions.
- Why all 9 are fresh (8-dim ckpt vs 14-dim head shape mismatch).
- Known operational issues: billing quota kills, norm-stats cold start,
  partition availability.
- Pre-fire verification checklist.
The action-transform refactor on transform_fixes replaced the single
`mode:` key with action_mode / coord_frame / rotation_mode on Eva and
Human. Arc's data configs were written before that landed, so once arc is
stacked on abc they were passing an unknown `mode` kwarg.

121 transform_list blocks across 37 configs, mechanically:

  cartesian                -> cartesian / camframe / euler
  cartesian_padded         -> cartesian_gripper_padded / camframe / euler
  cartesian_wristframe_ypr -> cartesian / eef_frame / euler
  keypoints_*frame_ypr     -> keypoints / camframe|eef_frame / euler
  arc_tokenizer_cartesian  -> arc_tokenizer_cartesian / camframe / euler

pushshapes keeps its own module-level `mode` vocabulary (arc_tokenizer,
pad_only) — it was never part of the refactor and is left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnBSsxfUNUZQQ9wBwxbkaf
abc gave Yam the refactored action_mode / coord_frame / rotation_mode
signature but only the plain cartesian layout. arc's
abc_fstshirt_mecka_freefold_arc_cotrain_D40_M100 config feeds yam_bimanual
through the arc tokenizer, so it was passing min_distance_unit /
resampled_vector_length into a method that had no such parameters.

Routes through the same _append_arc_tokenizer helper Eva and Human use.
Yam is already 14D with a real gripper, so unlike Human it needs no
padding step before tokenizing.

Also ports scripts/pixel_check.py, the last caller still on the old
single-`mode` API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnBSsxfUNUZQQ9wBwxbkaf
Human's arc mode was silently doing two things at once: padding the zero
gripper AND tokenizing. That made `arc_tokenizer_cartesian` mean something
different on Human than on Eva/Yam, and it did not parallel the existing
cartesian / cartesian_gripper_padded pair.

Splits them:

  arc_tokenizer_cartesian                 tokenize the native layout
                                          (Eva, Yam - real gripper, 14D)
  arc_tokenizer_cartesian_gripper_padded  pad, then tokenize
                                          (Human - no gripper, 12D -> 14D)

Human's bare `arc_tokenizer_cartesian` now raises and points at the padded
variant instead of quietly padding. The 48 Human blocks across 24 data
configs plus scripts/pixel_check.py move to the explicit name, so no
pipeline changes shape - it is the same transform list, honestly named.
Eva's 44 blocks are untouched.

Also guards rotation_mode at build time. The tokenizer's chunk layout is a
hard-coded 14D [xyz(3), ypr(3), grip(1)] x 2 and it SLERPs the ypr slots,
so quat (16D) and 6D (20D) were only ever going to fail - previously on
the first batch, deep inside a run, now when the transform list is built.

Verified: (M+1, 14) out, arc length 0.1998m for D=0.20, gripper slots
zero, rotation supervised; all 262 transform_lists across 101 data configs
still instantiate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnBSsxfUNUZQQ9wBwxbkaf
One evaluator serves arc-tokenized and time-indexed runs. Arc detection is by
row count (M waypoints plus a velocity token vs rollout_horizon steps), so a
baseline chunk passes through instead of hitting the detokenizer. Every metric
is arc-matched: clip to the first D metres of travel, resample to N points
spaced uniformly in arc length, then score. Names carry no hint of the action
space, so an arc run and its baseline twin plot on one axis.

Scoring is EEF-frame and every metric is frame invariant. Position error
survives a rigid transform, and rotation is reported as a geodesic angle,
|log(R_pred R_gt^T)|, rather than a per-axis ypr difference: ypr is frame
dependent and breaks at wraparound and gimbal lock, so it cannot be compared
across runs. arc_matched_resample can now carry rotation through instead of
resampling it and discarding it.

Adds the D40/M100 experiment set: cotrain and ABC-only BC, arc and baseline,
plus a 300M variant, all on the same evaluator.
Replaces the fixed-D arc-matched metric with four families, all anchored on the
ground truth under normal action-chunk sampling instead of a reconstruction:

- arcmatch: re-tokenize pred and GT onto a matched per-arm span (the shorter of
  the two travelled distances) and score the waypoints. Exactly travel
  invariant, so it measures path shape alone. Reported with and without the
  velocity row; the gap between them is the timing error.
- dtw: warp the prediction against the GT chunk. Elastic in time, so unlike
  arcmatch it does see a travel mismatch.
- detok / baseline: plain MSE against the raw GT chunk, split into xyz, ypr,
  gripper, final and geodesic. One name per action space; same computation.
- pose_err_m: position and rotation as one number in metres, via a lever arm
  so radians are converted to the length they cost rather than summed with
  metres under an arbitrary unit choice.

Drops the _timeidx_ rename. It existed because the old detok metric compared
two reconstructions and could not be compared across action spaces; with a
common GT and a matched chunk_length that is no longer true.

The tokenizer overwrote actions_cartesian in place, so an arc run's batch never
carried the time-indexed GT these metrics need. Recovering it by detokenizing
would be circular (the reconstruction spans D by construction, so its distance
carries no information), hence preserve_action_key.

Verified offline: arcmatch reads 1e-9 to 3e-8 across predictions covering 0.5x
to 2x the GT distance, a perfect prediction scores exactly 0 on every family,
and pose_err_m matches its closed form. The measured caveats for ranking an arc
run against its baseline are in the module docstring.

Also: 40h multitask data mix with scale/ removed and selection by lab+task,
sample_frac 0.2, and the skynet l40s launcher an arc experiment already
referenced but which did not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuDAQKMEVv79cvzYiargiK
evaluator/hpt/sim_pusht_arc.yaml is tracked and targets
egomimic.eval.hpt.eval_sim_arc.ArcSimEval, but the module was never committed,
so the config resolves to nothing on a fresh checkout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuDAQKMEVv79cvzYiargiK
make_velocity_payload's MEAN_PER_DIM is (pos[-1] - pos[0]) / total_time, i.e.
net displacement over time, so the velocity token is a CHORD rate. detokenize
advanced `s` by that rate but compared it against cumulative ARC length, and
arc >= chord for anything that is not a straight line, so the reconstruction
replayed the motion too slowly.

Measured on val episode 001948a7 (fold and stack the t-shirts), 12 windows:
replay duration vs the time the arm actually took was off by 47.5% on average
and up to 98% where the path doubles back and arc is nearly twice the chord.
After converting to the arc rate the reconstruction needs, mean error is 2.5%.

The conversion uses only what the token already carries (arc and chord are both
recoverable from the waypoints), so the action space, trained checkpoints and
norm stats are untouched. Guarded for chord ~ 0: a closed loop has speed ~ 0 by
construction and a chord-based token carries no timing for it, so that case
keeps the existing hold-first-waypoint behaviour.

This affects deploy as well as val videos -- rollout-arc.py shares detokenize.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuDAQKMEVv79cvzYiargiK
compute_metrics_and_viz reverted the chunk to camera frame and only then let
_visualize_preds detokenize it. The revert rotates AND translates every row,
but row M of an arc token is a VELOCITY -- translating it yields a position.

Measured on val episode 001948a7 frame 512, left arm: the velocity token
(0.0810, 0.0574, 0.0074) m/s came out of the revert as
(-0.2313, -0.0193, 0.8945), i.e. the camera-to-gripper offset. detokenize then
read a speed of 0.92 instead of 0.0995 and raced through the 0.393 m path in
8 control steps instead of 61, piling the other 92 dots on the endpoint. That
is what made every arc val video look like the arm teleports 40 cm in a third
of a second.

Adds HPTEvalVideo._viz_source (identity; a time-indexed run already predicts
poses) and overrides it in ArcTokEvalVideo to detokenize, so apply_transform
only ever receives rows that really are poses. This also fixes
cam_paired_mse_avg / cam_final_mse_avg, which were comparing tokens whose last
row was garbage.

Consolidates the two copies of the arc-detection predicate onto
ArcTokEvalVideo._is_arc, now also checking the last dim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuDAQKMEVv79cvzYiargiK
The arc path ran InterpolatePose(new_chunk_length=100) before tokenizing, which
squeezed a 200-frame yam window and a 600-frame human window into 100 rows: a 2x
and 6x decimation. Arc length measured on a decimated path chord-cuts and reads
systematically short, so D, the velocity token and every arc metric were built
on an understated path.

get_transform_list now takes chunk_length and defaults it to the RAW window for
arc modes, making the interpolation a no-op. The pipeline is what it should be:
read raw frames, find the frames covering D, resample those to M. Model input is
unchanged at (M+1, 14); only what the tokenizer consumes changes.

This also fixes the velocity scale for free. Each row is now a real 30 Hz frame,
so the hardcoded dt=1/30 is correct -- it was 2x too large on yam and 6x on
human, which would have driven a deployed policy that much too fast.

Baseline side: Human.get_keymap gains action_horizon so the raw window can be
equalized with yam. Human defaulted to 30 frames against yam's 45, so a human
baseline chunk covered 1.0 s and a yam one 1.5 s while both still emitted
(100, 14) -- nothing in the shapes revealed it. The multitask baseline config
now pins both to 45.

Adds evaluator viz_chunk_rows: a baseline chunk is interpolated UP to 100 rows
from 45 raw frames, so most of the drawn dots are interpolation rather than
control setpoints. Setting it to the raw window makes the overlay show what the
controller actually receives. Off by default.

Adds notebooks/arc_reconstruction_error.ipynb: reference chunk cut at D vs
detokenize(tokenize(chunk)), as overlaid video plus per-row error, one episode
each for yam and human.

Existing arc checkpoints and norm stats do not transfer -- the token values
change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuDAQKMEVv79cvzYiargiK
…umes

Four arc/baseline pairs, each pair differing only in action space:

  abc_fslongshirt_{bc,arc_bc}                     no cotrain, robot 10.01h
  abc_fstshirt_mecka_flagfold_cotrain_*           2.93h robot + 2.93h human
  abc_fslongshirt_micro_foldclothes_cotrain_*     10.01h robot + 9.88h human
  abc_mecka_fold_multitask_cotrain_*              38.5h robot + 39.8h human

The BC pair now uses abc "fold and stack the long sleeve shirts" at the same
10h volume and the same task as the fslongshirt cotrain pair, so the delta
between them isolates the human data rather than confounding it with a
different robot task. 10h was not reachable from "fold and stack the t-shirts"
(3.15h total), which is why the BC runs moved off it and were renamed.

Episode selection is now lab+task plus a deterministic subsample,
  int(episode_hash.encode().hex()[-6:], 16) % 1000 < K
so each split lands on a target number of HOURS instead of a `debug: N` episode
cap. The trailing bytes vary in every hash format we carry (abc uuids,
mecka/microagi timestamps) and the selector measures uniform on all four pools
(p10~100, p90~890). A sum(ord) variant was tried first and discarded: it is
nearly constant across same-length timestamp hashes, so K=283 and K=290 gave
2.91h and 10.13h from the same pool. The `debug: N` caps are removed because
they capped episode counts and fought the hour targets.

Every hour figure in the configs was verified by evaluating the config's own
filter lambda against the episode table, not computed by hand.

Also brings the four smaller experiments up to what the multitask pair carries:
sample_frac 0.2, gpus_per_node 2, and the header note that
`override /hydra/launcher` inside an experiment is inert and the launcher has
to be passed on the CLI.

BC models switch to a constant LR. Both inherited lr=1e-4 with
CosineAnnealingLR(T_max=1400) from bc_flow_eva while the cotrain runs are
constant, so late-epoch BC numbers were not comparable to their cotrain twins.
scheduler: null, lr unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuDAQKMEVv79cvzYiargiK
arcmatch compared a baseline against an arc run over different amounts of
motion. chunk_length counted ROWS, but a baseline row is 45/30/100 = 0.015 s
against an arc row's 1/30 s, so chunk_length: 30 scored 0.45 s of baseline
motion against 1.0 s of arc motion. The same mismatch broke dt in the velocity
payload and left arm_travel summing a total variation over 100 samples on one
side and 45 on the other.

Replaced with action_horizon, the true horizon in raw control frames, which is
what 'normal action chunk sampling' means and so what basedist is measured
over. The arc run's preserved window is already native spaced; a baseline chunk
is de-interpolated back down from its 100 rows.

De-interpolating only the baseline is not neutral, though. The round trip is a
low-pass and a baseline chunk has already had half of it applied, so undoing it
attenuates the baseline's error and leaves the arc side's intact. On real yam
chunks that alone still flattered the baseline 1.36x at identical injected
error. Both sides now go through the same H -> 100 -> H trip.

Measured on 54 real episodes, baseline/arc score at identical 2/5/10 mm error:
  before                    0.188  (baseline flattered 5.3x)
  de-interp baseline only   0.742  (flattered 1.36x)
  both round-tripped        1.0000 (p10 1.0000, p90 1.0000)

Perfect models of both classes score exactly 0. Round trip costs 0.29% of
measured travel and 0.6 mm, equally on both sides.

Also adds arcmatch_degenerate_frac: a matched span near zero collapses every
waypoint onto row 0, the origin in eef_frame, so the arm scores exactly 0
whatever was predicted. Those were silently dragging every mean down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pair the fslongshirt and mecka-fold-multitask arc/baseline runs with
annotation-keyed data configs and pooled-Qwen model configs so language
conditioning is the only variable at matched data volume.

Co-authored-by: Cursor <cursoragent@cursor.com>
The hydra-config refactor dropped the text stem and annotation_key path.
Bring both back as no-ops when annotation_key is unset so existing runs
stay unchanged.
Language baseline experiments use the abc 300M pooled-Qwen model as-is.
Arc-tokenizer runs inherit it and only change action_horizon/act_seq.
AnikethCheluva added a commit to GaTech-RL2/EgoVerse-graph that referenced this pull request Sep 8, 2026
Port of GaTech-RL2#573's arc-tokenizer work onto the graph fork, reduced to the part that
belongs in this architecture: the tokenizer as a pair of pipeline nodes.

The Planar arc tokenizer already existed as a loader-side transform, and that
placement is invisible to the graph -- a stage list shows the model consuming
`target` with no sign the target is an arc token rather than a time-indexed
chunk, and tools/config_graph.py cannot lint the boundary because nothing
declares it. These two stages move the boundary into the graph:

`ArcTokenizeStage` takes ActionTargetBuilder's PLACE rather than following it.
Two writers of `target` would be a duplicate-writer lint error and would leave
the graph ambiguous about which one the denoiser models; as the sole writer, it
leaves the rest of a DP or flow chain untouched. It tokenizes per sample through
the same TokenizePlanarArcLength the loader transform uses, so both paths
produce identical targets -- there is a test pinning that.

`ArcDetokenizeStage` walks the waypoint polyline at `speed * dt * k`, the
inverse of how the tokenizer lays waypoints out uniformly in arc length. It
saturates at the window end rather than extrapolating past it, holds position
for a zero-speed token (matching the tokenizer's degenerate branch), and
interpolates heading as (cos, sin) so a chunk that wraps past +/-pi does not
unwind through zero.

Adds `inference_only` to core as the mirror of `train_only`. It is needed, not
cosmetic: this runner treats a stage with an unsatisfied read as a
configuration error and raises, so a stage that legitimately exists in only one
mode has to say so. Without it the detokenizer -- whose `pred_action` input
exists only at inference -- would abort every training run. config_graph.py
reports the new restriction alongside the existing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcPCNAW3GKERANrqvfSCY1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants