Skip to content

fix: eliminate export-time episode truncation and camera-ahead-of-action violations - #1

Open
shamreen20 wants to merge 12 commits into
mainfrom
fix/export-truncation-and-action-lead
Open

fix: eliminate export-time episode truncation and camera-ahead-of-action violations#1
shamreen20 wants to merge 12 commits into
mainfrom
fix/export-truncation-and-action-lead

Conversation

@shamreen20

Copy link
Copy Markdown

Describe the change here

spereira02 and others added 12 commits August 6, 2026 11:47
nova-data-cli has no incremental-append mode and is single-threaded per
invocation, so exporting ~1000 episodes sequentially after collection wastes
hours. pipeline.sh pulls (remote SSH/rsync or a local dir) and exports in
parallel across workers sized from the machine's own RAM/cores, while
collection is still running.

Workers dynamically claim recordings via atomic mkdir, commit via a single
atomic rename that doubles as the durable "done" record (closing a duplicate-
export race found and fixed during testing), and bisect a failing batch
instead of quarantining healthy batch-mates alongside a bad recording.
Per-worker memory is capped via a systemd-run cgroup (MemorySwapMax=0) after
an OOM-killed rerun subprocess was found to be the cause of an earlier
corrupted export. merge_batches.py gains an atomic-output guarantee and a
pre-merge video-encoder compatibility check that lerobot's own
aggregate_datasets skips.

Full design rationale in tools/AGENT.md. Test suite under tools/tests/
exercises both acquisition modes, live-feed and backlog-only, crash/restart,
and a real end-to-end smoke test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
role_worker exited for good on a single empty candidate scan once
COLLECTION_DONE existed, with no tolerance for a transient glitch in that one
scan. Observed in production: worker w3 exited claiming no candidates while
~800 unclaimed recordings were still sitting right there, apparently under
system load (4 CPU-heavy nova-data-cli decodes competing for cores) - a
single false-empty scan permanently dropped a quarter of the throughput with
no error signal. role_supervisor's drain loop had the same shape and a worse
failure mode (would merge before everything was actually exported).

Both now require repeated confirmation (3 consecutive empty scans for a
worker, 2 clean passes for the supervisor) before trusting "nothing left",
matching the pattern role_acquire's idle-detection already used.

Also adds a final report (recordings found/exported/quarantined, episodes in
the merged dataset) and a nonzero exit code when anything's quarantined, so a
systemic failure doesn't silently look like a clean run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
top_up_workers was only ever called once at the start of the collection
phase (the supervisor then blocked on `wait "$acquire_pid"` until
collection fully finished), so the pool was permanently stuck at whatever
system memory allowed at that single instant. Combined with the backlog
cap only applying post-collection, a run could spawn its full
memory-supported worker count immediately and leave most of it idle for
the rest of a long collection if candidates matured slower than the pool
could consume them.

Now top_up_workers polls every 60s throughout collection too, and caps new
spawns against the live unclaimed backlog unconditionally (safe now that a
transient empty read just delays one poll instead of blocking forever).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… metadata

Design spec (verified against the installed lerobot package, LeRobot policy
configs, and real community datasets like DROID and AgiBot World 2026) plus
the resulting task-by-task implementation plan for two optional exporter
features: per-episode language instructions (config.task_field, sourced from
meta.json, falling back to the existing dataset-wide task_description) and a
fix generalizing episode_metadata to any JSON scalar type instead of
float-only with a wrong 0.0 default for missing fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds two optional, backward-compatible exporter capabilities for VLA
training datasets (LeRobot export head):

- config.task_field: names a meta.json field (e.g. "task") holding each
  episode's own natural-language instruction. Resolved per episode and
  written via LeRobot's native frame["task"]/task_index mechanism, so
  different episodes can carry different instructions without inventing
  a parallel column. Falls back to the existing task_description when
  unset or missing for a given episode.
- episode_metadata now accepts any JSON scalar (str/float/int/bool)
  instead of float-only, and defaults a missing field to null instead of
  the previously-wrong 0.0 default (e.g. a string field like cube_color).

Both default to today's exact behavior when unset. Verified end-to-end
against a real recording (task instruction and cube-position metadata
round-tripped correctly into the exported dataset).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reuses the existing lerobot_export.json in pick_and_place_imitation_learning
(no new config file) and this machine's paths, so the pipeline can be run
with no PIPELINE_* env vars for this dataset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
setsid detaches the supervisor from the controlling terminal, so Ctrl+C's
SIGINT never reached it -- the shutdown trap was correctly written but
never invoked. An outer wrapper now stays attached to the terminal and
forwards INT/TERM into the detached process group.

Also fixes a second bug this surfaced: the shutdown trap's own
`kill -- -$$` re-signals itself (same pgid), re-entering the same trap
forever instead of exiting. Trap now disarms itself before killing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ion violations

Two real defects found and fixed in the export pipeline, verified against
real recordings from pick_and_place_v4_1000:

1. signal_change trimming (_apply_trimming) compared its threshold against
   a per-sample joint-position delta, but the default threshold (0.01) sat
   above the 99th percentile of real per-sample motion for this robot/log
   rate. As a result, trimming matched almost nothing but the fastest
   samples (typically only the final retreat), collapsing episodes to a
   tiny fragment (as little as 1.6s of a true ~40s Leg B). Fixed the
   example configs to default to all_present trimming, lowered the
   threshold as a fallback default, and added a guard that warns loudly
   (with the observed signal statistics) whenever signal_change trimming
   keeps less than half the raw episode span, instead of silently shipping
   a fragment.

2. _query_action_state resampled action/state onto the export's fixed-rate
   grid via nearest-neighbor, which could select a sample from AFTER the
   grid timestamp. Because /actions_target has real dropouts (tens to
   hundreds of ms), this let some exported frames pair a camera timestamp
   with a numerically later action sample - i.e. camera-ahead-of-action in
   the exported data, undermining the action-lead invariant the collector
   otherwise guarantees at recording time. Replaced nearest-neighbor with
   strictly causal selection (latest sample at or before grid time), which
   cannot select a future sample. Verified on 5 real re-exported episodes:
   violations dropped from 425/9168 frame-camera pairs (4.6%, up to 331.7ms)
   to 3/9168 (0.03%, all attributable to genuine raw camera dropouts, not
   the export logic).

Re-exporting 4-5 real episodes before/after confirms both fixes: exported
duration now matches the raw HOME-to-HOME Leg B span (previously as low as
3.9% of it), and the exported action column exactly matches the raw
/actions_target value at its causal grid selection.

Test suite: 102 passed, 3 skipped.
get_frame_at/get_frame_index picked the nearest decoded frame to each grid
timestamp, ties going to the earlier frame. That nearest-frame rule could
still select a camera frame *before* the target time, re-opening the same
camera-ahead/behind-of-action class of bug that 5fc15af just fixed on the
action/state side.

Both FrameCache.get_frame_index and VideoDecoder.decode_at now select the
earliest frame at or after each target timestamp instead of the nearest one.
Targets past the last decoded frame still clamp to it (now logged as a
warning, since it means real target coverage was short).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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