From bf996c79385e80f285ce32bd8d3bb56b795711a2 Mon Sep 17 00:00:00 2001 From: Pepijn Date: Sun, 17 May 2026 13:20:39 +0200 Subject: [PATCH] fix(datasets): render flow-only low_level recipes instead of dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recipe whose only supervision is the action-expert flow loss (e.g. `low_level_execution`: `user(${subtask})` with `stream: low_level` and no `target` turn) was rejected at render time by `_render_message_recipe` and `_validate_rendered`, both of which required at least one target turn. The result: every blend draw of the flow-only recipe rendered to `None`, `predict_actions` was never set, `run_flow` never fired, and the action expert received no flow loss — leaving it at random init. Both gates now also accept a `low_level`-stream turn as valid supervision. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lerobot/datasets/language_render.py | 19 ++++++++++++--- tests/datasets/test_language_render.py | 32 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/lerobot/datasets/language_render.py b/src/lerobot/datasets/language_render.py index 1f4ed2749..42206907c 100644 --- a/src/lerobot/datasets/language_render.py +++ b/src/lerobot/datasets/language_render.py @@ -376,7 +376,15 @@ def _render_message_recipe( if turn.target: target_indices.append(message_idx) - if not target_indices: + # A render is meaningful if it supervises *something*: either a + # text-CE target turn, or a ``low_level`` stream turn (flow / action + # supervision — e.g. the flow-only ``low_level_execution`` recipe, + # ``user(${subtask})`` with ``stream: low_level`` and no target). + # Without this, a flow-only recipe renders to ``None`` every time + # the blend draws it → ``predict_actions`` is never True → the + # action expert never receives a flow loss. + has_low_level = any(stream == "low_level" for stream in streams) + if not target_indices and not has_low_level: return None rendered = { @@ -433,8 +441,13 @@ def _validate_rendered(rendered: RenderedMessages) -> None: if len(streams) != len(messages): raise ValueError("message_streams must be aligned with messages.") - if not target_indices: - raise ValueError("Rendered samples must contain at least one target message.") + # Valid iff it supervises something: a text-CE target turn OR a + # ``low_level`` stream turn (flow / action supervision). + if not target_indices and not any(s == "low_level" for s in streams): + raise ValueError( + "Rendered samples must contain a target message or a " + "low_level-stream message." + ) for idx in target_indices: if idx < 0 or idx >= len(messages): raise ValueError(f"Target message index {idx} is out of bounds.") diff --git a/tests/datasets/test_language_render.py b/tests/datasets/test_language_render.py index a7bc026ca..e604ede4a 100644 --- a/tests/datasets/test_language_render.py +++ b/tests/datasets/test_language_render.py @@ -370,6 +370,38 @@ def test_resolve_task_explicit_override_beats_rephrasings(): assert rendered["messages"][0]["content"] == "explicit override wins" +def test_flow_only_low_level_recipe_renders_without_target(): + """Regression: a flow-only ``low_level`` recipe has no ``target`` turn — + its supervision is the action-expert flow loss, not text-CE. It must + still render (not ``None``), otherwise every blend draw of it is dropped + and the action expert never receives a flow loss.""" + recipe = TrainingRecipe( + messages=[ + MessageTurn( + role="user", + content="${subtask}", + stream="low_level", + if_present="subtask", + ), + ], + bindings={"subtask": "active_at(t, style=subtask)"}, + ) + + rendered = render_sample( + recipe=recipe, + persistent=PERSISTENT, + events=[], + t=0.5, + sample_idx=0, + task="clean kitchen", + ) + + assert rendered is not None + assert rendered["messages"] == [{"role": "user", "content": "subtask 0"}] + assert rendered["message_streams"] == ["low_level"] + assert rendered["target_message_indices"] == [] + + def test_canonical_recipe_can_render_low_level_branch(): recipe = TrainingRecipe.from_yaml(Path("src/lerobot/configs/recipes/pi05_hirobot.yaml")) low_level = TrainingRecipe(blend={"low": recipe.blend["low_level_execution"]})