Skip to content

Add segment-aware demonstration episodes - #460

Open
yuecideng wants to merge 8 commits into
mainfrom
feat/segmented-demo-episodes
Open

Add segment-aware demonstration episodes#460
yuecideng wants to merge 8 commits into
mainfrom
feat/segmented-demo-episodes

Conversation

@yuecideng

Copy link
Copy Markdown
Contributor

Description

Adds a segment-aware expert demonstration contract so one Gym episode can contain multiple semantic subtrajectories, such as several pick-and-place operations, while preserving legacy single-action-list tasks.

The change:

  • introduces DemoSegment, structured episode/segment results, and one shared executor used by run-env and the ODS simulation worker;
  • makes reset the explicit commit/discard boundary, checks termination after every action, and bounds failed generation retries;
  • records independent per-env lengths plus validity, segment, and terminal annotations in expert rollout buffers;
  • persists LeRobot frame annotations, dynamic segment instructions, and meta/embodichain_episodes.jsonl, including cloned metadata in the async recorder;
  • makes ODS sampling validity-aware and adds episode, segment, and boundary chunk policies;
  • documents the task-owned segment API and ODS schema.

Existing tasks implementing create_demo_action_list() remain compatible as one legacy segment. Direct callers that pass num_traj > 1 to generate_function must migrate that logic into create_demo_segments().

No new dependencies are required.

Fixes: N/A

Type of change

  • New feature (non-breaking for existing task implementations)
  • Breaking behavior for direct generate_function(num_traj > 1) callers
  • Documentation update

Screenshots

N/A — runtime/data-model change with no visual UI.

Validation

  • black==26.3.1 on all changed Python files
  • pytest -q tests/gym/envs tests/gym/utils/test_gym_utils.py tests/data_pipeline/test_online_data.py tests/lab/scripts/test_run_env.py — 291 passed, 3 skipped
  • Focused demo/ODS/LeRobot/async/replay regression suite — 87 passed
  • Final affected-path regression suite — 66 passed, 1 skipped
  • make -C docs html — succeeded

The repository-wide strict Sphinx build remains blocked by 671 existing warnings; the normal HTML build succeeds and the changed Markdown pages produced no path-specific warnings.

Checklist

  • I have run the black . equivalent on all changed Python files.
  • I have made corresponding changes to the documentation.
  • I have added tests that prove the feature works.
  • Dependencies have been updated, if applicable (no dependency changes required).

@yuecideng yuecideng added enhancement New feature or request gym robot learning env and its related features dataset data Related to data_pipeline module docs Improvements or additions to documentation breaking labels Aug 5, 2026
# Conflicts:
#	embodichain/lab/scripts/run_env.py
#	tests/data_pipeline/test_online_data.py
#	tests/lab/scripts/test_run_env.py
@yuecideng
yuecideng marked this pull request as ready for review August 6, 2026 15:06
Copilot AI lite review requested due to automatic review settings August 6, 2026 15:06
@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown

Greptile Summary

The PR introduces segment-aware expert demonstrations and propagates episode, segment, boundary, validity, and terminal metadata through execution, online sampling, replay, and LeRobot recording.

  • Adds a shared demonstration executor with bounded retries and explicit commit/discard behavior.
  • Supports independently completed vectorized environments and segment-aware ODS sampling.
  • Persists segment annotations through synchronous and asynchronous recorders.
  • Adds a multi-segment example task, preview tooling, documentation, and regression tests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains within the eligible follow-up-review scope.

Important Files Changed

Filename Overview
embodichain/lab/gym/envs/demo.py Introduces the shared segment-aware demonstration contract and tracks per-environment completion and terminal outcomes.
embodichain/data_pipeline/engine/data.py Adds transactional online rollout generation, validity-aware sampling, bounded initialization and generation, and explicit worker lifecycle handling.
embodichain/data_pipeline/datasets/online_data.py Exposes episode, segment, and boundary sampling modes through the iterable dataset.
embodichain/lab/gym/envs/managers/datasets.py Persists frame-level segment annotations and episode metadata through the synchronous LeRobot recorder.
embodichain/lab/gym/envs/managers/async_datasets.py Extends asynchronous recording so cloned episode jobs retain segment metadata.
embodichain/lab/scripts/run_env.py Migrates offline generation to the shared executor and makes reset the explicit episode commit or discard boundary.

Sequence Diagram

sequenceDiagram
    participant Task
    participant Executor
    participant Env
    participant Recorder
    participant ODS
    Task->>Executor: create_demo_segments()
    loop Each segment and action
        Executor->>Env: step(action or hold)
        Env-->>Executor: terminated/truncated per environment
        Executor->>Recorder: record frame and segment annotations
    end
    alt Complete and all environments successful
        Executor->>Env: "reset(save_data=true)"
        Env->>Recorder: commit complete episode
        Recorder-->>ODS: valid annotated trajectory
    else Invalid, truncated, or failed
        Executor->>Env: "reset(save_data=false)"
        Env->>Recorder: discard pending episode
    end
Loading

Reviews (6): Last reviewed commit: "wip" | Re-trigger Greptile

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a segment-aware expert demonstration contract so a single Gym episode can contain multiple semantic sub-trajectories (“segments”), while keeping legacy create_demo_action_list() tasks compatible. This integrates a shared episode executor across run-env, dataset recorders (LeRobot + sidecars), and the OnlineDataEngine shared-buffer worker, with reset becoming the explicit commit/discard boundary.

Changes:

  • Introduces segment-aware demo types (DemoSegment, DemoEpisodeResult, etc.) and a common executor that supports lazy segment planning, vector-env staggered completion, and per-frame annotations.
  • Makes offline generation and online shared-buffer filling transactional (commit on reset(), discard on reset(save_data=False)), adds bounded retry, and propagates durability/flush failures.
  • Extends recording/sampling schema to include valid + segment/terminal annotations, updates LeRobot exports + metadata sidecar, and documents new APIs/sampling modes.

Reviewed changes

Copilot reviewed 27 out of 27 changed files in this pull request and generated no comments.

Show a summary per file
File Description
tests/lab/scripts/test_run_env.py Expands CLI runner tests for transactional generation, replay ownership, and cleanup semantics.
tests/gym/envs/test_replay.py Updates replay close behavior expectation (no implicit autosave on close).
tests/gym/envs/test_demo.py New comprehensive tests for segment-aware demo execution and annotation behavior.
tests/gym/envs/tasks/test_stay_still_save.py Adjusts registered time limit to avoid truncation on a 100-step expert plan.
tests/gym/envs/managers/test_dataset_manager.py Adds finalize idempotency/error aggregation and commit/discard camera/trajectory tests.
tests/gym/envs/managers/test_dataset_functors.py Adds LeRobot finalize idempotency, frame annotation, and JSONL sidecar tests.
tests/gym/envs/managers/test_async_dataset_functors.py Extends async recorder tests for cloned annotations/metadata and finalize failure aggregation.
tests/data_pipeline/test_online_data.py Adds engine lifecycle/state/error-channel tests and segment/boundary sampling tests.
tests/data_pipeline/depth_video/test_writer.py Ensures depth encoder close failures surface as durability errors.
embodichain/lab/scripts/run_env.py Switches to segment-aware executor; makes reset the commit boundary; improves replay/cleanup semantics.
embodichain/lab/gym/utils/gym_utils.py Extends rollout buffer schema with validity/segment/terminal annotation fields.
embodichain/lab/gym/envs/managers/record.py Makes camera recorders transactional + idempotent finalize/close; async recorder supports explicit per-env commit queues.
embodichain/lab/gym/envs/managers/datasets.py LeRobotRecorder now persists per-frame annotations + episode JSONL sidecar and has idempotent durability-aware finalize.
embodichain/lab/gym/envs/managers/dataset_manager.py Makes dataset functor finalization idempotent and aggregates failures across functors.
embodichain/lab/gym/envs/managers/async_datasets.py Async LeRobot recorder now clones annotations + metadata and aggregates background failures at finalize barrier.
embodichain/lab/gym/envs/embodied_env.py Adds per-env rollout cursors, segment metadata hooks, transactional discard, and safe masking for staggered vector demos.
embodichain/lab/gym/envs/demo.py New segment-aware demonstration protocol + executor implementation.
embodichain/lab/gym/envs/base_env.py Disables auto-reset during demo execution (similar to replay).
embodichain/lab/gym/envs/init.py Exposes demo protocol symbols from envs package.
embodichain/data_pipeline/engine/data.py Adds explicit lifecycle states, worker error broadcast channel, transactional writes, and segment-aware sampling modes.
embodichain/data_pipeline/engine/init.py Re-exports new engine state/error types.
embodichain/data_pipeline/depth_video/writer.py Treats depth sidecar close/meta failures as raised durability errors.
embodichain/data_pipeline/datasets/online_data.py Forwards segment/boundary sampling modes from OnlineDataset into OnlineDataEngine.
embodichain_tasks/embodichain_tasks/special/stay_still_save.py Updates env time limit rationale for segment-aware executor commit behavior.
docs/source/overview/gym/dataset_functors.md Documents finalize as a durability barrier (no implicit commit) and error surfacing semantics.
docs/source/guides/run_env.md Documents segment API, retry/commit behavior, and annotation outputs.
docs/source/features/online_data.md Documents transactional rows, lifecycle states, validity masks, and segment/boundary sampling.
Suppressed comments (1)

embodichain/lab/gym/envs/managers/record.py:198

  • max_env_num is not being enforced here: num_frames uses max(rgb.shape[0], max_env_num), which will never cap the number of environments rendered when rgb.shape[0] > max_env_num. This can accidentally record/merge far more env frames than intended (and increases CPU/GPU copy + video size). Use min(...) so the recorder respects max_env_num.
        rgb = data["color"]

        num_frames = max(rgb.shape[0], max_env_num)
        rgb = rgb[:num_frames]
        rgb = self._draw_frames_into_one_image(rgb)[..., :3].cpu().numpy()

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copilot AI review requested due to automatic review settings August 6, 2026 15:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated no new comments.

Suppressed comments (5)

tests/data_pipeline/test_online_data.py:134

  • forkserver is not available on all supported platforms (e.g., Windows), so this can make the test suite fail depending on the runner OS. Prefer spawn here or select the start method conditionally (e.g., fall back to spawn when forkserver is unavailable via mp.get_all_start_methods()).
    engine._mp_ctx = mp.get_context("forkserver")

embodichain/lab/gym/envs/managers/record.py:139

  • env_ids is accepted but ignored in the synchronous record_camera_data implementation, while callers now pass per-reset env_ids. This is confusing to API consumers and makes it unclear whether partial resets are supported; either (mandatory) implement env_ids semantics (e.g., per-env frame buffers like the async recorder) or (alternative) remove the parameter and have the caller only invoke this recorder on full resets (or document/enforce that env_ids must be None/all envs).
    def save_and_clear(self, env_ids: Union[torch.Tensor, None] = None) -> None:

embodichain/lab/gym/envs/managers/record.py:157

  • env_ids is accepted but ignored in the synchronous record_camera_data implementation, while callers now pass per-reset env_ids. This is confusing to API consumers and makes it unclear whether partial resets are supported; either (mandatory) implement env_ids semantics (e.g., per-env frame buffers like the async recorder) or (alternative) remove the parameter and have the caller only invoke this recorder on full resets (or document/enforce that env_ids must be None/all envs).
    def discard_and_clear(self, env_ids: Union[torch.Tensor, None] = None) -> None:
        """Discard recorded frames without creating an episode video."""
        self._frames = []

embodichain/data_pipeline/engine/data.py:991

  • sample_batch() holds the producer window lock while doing relatively expensive tensor ops (unfold, window filtering, and candidate selection) and then cloning the result. This can block the producer from advancing the write window (it acquires the same lock) and may reduce throughput under load. Consider shrinking the critical section by reading (lock_start, lock_end) under the lock, releasing it during window computation, and only re-acquiring to clone (with a re-check that (lock_start, lock_end) is unchanged), or by moving window precomputation to a cheaper structure.
        with self._lock_index.get_lock():
            lock_start: int = self._lock_index[0]
            lock_end: int = self._lock_index[1]

            if "valid" in self.shared_buffer.keys():
                valid = self.shared_buffer["valid"].bool()
            else:
                # Schema-v1 buffers are one fully valid segment per row.
                valid = torch.ones(
                    self.buffer_size,
                    max_steps,
                    dtype=torch.bool,
                    device=self.shared_buffer.device,
                )

            all_rows = torch.arange(self.buffer_size, device=valid.device)
            is_locked = (all_rows >= lock_start) & (all_rows < lock_end)
            valid_windows = valid.unfold(1, chunk_size, 1).all(dim=-1)
            valid_windows[is_locked] = False

            segment_ids = self.shared_buffer.get("segment_id", None)
            if segment_ids is None:
                segment_ids = torch.zeros_like(valid, dtype=torch.int64)

embodichain/data_pipeline/engine/data.py:1032

  • sample_batch() holds the producer window lock while doing relatively expensive tensor ops (unfold, window filtering, and candidate selection) and then cloning the result. This can block the producer from advancing the write window (it acquires the same lock) and may reduce throughput under load. Consider shrinking the critical section by reading (lock_start, lock_end) under the lock, releasing it during window computation, and only re-acquiring to clone (with a re-check that (lock_start, lock_end) is unchanged), or by moving window precomputation to a cheaper structure.
            result = self.shared_buffer[row_indices[:, None], time_indices].clone()

Copilot AI review requested due to automatic review settings August 6, 2026 16:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated 3 comments.

Suppressed comments (2)

tests/data_pipeline/test_online_data.py:134

  • The test helper unconditionally requests the forkserver start method, which is unavailable on some platforms (notably Windows). Use a supported start method fallback (e.g., prefer forkserver when available, else spawn) so the test suite remains portable.
    engine._mp_ctx = mp.get_context("forkserver")

embodichain/data_pipeline/engine/data.py:1004

  • This computes valid_windows (and potentially segment_windows) via unfold(...).all(...) across the entire buffer on every sample_batch call while holding the shared lock. For large buffer_size / max_episode_steps, this can become a major CPU bottleneck and also delay the producer from advancing the lock window. Consider a more O(buffer_size) approach (e.g., track per-row valid lengths, and for segment/boundary modes precompute per-row boundary indices or segment spans) so sampling remains fast under training load.
            all_rows = torch.arange(self.buffer_size, device=valid.device)
            is_locked = (all_rows >= lock_start) & (all_rows < lock_end)
            valid_windows = valid.unfold(1, chunk_size, 1).all(dim=-1)
            valid_windows[is_locked] = False

            segment_ids = self.shared_buffer.get("segment_id", None)
            if segment_ids is None:
                segment_ids = torch.zeros_like(valid, dtype=torch.int64)

            if sampling_mode == "segment":
                segment_windows = segment_ids.unfold(1, chunk_size, 1)
                same_segment = (segment_windows == segment_windows[..., :1]).all(
                    dim=-1
                ) & (segment_windows[..., 0] >= 0)
                valid_windows &= same_segment
            elif sampling_mode == "boundary":
                segment_windows = segment_ids.unfold(1, chunk_size, 1)
                crosses_boundary = (
                    segment_windows[..., 1:] != segment_windows[..., :-1]
                ).any(dim=-1)
                valid_windows &= crosses_boundary

Comment on lines +215 to +223
def _normalize_env_ids(self, env_ids: Union[torch.Tensor, None]) -> list[int]:
"""Return recorder-local environment IDs for a transaction boundary."""
if env_ids is None:
return list(range(self._num_envs))
if isinstance(env_ids, torch.Tensor):
values = env_ids.reshape(-1).cpu().tolist()
else:
values = list(env_ids)
return [int(env_id) for env_id in values if int(env_id) < self._num_envs]
Comment on lines +630 to +634
success_source = (
success_fn()
if success_fn is not None
else last_info.get("success", True)
)
Comment on lines +994 to 1001
if annotations is not None:
for annotation_key, feature_key in DEMO_FRAME_FEATURES.items():
if annotation_key not in annotations:
continue
value = torch.as_tensor(annotations[annotation_key]).item()
frame[feature_key] = torch.tensor([int(value)], dtype=torch.int64)

return frame
Copilot AI review requested due to automatic review settings August 8, 2026 12:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 27 changed files in this pull request and generated no new comments.

Suppressed comments (1)

embodichain/lab/gym/envs/managers/record.py:224

  • _normalize_env_ids() filters env_ids only by < self._num_envs. Negative env IDs (e.g. -1) will pass this check and later index from the end of _frames_list / _committed_env_episodes, corrupting the wrong recorder row.

Filter to 0 <= env_id < self._num_envs before returning.

    def _normalize_env_ids(self, env_ids: Union[torch.Tensor, None]) -> list[int]:
        """Return recorder-local environment IDs for a transaction boundary."""
        if env_ids is None:
            return list(range(self._num_envs))
        if isinstance(env_ids, torch.Tensor):
            values = env_ids.reshape(-1).cpu().tolist()
        else:
            values = list(env_ids)
        return [int(env_id) for env_id in values if int(env_id) < self._num_envs]

Copilot AI review requested due to automatic review settings August 8, 2026 14:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated no new comments.

Suppressed comments (2)

embodichain/lab/gym/envs/managers/datasets.py:347

  • If a caller saves episodes without providing rollout-buffer annotations (i.e., annotations=None), frames currently have no valid field even though the LeRobot schema includes segment/terminal annotations. Adding a default valid=True keeps the per-frame annotation contract consistent and avoids consumers having to special-case the absence of annotation.valid.
                frame_annotations = {
                    "episode_step": frame_index,
                    "segment_id": 0,
                    "segment_step": frame_index,
                    "segment_start": frame_index == 0,

embodichain/lab/gym/envs/managers/datasets.py:76

  • DEMO_ANNOTATION_KEYS includes valid, and LeRobotRecorder collects valid from the rollout buffer, but DEMO_FRAME_FEATURES does not map it into a LeRobot annotation.* feature. This makes the valid annotation silently dropped (and the collected tensor effectively unused). Consider adding valid -> annotation.valid so the schema matches the executor/buffer contract (or stop collecting valid if intentionally omitted).

This issue also appears on line 343 of the same file.

DEMO_FRAME_FEATURES = {
    "episode_step": "annotation.episode_step",
    "segment_id": "annotation.segment_id",
    "segment_step": "annotation.segment_step",
    "segment_start": "annotation.segment_start",

Copilot AI review requested due to automatic review settings August 8, 2026 17:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 40 out of 40 changed files in this pull request and generated no new comments.

Suppressed comments (1)

embodichain/lab/gym/envs/demo.py:370

  • publish_active_mask() builds _demo_active_mask using device = getattr(previous, "device", None) and then passes that device into torch.tensor(...). When there is no existing mask and the env does not implement _set_demo_active_mask, this can pass device=None (which is not accepted on some Torch builds) and also ignores target.device when present. Prefer selecting target.device as a fallback and omit the device= argument when unknown.
        previous = getattr(target, "_demo_active_mask", None)
        device = getattr(previous, "device", None)
        setattr(
            target,
            "_demo_active_mask",
            torch.tensor(active, dtype=torch.bool, device=device),
        )

Copilot AI review requested due to automatic review settings August 8, 2026 17:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 40 out of 40 changed files in this pull request and generated no new comments.

Suppressed comments (5)

embodichain/lab/scripts/run_env.py:733

  • BaseException.add_note() is only available on Python 3.11+, but the package declares requires-python >=3.10. This will raise AttributeError when cleanup fails while unwinding another exception, potentially hiding the original error. Guard the call and fall back to logging when notes are unavailable.
            body_error.add_note(
                "Environment cleanup also failed: "
                f"{type(cleanup_error).__name__}: {cleanup_error}"
            )

embodichain/lab/scripts/run_env.py:657

  • BaseException.add_note() is only available on Python 3.11+, but the project supports Python >=3.10. Calling it here will raise AttributeError during cleanup and can mask the real close failure. Prefer exception chaining (or guard with hasattr).

This issue also appears on line 730 of the same file.

        if abort_error is not None:
            close_error.add_note(
                "Pending episode abort also failed: "
                f"{type(abort_error).__name__}: {abort_error}"
            )

embodichain/lab/scripts/preview_lerobot_data.py:151

  • _read_episode_sidecar() reads the entire meta/embodichain_episodes.jsonl into memory via read_text().splitlines(). For large datasets this can be very slow and memory-heavy; iterating the file line-by-line avoids loading the full sidecar.
    for line in sidecar_path.read_text(encoding="utf-8").splitlines():
        if not line.strip():
            continue
        record = json.loads(line)
        record_index = record.get("lerobot_episode_index", record.get("episode_index"))

embodichain/lab/gym/envs/managers/datasets.py:452

  • BaseException.add_note() is only available on Python 3.11+, but the project supports Python >=3.10. If depth abort also fails on Python 3.10, this will raise AttributeError and hide the original save failure. Guard add_note() and fall back to logging.
                    error.add_note(
                        "Depth sidecar abort also failed: "
                        f"{type(abort_error).__name__}: {abort_error}"
                    )

embodichain/main.py:78

  • CLI command names in COMMANDS appear to consistently use kebab-case (e.g., run-env, preview-asset, preview-scene), but the new command is registered as preview_lerobot_data with underscores. This is likely to be surprising/inconsistent for users and makes autocomplete harder. Consider renaming to preview-lerobot-data and updating docs/tests accordingly.
    Command(
        name="preview_lerobot_data",
        target="embodichain.lab.scripts.preview_lerobot_data:cli",
        help="Print and validate a recorded LeRobot dataset episode.",
    ),

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking data Related to data_pipeline module dataset docs Improvements or additions to documentation enhancement New feature or request gym robot learning env and its related features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants