Skip to content

Commit 9a6d630

Browse files
Alex-Hacheclaude
andcommitted
feat: demo store writer (BACKLOG 6C)
- decras/imitation/store.py: save_demo / load_demo / list_demos / ingest_sequence; deterministic id <dataset>_ep<NNN>_<density>. - retrieval.py: to_dict/from_dict on Demo/Primitive/DemoMetadata; DemoMetadata extended with provenance fields (density, start_ee_position, created_at, source_sequence_path, segmenter_git_sha). - scripts/add_demo.py: thin CLI wrapping segmenter output + task as a Demo and writing it to demos/. Duplicates raise unless --overwrite. - tests/test_store.py: 13 tests (roundtrip, list, duplicate handling, ingest provenance/task-override/missing-task). - demos/sticks_v2_ep000_medium.json: first ingested demo via CLI. - BACKLOG.md + PROJECT_STATUS.md updated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c67b970 commit 9a6d630

7 files changed

Lines changed: 566 additions & 20 deletions

File tree

‎BACKLOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@
140140
141141
- [x] **Design demo store schema** — `decras/imitation/retrieval.py` — `Demo`, `Primitive`, `DemoMetadata` dataclasses.
142142

143-
- [ ] **Build demo store writer** — Segmenter v2 output + task string → Demo JSON on disk.
143+
- [x] **Build demo store writer** — Segmenter v2 output + task string → Demo JSON on disk. Store at `demos/` with deterministic id `<dataset>_ep<NNN>_<density>.json`. `decras.imitation.store` has `save_demo` / `load_demo` / `list_demos` / `ingest_sequence`. CLI: `uv run python -m scripts.add_demo <sequence.json> --task "..."`. Duplicates raise unless `--overwrite`. Provenance fields added at ingest: `created_at`, `source_sequence_path`, `segmenter_git_sha`.
144144
- *Tag*: `claude-code`
145145

146146
- [ ] **Build demo retriever** — TF-IDF or sentence-transformer cosine similarity on task descriptions. `retrieve("pick up the stick") → [demo_1, demo_3]`.

‎PROJECT_STATUS.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,17 @@ scripts/
7070
replay_teleop.py — Replay recorded episode on follower hardware
7171
visualize_trajectory.py — FK over Parquet frames → 3D matplotlib EE path, per-episode colors; --segment --density {low|medium|high} overlays segmenter v2 waypoints + straight-line segments
7272
segment_trajectory.py — Segmenter v2 (waypoint-based): direction changes + speed dips + gripper events → one move_to_delta per segment; --density tunes granularity (low~5, medium~13, high~26 primitives on sticks_v2)
73+
add_demo.py — Thin CLI that wraps segmenter output + task as a Demo and writes it to the store (decras.imitation.store.ingest_sequence)
7374
7475
calibration/
7576
record_grid.py — Interactive calibration grid recorder (leader+follower teleop, manual xyz input, resume support)
7677
7778
decras/
7879
imitation/
79-
retrieval.py — Demo store schema (Demo, Primitive, DemoMetadata dataclasses)
80+
retrieval.py — Demo store schema (Demo, Primitive, DemoMetadata dataclasses, to_dict/from_dict)
81+
store.py — Demo store writer: save_demo/load_demo/list_demos/ingest_sequence; deterministic id <dataset>_ep<NNN>_<density>
82+
83+
demos/ — Demo store (one JSON per ingested segmenter output)
8084
8185
datasets/
8286
sticks_v1/ — 5 teleop episodes, task: "pick stick and place at target"
@@ -174,7 +178,9 @@ All 8 motion primitives hardware-validated (March 2026):
174178
- First RAG experiment: inject demo sequence as few-shot example
175179

176180
**Phase 6C — Demo Store & Retrieval (AFTER 6A+6B)**:
177-
- Demo store writer + retriever + RAG integration + more demos + fine-tuning data
181+
- ~~Demo store schema~~ — DONE (retrieval.py dataclasses)
182+
- ~~Demo store writer~~ — DONE (`decras.imitation.store`, CLI at `scripts/add_demo.py`, `demos/` at project root)
183+
- Retriever (TF-IDF / embedding cosine on task strings) + RAG integration + more demos + fine-tuning data
178184

179185
### Phase 7 — Memory & Context
180186

‎decras/imitation/retrieval.py‎

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,24 @@
1212
],
1313
"metadata": {
1414
"dataset": "sticks_v1",
15-
"episode": 3
15+
"episode": 3,
16+
"density": "medium",
17+
"start_ee_position": {"x": 0.19, "y": 0.05, "z": 0.12},
18+
"created_at": "2026-04-19T10:12:00+00:00",
19+
"source_sequence_path": "datasets/sticks_v1/sequences/episode_003.json",
20+
"segmenter_git_sha": "c67b970"
1621
}
1722
}
1823
19-
Fields
20-
------
21-
task : str
22-
Natural-language description of the demonstrated task. Used as the
23-
retrieval key when selecting few-shot examples for the LLM planner.
24-
primitives : list[Primitive]
25-
Ordered sequence of MCP tool calls that the segmenter extracted from the
26-
continuous demonstration trajectory. Each entry contains:
27-
- tool: name of the MCP primitive (must match a server.py tool)
28-
- args: keyword arguments passed to the tool (may be empty)
29-
- timestamp: seconds from episode start when this primitive begins
30-
metadata : DemoMetadata
31-
Provenance information so we can trace a stored demo back to its source
32-
recording. Contains the LeRobot dataset name and episode index.
24+
Only ``dataset`` and ``episode`` are required in metadata; the rest are
25+
populated by the ingest pipeline (``decras.imitation.store``) and carry
26+
provenance information (when/where/which version of the segmenter produced
27+
this demo).
3328
"""
3429

3530
from __future__ import annotations
3631

37-
from dataclasses import dataclass, field
32+
from dataclasses import dataclass, field, fields
3833
from typing import Any
3934

4035

@@ -46,13 +41,44 @@ class Primitive:
4641
args: dict[str, Any]
4742
timestamp: float
4843

44+
def to_dict(self) -> dict[str, Any]:
45+
return {"tool": self.tool, "args": dict(self.args), "timestamp": self.timestamp}
46+
47+
@classmethod
48+
def from_dict(cls, data: dict[str, Any]) -> "Primitive":
49+
return cls(
50+
tool=data["tool"],
51+
args=dict(data.get("args", {})),
52+
timestamp=float(data["timestamp"]),
53+
)
54+
4955

5056
@dataclass
5157
class DemoMetadata:
5258
"""Provenance info linking a demo back to its source recording."""
5359

5460
dataset: str
5561
episode: int
62+
density: str | None = None
63+
start_ee_position: dict[str, float] | None = None
64+
created_at: str | None = None
65+
source_sequence_path: str | None = None
66+
segmenter_git_sha: str | None = None
67+
68+
def to_dict(self) -> dict[str, Any]:
69+
out: dict[str, Any] = {"dataset": self.dataset, "episode": self.episode}
70+
for f in fields(self):
71+
if f.name in ("dataset", "episode"):
72+
continue
73+
value = getattr(self, f.name)
74+
if value is not None:
75+
out[f.name] = value
76+
return out
77+
78+
@classmethod
79+
def from_dict(cls, data: dict[str, Any]) -> "DemoMetadata":
80+
known = {f.name for f in fields(cls)}
81+
return cls(**{k: v for k, v in data.items() if k in known})
5682

5783

5884
@dataclass
@@ -61,4 +87,21 @@ class Demo:
6187

6288
task: str
6389
primitives: list[Primitive] = field(default_factory=list)
64-
metadata: DemoMetadata = field(default_factory=lambda: DemoMetadata(dataset="", episode=0))
90+
metadata: DemoMetadata = field(
91+
default_factory=lambda: DemoMetadata(dataset="", episode=0)
92+
)
93+
94+
def to_dict(self) -> dict[str, Any]:
95+
return {
96+
"task": self.task,
97+
"primitives": [p.to_dict() for p in self.primitives],
98+
"metadata": self.metadata.to_dict(),
99+
}
100+
101+
@classmethod
102+
def from_dict(cls, data: dict[str, Any]) -> "Demo":
103+
return cls(
104+
task=data["task"],
105+
primitives=[Primitive.from_dict(p) for p in data.get("primitives", [])],
106+
metadata=DemoMetadata.from_dict(data.get("metadata", {})),
107+
)

‎decras/imitation/store.py‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Demo store — write, load, and list decomposed demonstrations.
2+
3+
The store lives at ``demos/`` at the project root, parallel to ``datasets/``.
4+
Each demo is one JSON file named by a deterministic id:
5+
6+
<dataset>_ep<episode:03d>[_<density>].json
7+
8+
Determinism matters: re-segmenting the same episode at the same density should
9+
land on the same file so segmenter tweaks overwrite the prior version instead
10+
of piling up near-duplicates (use ``overwrite=True`` to opt in).
11+
12+
Provenance fields (``created_at``, ``source_sequence_path``,
13+
``segmenter_git_sha``) are added at ingest time by :func:`ingest_sequence`.
14+
15+
Typical flow::
16+
17+
# segmenter already ran and produced datasets/sticks_v2/sequences/episode_000.json
18+
ingest_sequence(Path("datasets/sticks_v2/sequences/episode_000.json"),
19+
task="pick stick and place at target")
20+
# → writes demos/sticks_v2_ep000_medium.json
21+
22+
demos = list_demos() # [Demo, ...]
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import json
28+
import subprocess
29+
from datetime import datetime, timezone
30+
from pathlib import Path
31+
32+
from .retrieval import Demo, DemoMetadata, Primitive
33+
34+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
35+
DEFAULT_STORE_ROOT = PROJECT_ROOT / "demos"
36+
37+
38+
def demo_id(metadata: DemoMetadata) -> str:
39+
"""Stable filename stem for a demo — deterministic from (dataset, episode, density)."""
40+
parts = [metadata.dataset, f"ep{int(metadata.episode):03d}"]
41+
if metadata.density:
42+
parts.append(metadata.density)
43+
return "_".join(parts)
44+
45+
46+
def demo_path(demo: Demo, root: Path = DEFAULT_STORE_ROOT) -> Path:
47+
return Path(root) / f"{demo_id(demo.metadata)}.json"
48+
49+
50+
def save_demo(demo: Demo, root: Path = DEFAULT_STORE_ROOT, *, overwrite: bool = False) -> Path:
51+
"""Write ``demo`` to the store. Raises :class:`FileExistsError` unless ``overwrite``."""
52+
root = Path(root)
53+
root.mkdir(parents=True, exist_ok=True)
54+
path = demo_path(demo, root)
55+
if path.exists() and not overwrite:
56+
raise FileExistsError(
57+
f"Demo already exists at {path}. Pass overwrite=True to replace."
58+
)
59+
path.write_text(json.dumps(demo.to_dict(), indent=2))
60+
return path
61+
62+
63+
def load_demo(path: Path) -> Demo:
64+
return Demo.from_dict(json.loads(Path(path).read_text()))
65+
66+
67+
def list_demos(root: Path = DEFAULT_STORE_ROOT) -> list[Demo]:
68+
root = Path(root)
69+
if not root.exists():
70+
return []
71+
return [load_demo(p) for p in sorted(root.glob("*.json"))]
72+
73+
74+
def _current_git_sha() -> str | None:
75+
"""Best-effort short HEAD sha of the project repo; None if unavailable."""
76+
try:
77+
out = subprocess.run(
78+
["git", "rev-parse", "--short", "HEAD"],
79+
capture_output=True,
80+
text=True,
81+
check=True,
82+
cwd=PROJECT_ROOT,
83+
timeout=2,
84+
)
85+
return out.stdout.strip() or None
86+
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
87+
return None
88+
89+
90+
def ingest_sequence(
91+
sequence_path: Path,
92+
task: str | None = None,
93+
root: Path = DEFAULT_STORE_ROOT,
94+
*,
95+
overwrite: bool = False,
96+
) -> Path:
97+
"""Wrap a segmenter-produced sequence JSON as a Demo and write it to the store.
98+
99+
The source JSON is expected to match the segmenter v2 output shape::
100+
101+
{"task": ..., "primitives": [...], "metadata": {"dataset", "episode", ...}}
102+
103+
``task`` overrides any task present in the source file. If neither is set,
104+
:class:`ValueError` is raised.
105+
"""
106+
sequence_path = Path(sequence_path)
107+
raw = json.loads(sequence_path.read_text())
108+
109+
effective_task = task if task is not None else raw.get("task")
110+
if not effective_task:
111+
raise ValueError(
112+
f"No task provided and none found in {sequence_path}. "
113+
f"Pass task=... to ingest_sequence()."
114+
)
115+
116+
src_meta = raw.get("metadata") or {}
117+
if "dataset" not in src_meta or "episode" not in src_meta:
118+
raise ValueError(
119+
f"Source sequence {sequence_path} is missing metadata.dataset or metadata.episode"
120+
)
121+
122+
try:
123+
src_rel = str(sequence_path.resolve().relative_to(PROJECT_ROOT))
124+
except ValueError:
125+
src_rel = str(sequence_path.resolve())
126+
127+
demo_meta = DemoMetadata(
128+
dataset=src_meta["dataset"],
129+
episode=int(src_meta["episode"]),
130+
density=src_meta.get("density"),
131+
start_ee_position=src_meta.get("start_ee_position"),
132+
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
133+
source_sequence_path=src_rel,
134+
segmenter_git_sha=_current_git_sha(),
135+
)
136+
primitives = [Primitive.from_dict(p) for p in raw.get("primitives", [])]
137+
demo = Demo(task=effective_task, primitives=primitives, metadata=demo_meta)
138+
return save_demo(demo, root, overwrite=overwrite)

0 commit comments

Comments
 (0)