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"]})