Skip to content

DATASET: ECoT episodes in LeRobot v3.0·

The analogy of Embodied Chain-of-Thought reasoning (ECoT, Zawalski et al. 2024) with the robot body replaced by a human cortex in an EPOC X: observation = the brain (band power, metrics, gestures, CQ); action = the agent's speech; reasoning = the per-frame ECoT string; reward = the brain's response (Δstress, Δengagement) after the answer.

On-disk layout·

datasets/<name>/
├── meta/
│   ├── info.json                 # codebase_version "v3.0", fps, features, paths
│   ├── stats.json                # global per-feature stats
│   ├── tasks.parquet             # task string → task_index (the ECoT text)
│   └── episodes/chunk-000/file-000.parquet   # per-episode metadata + stats
└── data/chunk-000/file-000.parquet           # frames, all episodes concatenated

save_episode() dedupes task strings into meta/tasks.parquet and writes a per-frame task_index. Writer contract: LeRobotDataset.create(repo_id, fps, features, root, robot_type="epoc-x", use_videos=False); add_frame per tick; save_episode per episode; finalize() once.

Dependencies: pyarrow>=15, pandas>=2 (uv sync --extra dataset); dataset_v3.py writes the v3 layout directly, mirroring lerobot's utils.py; lerobot is only needed to load. The dataset is valid on disk after every episode.

Clock·

fps = 8, the native rate of pow: one frame per 125 ms tick. Slower streams resample last-known-value (met slow keys ~10 s, dev 2 Hz), faster ones latest-sample (fac and mot, 32 Hz), events OR-accumulate. timestamp = frame_index / fps.

Feature schema·

key dtype shape semantics
observation.state float32 [70] band power, channel-major AF3.theta … AF4.gamma (14 ch × 5 bands, pow order)
observation.contact_quality float32 [14] per-channel CQ 0 to 4
observation.motion float32 [10] Q0 Q1 Q2 Q3 ACCX ACCY ACCZ MAGX MAGY MAGZ; zeros when absent
observation.motion_valid float32 [1] 1.0 if a mot sample landed within 0.5 s
observation.metrics float32 [7] attention engagement excitement longExcitement stress relaxation interest; absent → -1
observation.metrics_valid float32 [7] per-axis 1/0 validity mask
observation.facial float32 [6] eye one-hot + upper_pow + lower_pow
observation.facial_label float32 [2] vocab indices [eyeAct, lowerAct]
observation.events float32 [12] multi-hot events this tick
action float32 [4] [spoke, tool_called, marker_injected, turn_length_tokens_norm]
task (string) the per-frame ECoT text
timestamp, frame_index, episode_index, index, task_index std [1] v3 bookkeeping

No video keys. Channel order: AF3 F7 F3 FC5 T7 P7 O1 O2 P8 T8 FC6 F4 F8 AF4. Bands: theta alpha betaL betaH gamma (raw eeg is license-gated on Basic; pow is the source). Event slots: blink wink_left wink_right head_turn_left head_turn_right nod clench smile focus_high focus_low stress_high command; double_blink also sets blink; command:<act> sets command; look_up/look_down set no bits. Facial vocab (in info.json names): eyeAct ∈ {neutral, blink, winkL, winkR}, lowerAct ∈ {neutral, smile, clench, frown, laugh, smirkLeft, smirkRight}; unknown → -1.

The task string·

TASK: <user question or 'ambient'> | AMBIENT: <ambient line verbatim> | PLAN: <first sentence of reasoning> | TOOL: <name or none> | ACT: <first 200 chars said so far> | REWARD: <Δstress,Δengagement after the turn>

Each tick of a streaming turn carries the reply so far, interleaving reasoning with the brain at 8 Hz. REWARD is back-filled at save_episode(): the first metric sample after the turn minus the last one before it (met ticks every ~10 s, so windowed means would see nothing). Idle ticks: TASK: idle | AMBIENT: <line>.

Episode semantics·

One episode is one agent turn: a 3 s ring buffer puts the start 3 s before the user message, the end 12 s after the agent finishes, long enough to catch the next met sample so REWARD is computable. strands-emotiv record ambient --minutes N records idle 30 s baseline episodes (TASK: idle).

Components·

recorder.py (on_sample/on_event/on_agent) taps the server's Cortex fan-out; bus.py feeds it agent turns from /ask and /stream. dataset_api.py exposes record/start, record/stop, status, episodes, export and publish under /api/dataset/, passkey-gated; the dashboard REC panel drives these routes. Datasets live under ./datasets/<name>/, gitignored.

Loading and pushing·

from lerobot.datasets.lerobot_dataset import LeRobotDataset
ds = LeRobotDataset("cagataydev/emotiv-ecot", root="datasets/<name>")  # local
ds = LeRobotDataset("cagataydev/emotiv-ecot")                          # Hub
frame = ds[0]                      # tensors + ECoT string

Publish uploads the root to hf.co/datasets/cagataydev/emotiv-ecot (private) and moves the v3.0 tag to the new head; lerobot refuses Hub datasets without that tag.

Validation·

The full loop ran against a real EPOC X: record, one agent turn, stop, publish, load back from the Hub. One episode: 99 frames @ 8 fps (12.4 s), a real head_turn_left in observation.events[3] at frame 49, ambient CQ 13/14 matching one dead channel in observation.contact_quality (P7 = 0).

That episode carries Δstress=nan, correctly: met ticks at 0.1 Hz on the Basic license, so an episode whose post-roll ends before the next sample stays honestly unmeasured. Episodes that catch it get real deltas.