mirror of
https://github.com/huggingface/lerobot.git
synced 2026-06-01 11:21:27 +00:00
* Add extensive language support * Address review: split persistent/event schemas, drop event timestamps - recipe.py: derive _VALID_ROLES/_VALID_STREAMS from MessageRole/MessageStream Literals - dataset_metadata.py: keep CODEBASE_VERSION at v3.0 - language.py: remove RESERVED_STYLES; split arrow/feature schemas into persistent (with timestamp) and event (without timestamp); add docstrings - language_render.py: events use frame-row timestamp implicitly; no per-event timestamp filtering or sorting - converters.py: drop unused subtask_key passthrough - add docstrings to new public APIs (recipe, render_messages_processor, collate) - update tests for split schemas; revert uv.lock Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add docstrings to all new helpers; revert uv.lock Covers private helpers in recipe.py, language.py, language_render.py, and render_messages_processor.py. Also reverts uv.lock to main (it was re-generated by `uv run` during local checks). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(language): add motion (persistent) and trace (event-only) styles Promote the previously-reserved motion/trace styles to first-class core styles. motion routes to language_persistent (it tracks robot state over time); trace routes to language_events (single-moment annotations). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(language): per-camera tagging on view-dependent styles Adds a nullable `camera` field to the language row struct (both persistent and event variants) so view-dependent styles like `vqa` can carry which `observation.images.*` view they were grounded against. Without this, multi-camera datasets ended up with multiple `(vqa, role)` rows at the same timestamp that the resolver could not disambiguate. - `language.py`: add `camera` to PERSISTENT_ROW_FIELDS / EVENT_ROW_FIELDS, to both Arrow struct types and the HF datasets feature mappings; introduce VIEW_DEPENDENT_STYLES = {vqa, motion, trace} plus `is_view_dependent_style` and `validate_camera_field` helpers (camera required iff style is view-dependent). - `language_render.py`: thread an optional `camera=` kwarg through every resolver (`active_at`, `emitted_at`, `nth_prev`, `nth_next`) and through `_matching_rows` / `_select_*`, so recipes can disambiguate per-camera VQA with `emitted_at(t, style=vqa, role=assistant, camera=...)`. Without a `camera` filter, multi-row matches keep raising the existing ambiguity error — which is the desired behaviour on multi-camera data. - `recipes/pi05_hirobot.yaml`: replace the single `ask_vqa` branch with `ask_vqa_top` and `ask_vqa_wrist` per-camera sub-recipes (each carrying the matching image block), keeping the original 0.20 budget and documenting the customization point for datasets with different cameras. - Tests: schema test asserts the new field order; new tests cover `is_view_dependent_style`, `validate_camera_field` (both required and forbidden directions), per-camera `emitted_at` filtering, and the ambiguity error when two cameras emit `(vqa, assistant)` at the same timestamp without a `camera=` filter. RenderMessagesStep + dataset passthrough fixtures updated to include the new field. - `docs/source/language_and_recipes.mdx`: document the `camera` field, the per-camera resolver pattern, and the canonical recipe convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(language): drop motion from VIEW_DEPENDENT_STYLES Motion primitives are described in robot-frame (joint / Cartesian) terms, not pixel space, so they are camera-agnostic. Only `vqa` (event) and `trace` (event, pixel-trajectory) are view-dependent. The `camera` field stays on PERSISTENT_ROW_FIELDS for schema symmetry — the validator, resolver, and HF feature mapping behave identically across the two columns regardless of which styles populate `camera` today — but persistent rows now always have `camera=None` in practice. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(language): task_aug style + automatic ${task} rephrasing rotation Adds task-prompt diversity (Xiao 2022 / CAST) without touching ``meta/tasks.parquet`` or forcing recipes to opt in. The plan reserved ``task_aug`` as a future style; this lands it now. - ``language.py``: add ``task_aug`` to ``CORE_STYLES`` and ``PERSISTENT_STYLES``. ``column_for_style("task_aug")`` returns ``language_persistent`` so PR 2 writers route it correctly. - ``language_render.py``: ``_resolve_task`` now consults the persistent slice for rows of ``style="task_aug", role="user"``. When any exist it picks one deterministically by ``sample_idx`` (blake2b-keyed, not Python's randomized hash) so an epoch sees every rephrasing of every episode while the same sample still resolves identically across reruns. Falls back to the canonical ``meta/tasks.parquet`` task when no rephrasings are present, so existing datasets and unannotated runs keep their behaviour. Explicit ``task=`` overrides still win. - Tests: rephrasing coverage across samples, determinism on repeat ``sample_idx``, fallback when persistent has no ``task_aug`` rows, and explicit override priority. Recipes get this for free: any ``${task}`` placeholder rotates through the available rephrasings. Recipes that want the literal canonical task can override the binding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(language): tool catalog in meta/info.json + LeRobotDatasetMetadata.tools Stores OpenAI-style function schemas at ``meta/info.json["tools"]`` so datasets can declare which tools are available (today: just ``say``; tomorrow: per-dataset extensions). The ``DEFAULT_TOOLS`` constant fills in for unannotated datasets so chat-template consumers don't have to special-case anything. Three pieces: - ``language.py``: ``SAY_TOOL_SCHEMA`` and ``DEFAULT_TOOLS`` constants. Single source of truth — PR 2's writer and PR 3's runtime tool registry will both import from here instead of duplicating the dict. - ``dataset_metadata.py``: ``LeRobotDatasetMetadata.tools`` property reads ``info.json["tools"]`` and falls back to ``DEFAULT_TOOLS``. Returns deep-copied dicts so callers can mutate the result safely. - ``docs/source/tools.mdx``: spec page covering the catalog, per-row invocations, and the three-step "how to add a new tool" workflow (declare schema, implement, register). Linked from the docs toctree under the Datasets section. This lays the groundwork for PR 2's pipeline writing the catalog out during annotation, and PR 3's ``src/lerobot/tools/`` package shipping runnable implementations (one file per tool — first up: ``say.py`` wrapping Kyutai's pocket-tts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Apply ruff and prettier formatting after merge Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(language): unify resolver dispatch and prune redundant test scaffolding * Drop the unused `events` kwarg from `active_at`/`nth_prev`/`nth_next`; only `emitted_at` actually consults events. The dispatcher in `_resolve_spec` now passes events conditionally. * Replace the dual `_persistent_sort_key`/`_event_sort_key` pair with a single `_row_sort_key` and drop the `sort_key` parameter from `_select_one`. Event rows lack `timestamp` (it is implicit in the frame) and now default to `0.0` for sort purposes — the `(style, role)` tiebreaker is unchanged. * Inline `_select_latest` into `active_at` (its only caller). * Collapse `emitted_at`'s dual-branch into one `_select_one` call. * Tighten `_validate_persistent_resolver` to a single `column_for_style(style) != LANGUAGE_PERSISTENT` check. * Parameterize `test_per_camera_blend_renders_both_views` over the two cameras and factor the sub-recipe builder into `_vqa_subrecipe` so the test no longer hand-rolls two near-identical recipe blocks. Net -98 LOC; behavior, public resolver names, and test expectations unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(language): always raise on ambiguous resolver matches `_select_one` previously skipped its ambiguity check whenever any of `role`/`tool_name`/`camera` was set, on the assumption that the caller had already pinned down a unique row. That left a real ambiguity hole for VQA: with two cameras emitting `(vqa, assistant)` at the same frame, `emitted_at(..., role="assistant")` silently picked the first sorted row instead of telling the recipe to add `camera=...`. The existing `test_emitted_at_raises_on_ambiguous_per_camera_vqa` test already encoded the desired behavior. Tighten the check: any time `len(rows) > 1` we now raise with the selectors echoed back, so users see exactly which fields they passed and that more is needed to disambiguate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: fix CI — collapse short ValueError to one line, refresh uv.lock * `ruff format` on CI (newer version) wants the short `camera=None` ValueError on a single line. * `uv.lock` was stale relative to `pyproject.toml`'s `datasets>=4.7.0` pin (and picked up upstream `s390x` marker fixes for cuda packages). CI runs `uv sync --locked` which rejected the divergence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(language): keep base install green — drop processor re-export, gate dataset-extra tests `lerobot.processor` re-exported `RenderMessagesStep` at the package level, so importing anything from `lerobot.processor` pulled in `lerobot.datasets.language` → `lerobot.datasets/__init__.py` → `require_package("datasets")`, which fails in the Tier 1 base install that intentionally omits the `[dataset]` extra. The chain bricked collection for unrelated suites (`tests/policies/pi0_pi05/...`, `tests/envs/...`, etc.). * Stop re-exporting `RenderMessagesStep` from `lerobot.processor`. The only consumer (the test) already imports from the submodule. Document the deliberate omission in the module docstring. * Add `pytest.importorskip("datasets", ...)` (and `pandas` where needed) at the top of the four PR-added tests that exercise the language stack: - tests/datasets/test_language.py - tests/datasets/test_language_render.py - tests/processor/test_render_messages_processor.py - tests/utils/test_collate.py Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(language): address review — tools accessor, motion docs, conditional collate * **`meta.tools` actually reads `info.json["tools"]`.** `DatasetInfo` had no `tools` field, so `from_dict` silently dropped the key (it warned about unknown fields then discarded them) and the property always returned `DEFAULT_TOOLS`. Added `tools: list[dict] | None` to the dataclass; `to_dict()` drops it when unset so existing datasets keep a clean `info.json`. Fixed the accessor to read `self.info.tools` (the previous `.get(...)` would have raised AttributeError on the dataclass anyway). Added regression tests: fallback when absent, round-trip from disk, and round-trip through `DatasetInfo.from_dict` / `to_dict`. * **`motion` is not view-dependent — fix the docs.** The mdx claimed rows of style `motion` must carry `camera`, but `VIEW_DEPENDENT_STYLES = {"vqa", "trace"}` and the validator agrees: motion primitives are joint/Cartesian-frame, not pixel-space. Updated both call-out paragraphs in `language_and_recipes.mdx`. * **Conditional `collate_fn` swap.** Added `meta.has_language_columns` and gate the `lerobot_collate_fn` swap in `lerobot_train.py` on it, so non-language datasets keep PyTorch's `default_collate`. Also added a pass-through test in `test_collate.py` that asserts on a plain tensor batch the custom collate matches `default_collate` key-for-key, plus a test for the `None`-sample drop path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: dedupe regex, centralize column names, harden collate, more tests * **#2 — dedupe `_PLACEHOLDER_RE`.** The same regex was compiled in `recipe.py` and `language_render.py`. Promote to module-level `PLACEHOLDER_RE` in `recipe.py` (its primary owner — declares template syntax) and import from `language_render.py`. * **#3 — centralize language column names.** `io_utils.py` had hardcoded `{"language_persistent", "language_events"}` literals at two sites. Replace with `LANGUAGE_COLUMNS` import so a future column rename can't silently desync. * **#4 — defensive collate preserved-keys.** `lerobot_collate_fn` silently filtered language fields from samples that didn't have them, which would hand downstream consumers a preserved list shorter than the tensor batch. Now: if any sample carries a key, every sample in the batch must carry it; otherwise raise a `ValueError` so the upstream rendering bug surfaces at the boundary. * **#5 — `_scalar` rejects non-singleton lists.** Previously a zero- or multi-element list fell through and triggered confusing `float([])` errors downstream. Now raises `ValueError` with the actual length. * **#6 — refactor `_extract_complementary_data`.** Replace 11 lines of `key = {... if ... else {}}` plus an 11-line splat dict with a single `_COMPLEMENTARY_KEYS` tuple iterated once. * **#7 — document `EXTENDED_STYLES`.** Was an empty `set()` with no comment. Add a docstring explaining it's an intentional extension point: downstream modules append project-local styles before `column_for_style` is called. * **#9 — `tools.mdx` notes the runtime layer is future work.** The page referenced `src/lerobot/tools/`, `registry.py`, and `get_tools(meta)` — none exist in this PR. Added a callout at the start of "How to add your own tool" plus a note on the implementations paragraph. * **#10 — tests for YAML round-trip, malformed rows, blend validation.** `test_recipe.py` grew from 1 case to 12 covering: blend-or-messages exclusivity, target-turn requirement, blend emptiness, weight presence/positivity, nested-blend rejection, `from_dict` with nested blends, `from_yaml` / `load_recipe` agreement, top-level non-mapping rejection. Added a malformed-row test for `_normalize_rows` that asserts non-dict entries raise `TypeError`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: emitted_at uses 0.1s tolerance; MessageTurn requires stream at construction * **Float tolerance in `emitted_at` for persistent styles.** The ``_timestamp(row) == t`` exact-equality check silently missed any caller that derived ``t`` arithmetically (e.g. ``frame_idx / fps``) even though the parquet timestamp would only differ by ULPs. Added ``EMITTED_AT_TOLERANCE_S = 0.1`` and check ``abs(...) <= tolerance`` instead, with a docstring explaining why exact equality wasn't enough and why 0.1 s is safe at typical 30–100 Hz control rates. Test asserts the new behavior at half-window (matches) and double-window (no match) using the constant so it stays in sync. * **`MessageTurn.stream` is required at construction.** It was typed ``MessageStream | None = None`` so YAML could omit ``stream:`` and pass the dataclass invariant — but ``_validate_rendered`` rejected ``None`` streams later, surfacing the error at the first sample instead of at recipe load. Now ``__post_init__`` raises ``ValueError`` if ``stream`` is ``None``, with the list of valid streams in the message. The redundant late-stage check in ``_validate_rendered`` is replaced with a one-line comment that cites the upstream invariant. Test pins the new construction-time rejection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(tools): drop follow-up-PR references Reword the two callouts in `tools.mdx` to describe the runtime layer in present tense ("not part of the catalog layer shipped today", "those modules don't yet exist in the tree") instead of pointing at a specific follow-up PR. Keeps the doc honest about what works now without coupling it to a particular release order. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: address CarolinePascal feedback - language timestamps: float64 -> float32 to match LeRobotDataset frame timestamps (Arrow struct + HF feature) - dataset_metadata: hoist `.language` imports to module top — language.py has no lerobot imports, so there is no circular-import risk - dataset_metadata: add a `meta.tools` setter that persists the catalog to info.json and reloads `meta.info` - feature_utils: validate the `language` dtype instead of returning "" — warn (non-fatal) when a non-empty value is written at record time - centralize the scalar-unwrap helper as `lerobot.utils.utils.unwrap_scalar`, shared by render_messages_processor and language_render - docs: move `## Layer 2 — recipe anatomy` ahead of the resolver sections, which describe recipe bindings rather than dataset layout - language_render: note in EMITTED_AT_TOLERANCE_S that persistent rows change on a human-action timescale, not the camera frame rate Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
546 lines
19 KiB
Python
546 lines
19 KiB
Python
#!/usr/bin/env python
|
||
|
||
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
|
||
#
|
||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||
# you may not use this file except in compliance with the License.
|
||
# You may obtain a copy of the License at
|
||
#
|
||
# http://www.apache.org/licenses/LICENSE-2.0
|
||
#
|
||
# Unless required by applicable law or agreed to in writing, software
|
||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
# See the License for the specific language governing permissions and
|
||
# limitations under the License.
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import hashlib
|
||
import re
|
||
from collections.abc import Sequence
|
||
from typing import Any
|
||
|
||
from lerobot.configs.recipe import DEFAULT_BINDINGS, PLACEHOLDER_RE, TrainingRecipe
|
||
from lerobot.utils.utils import unwrap_scalar
|
||
|
||
from .language import LANGUAGE_PERSISTENT, column_for_style
|
||
|
||
LanguageRow = dict[str, Any]
|
||
RenderedMessages = dict[str, list[Any]]
|
||
|
||
_RESOLVER_RE = re.compile(r"^(?P<name>[A-Za-z_][A-Za-z0-9_]*)\((?P<args>.*)\)$")
|
||
|
||
|
||
def active_at(
|
||
t: float,
|
||
*,
|
||
persistent: Sequence[LanguageRow],
|
||
style: str | None = None,
|
||
role: str | None = None,
|
||
tool_name: str | None = None,
|
||
camera: str | None = None,
|
||
) -> LanguageRow | None:
|
||
"""Return the persistent row of ``style`` that is active at time ``t``.
|
||
|
||
A persistent row is "active" at ``t`` when its own ``timestamp`` is the
|
||
most recent one ``<= t`` for the given ``style``/``role``/``tool_name``/
|
||
``camera`` selector. Only valid for persistent styles.
|
||
"""
|
||
_validate_persistent_resolver("active_at", style)
|
||
matches = [
|
||
row
|
||
for row in _matching_rows(persistent, style=style, role=role, tool_name=tool_name, camera=camera)
|
||
if _timestamp(row) <= t
|
||
]
|
||
if not matches:
|
||
return None
|
||
latest_ts = max(_timestamp(row) for row in matches)
|
||
return _select_one(
|
||
[row for row in matches if _timestamp(row) == latest_ts],
|
||
style=style,
|
||
role=role,
|
||
tool_name=tool_name,
|
||
camera=camera,
|
||
)
|
||
|
||
|
||
EMITTED_AT_TOLERANCE_S = 0.1
|
||
"""Half-window for matching persistent rows to a frame timestamp in
|
||
``emitted_at``. Persistent timestamps come from parquet (float32) and ``t``
|
||
is also a float32 from parquet, so in the ideal hot path an exact match
|
||
would suffice — but any caller that derives ``t`` arithmetically (e.g.
|
||
``frame_idx / fps``) breaks bit-equality. A 0.1 s tolerance covers
|
||
common arithmetic drift without admitting frames that are visibly far
|
||
apart at typical control rates (30–100 Hz). This does mean two persistent
|
||
rows of the same selector emitted within 0.1 s of each other cannot be
|
||
told apart by ``emitted_at`` — acceptable because persistent annotations
|
||
(subtask / plan / memory transitions) change on a human-action timescale,
|
||
not at the camera frame rate."""
|
||
|
||
|
||
def emitted_at(
|
||
t: float,
|
||
*,
|
||
persistent: Sequence[LanguageRow],
|
||
events: Sequence[LanguageRow],
|
||
style: str | None = None,
|
||
role: str | None = None,
|
||
tool_name: str | None = None,
|
||
camera: str | None = None,
|
||
) -> LanguageRow | None:
|
||
"""Return the row of ``style`` emitted at exactly time ``t``.
|
||
|
||
For persistent styles, this matches persistent rows whose own ``timestamp``
|
||
is within ``EMITTED_AT_TOLERANCE_S`` of ``t`` (see that constant for why
|
||
we use a tolerance instead of bit-equality). For event styles, the
|
||
``events`` list is assumed to come from the dataset row at frame ``t``
|
||
(event rows carry no timestamp of their own), so all matching event rows
|
||
are considered emitted at ``t``. ``camera`` filters by the row's
|
||
``camera`` field — required to disambiguate when multiple view-dependent
|
||
rows share ``(t, role)`` across cameras.
|
||
"""
|
||
if column_for_style(style) == LANGUAGE_PERSISTENT:
|
||
matches = [
|
||
row
|
||
for row in _matching_rows(persistent, style=style, role=role, tool_name=tool_name, camera=camera)
|
||
if abs(_timestamp(row) - t) <= EMITTED_AT_TOLERANCE_S
|
||
]
|
||
else:
|
||
matches = _matching_rows(events, style=style, role=role, tool_name=tool_name, camera=camera)
|
||
return _select_one(matches, style=style, role=role, tool_name=tool_name, camera=camera)
|
||
|
||
|
||
def nth_prev(
|
||
t: float,
|
||
*,
|
||
persistent: Sequence[LanguageRow],
|
||
style: str | None = None,
|
||
offset: int = 1,
|
||
role: str | None = None,
|
||
tool_name: str | None = None,
|
||
camera: str | None = None,
|
||
) -> LanguageRow | None:
|
||
"""Return the persistent row that was active ``offset`` steps before ``t``.
|
||
|
||
Walks back through chronologically sorted persistent rows of ``style``
|
||
(filtered by optional ``role``/``tool_name``/``camera``) and returns the
|
||
one ``offset`` positions before the row active at ``t``. Only valid for
|
||
persistent styles.
|
||
"""
|
||
return _nth_relative("nth_prev", t, persistent, style, -offset, role, tool_name, camera)
|
||
|
||
|
||
def nth_next(
|
||
t: float,
|
||
*,
|
||
persistent: Sequence[LanguageRow],
|
||
style: str | None = None,
|
||
offset: int = 1,
|
||
role: str | None = None,
|
||
tool_name: str | None = None,
|
||
camera: str | None = None,
|
||
) -> LanguageRow | None:
|
||
"""Return the persistent row that becomes active ``offset`` steps after ``t``.
|
||
|
||
Walks forward through chronologically sorted persistent rows of ``style``
|
||
(filtered by optional ``role``/``tool_name``/``camera``) and returns the
|
||
one ``offset`` positions after the row active at ``t``. Only valid for
|
||
persistent styles.
|
||
"""
|
||
return _nth_relative("nth_next", t, persistent, style, offset, role, tool_name, camera)
|
||
|
||
|
||
def render_sample(
|
||
*,
|
||
recipe: TrainingRecipe,
|
||
persistent: Sequence[LanguageRow] | None,
|
||
events: Sequence[LanguageRow] | None,
|
||
t: float,
|
||
sample_idx: int,
|
||
task: str | None = None,
|
||
dataset_ctx: Any | None = None,
|
||
) -> RenderedMessages | None:
|
||
"""Render the chat-style messages for a single dataset sample.
|
||
|
||
Resolves the recipe's bindings against ``persistent`` and ``events`` rows
|
||
at frame timestamp ``t``, then expands the recipe's message templates.
|
||
Returns ``None`` if the resolved sample contains no target message.
|
||
"""
|
||
persistent_rows = _normalize_rows(persistent or [])
|
||
event_rows = _normalize_rows(events or [])
|
||
selected_recipe = _select_recipe(recipe, sample_idx)
|
||
bindings = _resolve_bindings(
|
||
selected_recipe,
|
||
persistent=persistent_rows,
|
||
events=event_rows,
|
||
t=t,
|
||
sample_idx=sample_idx,
|
||
task=task,
|
||
dataset_ctx=dataset_ctx,
|
||
)
|
||
return _render_message_recipe(selected_recipe, bindings)
|
||
|
||
|
||
def _select_recipe(recipe: TrainingRecipe, sample_idx: int) -> TrainingRecipe:
|
||
"""Pick a deterministic blend component for ``sample_idx`` (or return ``recipe``)."""
|
||
if recipe.blend is None:
|
||
return recipe
|
||
|
||
total_weight = sum(component.weight or 0.0 for component in recipe.blend.values())
|
||
if total_weight <= 0:
|
||
raise ValueError("Blend weights must sum to a positive value.")
|
||
|
||
digest = hashlib.blake2b(str(sample_idx).encode(), digest_size=8).digest()
|
||
draw = int.from_bytes(digest, "big") / 2**64 * total_weight
|
||
cumulative = 0.0
|
||
last_component: TrainingRecipe | None = None
|
||
for component in recipe.blend.values():
|
||
last_component = component
|
||
cumulative += component.weight or 0.0
|
||
if draw < cumulative:
|
||
return component
|
||
assert last_component is not None
|
||
return last_component
|
||
|
||
|
||
def _resolve_bindings(
|
||
recipe: TrainingRecipe,
|
||
*,
|
||
persistent: Sequence[LanguageRow],
|
||
events: Sequence[LanguageRow],
|
||
t: float,
|
||
sample_idx: int,
|
||
task: str | None,
|
||
dataset_ctx: Any | None,
|
||
) -> dict[str, LanguageRow | str | None]:
|
||
"""Resolve every binding in ``recipe`` (plus ``task``) at time ``t``."""
|
||
bindings: dict[str, LanguageRow | str | None] = {
|
||
"task": _resolve_task(task, dataset_ctx, persistent=persistent, sample_idx=sample_idx),
|
||
}
|
||
specs = {**DEFAULT_BINDINGS, **(recipe.bindings or {})}
|
||
for name, spec in specs.items():
|
||
bindings[name] = _resolve_spec(spec, persistent=persistent, events=events, t=t)
|
||
return bindings
|
||
|
||
|
||
def _resolve_task(
|
||
task: str | None,
|
||
dataset_ctx: Any | None,
|
||
*,
|
||
persistent: Sequence[LanguageRow] = (),
|
||
sample_idx: int = 0,
|
||
) -> str | None:
|
||
"""Return the task string for ``sample_idx``.
|
||
|
||
Resolution order:
|
||
|
||
1. Explicit ``task`` override (caller-supplied) wins.
|
||
2. If ``persistent`` contains rows of style ``task_aug`` (role=user),
|
||
deterministically pick one by ``sample_idx`` so each frame of an
|
||
episode rotates through the available rephrasings across an epoch.
|
||
This realizes Xiao 2022 / CAST-style task-prompt diversity without
|
||
changing ``meta/tasks.parquet`` and without forcing recipes to opt
|
||
in: ``${task}`` automatically picks a rephrasing when one exists,
|
||
and falls back to the canonical task otherwise. Recipes that want
|
||
the literal canonical task can override the binding.
|
||
3. Otherwise read the canonical task from ``dataset_ctx`` (which is
|
||
backed by ``meta/tasks.parquet``).
|
||
"""
|
||
if task is not None:
|
||
return task
|
||
|
||
aug_rows = [r for r in persistent if r.get("style") == "task_aug" and r.get("role") == "user"]
|
||
if aug_rows:
|
||
# Deterministic, blake2b-based pick keyed on sample_idx so the
|
||
# rotation is reproducible across runs (Python's built-in ``hash``
|
||
# is process-randomized).
|
||
digest = hashlib.blake2b(f"task_aug:{sample_idx}".encode(), digest_size=8).digest()
|
||
idx = int.from_bytes(digest, "big") % len(aug_rows)
|
||
chosen = aug_rows[idx].get("content")
|
||
if chosen:
|
||
return str(chosen)
|
||
|
||
if dataset_ctx is None:
|
||
return None
|
||
if isinstance(dataset_ctx, dict):
|
||
return dataset_ctx.get("task")
|
||
return getattr(dataset_ctx, "task", None)
|
||
|
||
|
||
def _resolve_spec(
|
||
spec: str,
|
||
*,
|
||
persistent: Sequence[LanguageRow],
|
||
events: Sequence[LanguageRow],
|
||
t: float,
|
||
) -> LanguageRow | None:
|
||
"""Parse a single binding's resolver expression and dispatch to its function."""
|
||
match = _RESOLVER_RE.match(spec.strip())
|
||
if match is None:
|
||
raise ValueError(f"Invalid resolver expression: {spec!r}")
|
||
name = match.group("name")
|
||
kwargs = _parse_resolver_args(match.group("args"))
|
||
kwargs.pop("t_arg", None)
|
||
|
||
if name == "emitted_at":
|
||
return emitted_at(t, persistent=persistent, events=events, **kwargs)
|
||
if name == "active_at":
|
||
return active_at(t, persistent=persistent, **kwargs)
|
||
if name == "nth_prev":
|
||
return nth_prev(t, persistent=persistent, **kwargs)
|
||
if name == "nth_next":
|
||
return nth_next(t, persistent=persistent, **kwargs)
|
||
raise ValueError(f"Unknown language resolver: {name!r}")
|
||
|
||
|
||
def _parse_resolver_args(args: str) -> dict[str, Any]:
|
||
"""Parse a comma-separated resolver argument list into a kwargs dict."""
|
||
kwargs: dict[str, Any] = {}
|
||
if not args.strip():
|
||
return kwargs
|
||
|
||
parts = [part.strip() for part in args.split(",") if part.strip()]
|
||
for part in parts:
|
||
if part == "t":
|
||
kwargs["t_arg"] = True
|
||
continue
|
||
if "=" not in part:
|
||
raise ValueError(f"Invalid resolver argument: {part!r}")
|
||
key, value = (item.strip() for item in part.split("=", 1))
|
||
if key == "offset":
|
||
kwargs[key] = int(value)
|
||
else:
|
||
kwargs[key] = value.strip("\"'")
|
||
return kwargs
|
||
|
||
|
||
def _render_message_recipe(
|
||
recipe: TrainingRecipe,
|
||
bindings: dict[str, LanguageRow | str | None],
|
||
) -> RenderedMessages | None:
|
||
"""Expand ``recipe.messages`` into rendered chat messages using ``bindings``."""
|
||
assert recipe.messages is not None
|
||
messages: list[dict[str, Any]] = []
|
||
streams: list[str | None] = []
|
||
target_indices: list[int] = []
|
||
|
||
for turn in recipe.messages:
|
||
if turn.if_present is not None and bindings.get(turn.if_present) is None:
|
||
continue
|
||
|
||
message = {"role": turn.role}
|
||
if turn.content is not None:
|
||
message["content"] = _render_content(turn.content, bindings)
|
||
|
||
if turn.tool_calls_from is not None:
|
||
row = bindings.get(turn.tool_calls_from)
|
||
tool_calls = row.get("tool_calls") if isinstance(row, dict) else None
|
||
if tool_calls:
|
||
message["tool_calls"] = copy.deepcopy(tool_calls)
|
||
|
||
message_idx = len(messages)
|
||
messages.append(message)
|
||
streams.append(turn.stream)
|
||
if turn.target:
|
||
target_indices.append(message_idx)
|
||
|
||
if not target_indices:
|
||
return None
|
||
|
||
rendered = {
|
||
"messages": messages,
|
||
"message_streams": streams,
|
||
"target_message_indices": target_indices,
|
||
}
|
||
_validate_rendered(rendered)
|
||
return rendered
|
||
|
||
|
||
def _render_content(
|
||
content: str | list[dict[str, Any]],
|
||
bindings: dict[str, LanguageRow | str | None],
|
||
) -> str | list[dict[str, Any]]:
|
||
"""Substitute bindings into a string or each string field of multimodal blocks."""
|
||
if isinstance(content, str):
|
||
return _substitute(content, bindings)
|
||
|
||
rendered_blocks = []
|
||
for block in content:
|
||
rendered_block = copy.deepcopy(block)
|
||
for key, value in rendered_block.items():
|
||
if isinstance(value, str):
|
||
rendered_block[key] = _substitute(value, bindings)
|
||
rendered_blocks.append(rendered_block)
|
||
return rendered_blocks
|
||
|
||
|
||
def _substitute(template: str, bindings: dict[str, LanguageRow | str | None]) -> str:
|
||
"""Replace ``${name}`` placeholders in ``template`` with their bound values."""
|
||
|
||
def replace(match: re.Match[str]) -> str:
|
||
"""Resolve a single ``${name}`` match to its bound string value."""
|
||
name = match.group(1)
|
||
if name not in bindings:
|
||
raise ValueError(f"Unknown template binding: {name!r}")
|
||
value = bindings[name]
|
||
if value is None:
|
||
return ""
|
||
if isinstance(value, dict):
|
||
content = value.get("content")
|
||
return "" if content is None else str(content)
|
||
return str(value)
|
||
|
||
return PLACEHOLDER_RE.sub(replace, template)
|
||
|
||
|
||
def _validate_rendered(rendered: RenderedMessages) -> None:
|
||
"""Sanity-check the rendered output for stream/target alignment."""
|
||
messages = rendered["messages"]
|
||
streams = rendered["message_streams"]
|
||
target_indices = rendered["target_message_indices"]
|
||
|
||
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.")
|
||
for idx in target_indices:
|
||
if idx < 0 or idx >= len(messages):
|
||
raise ValueError(f"Target message index {idx} is out of bounds.")
|
||
# ``stream`` is enforced non-None at MessageTurn construction time
|
||
# (see ``MessageTurn.__post_init__``), so a missing stream here would
|
||
# mean the dataclass invariant was bypassed; no need to re-check.
|
||
|
||
|
||
def _nth_relative(
|
||
name: str,
|
||
t: float,
|
||
persistent: Sequence[LanguageRow],
|
||
style: str | None,
|
||
offset: int,
|
||
role: str | None,
|
||
tool_name: str | None,
|
||
camera: str | None,
|
||
) -> LanguageRow | None:
|
||
"""Shared body for ``nth_prev`` / ``nth_next`` with signed ``offset``."""
|
||
_validate_persistent_resolver(name, style)
|
||
if abs(offset) < 1:
|
||
raise ValueError(f"{name} offset must be non-zero.")
|
||
|
||
rows = sorted(
|
||
_matching_rows(persistent, style=style, role=role, tool_name=tool_name, camera=camera),
|
||
key=_row_sort_key,
|
||
)
|
||
if not rows:
|
||
return None
|
||
|
||
anchor_idx = None
|
||
for idx, row in enumerate(rows):
|
||
if _timestamp(row) <= t:
|
||
anchor_idx = idx
|
||
else:
|
||
break
|
||
|
||
target_idx = (offset - 1 if offset > 0 else None) if anchor_idx is None else anchor_idx + offset
|
||
|
||
if target_idx is None or target_idx < 0 or target_idx >= len(rows):
|
||
return None
|
||
return rows[target_idx]
|
||
|
||
|
||
def _validate_persistent_resolver(name: str, style: str | None) -> None:
|
||
"""Reject calls with missing or event-only ``style`` for persistent resolvers."""
|
||
if style is None:
|
||
raise ValueError(f"{name} requires a persistent style.")
|
||
if column_for_style(style) != LANGUAGE_PERSISTENT:
|
||
raise ValueError(f"{name} cannot be used with event-only style {style!r}.")
|
||
|
||
|
||
def _matching_rows(
|
||
rows: Sequence[LanguageRow],
|
||
*,
|
||
style: str | None,
|
||
role: str | None,
|
||
tool_name: str | None,
|
||
camera: str | None,
|
||
) -> list[LanguageRow]:
|
||
"""Return ``rows`` filtered by optional ``style``/``role``/``tool_name``/``camera`` selectors."""
|
||
return [
|
||
row
|
||
for row in rows
|
||
if (style is None or row.get("style") == style)
|
||
and (role is None or row.get("role") == role)
|
||
and (tool_name is None or _row_has_tool_name(row, tool_name))
|
||
and (camera is None or row.get("camera") == camera)
|
||
]
|
||
|
||
|
||
def _select_one(
|
||
rows: Sequence[LanguageRow],
|
||
*,
|
||
style: str | None,
|
||
role: str | None,
|
||
tool_name: str | None,
|
||
camera: str | None,
|
||
) -> LanguageRow | None:
|
||
"""Return the single matching row, or raise if the resolver is ambiguous.
|
||
|
||
Multiple matches always raise — even when the caller already passed
|
||
some selectors — because remaining ambiguity means the data has
|
||
several rows that look identical to the resolver and the caller
|
||
needs to pin down a specific one (e.g. add ``camera=...`` for VQA
|
||
rows shared across cameras).
|
||
"""
|
||
if not rows:
|
||
return None
|
||
if len(rows) > 1:
|
||
raise ValueError(
|
||
f"Ambiguous resolver for style={style!r} role={role!r} "
|
||
f"tool_name={tool_name!r} camera={camera!r}: {len(rows)} matching rows. "
|
||
f"Add a selector that distinguishes them."
|
||
)
|
||
return rows[0]
|
||
|
||
|
||
def _row_sort_key(row: LanguageRow) -> tuple[float, str, str]:
|
||
"""Stable sort key for both persistent and event rows.
|
||
|
||
Event rows lack ``timestamp`` (it is implicit in the frame), so default
|
||
to ``0.0`` — within a single frame all event rows share the same sort
|
||
bucket and are tiebroken by ``(style, role)``.
|
||
"""
|
||
timestamp = row.get("timestamp")
|
||
ts = float(unwrap_scalar(timestamp)) if timestamp is not None else 0.0
|
||
return (ts, row.get("style") or "", row.get("role") or "")
|
||
|
||
|
||
def _timestamp(row: LanguageRow) -> float:
|
||
"""Extract a row's ``timestamp`` as a Python float (unwrapping numpy scalars)."""
|
||
return float(unwrap_scalar(row["timestamp"]))
|
||
|
||
|
||
def _row_has_tool_name(row: LanguageRow, tool_name: str) -> bool:
|
||
"""Return ``True`` if any of the row's tool calls invokes ``tool_name``."""
|
||
for tool_call in row.get("tool_calls") or []:
|
||
if isinstance(tool_call, str):
|
||
continue
|
||
function = tool_call.get("function") if isinstance(tool_call, dict) else None
|
||
if isinstance(function, dict) and function.get("name") == tool_name:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _normalize_rows(rows: Sequence[Any]) -> list[LanguageRow]:
|
||
"""Convert pyarrow scalars / mappings into a fresh list of plain dict rows."""
|
||
normalized = []
|
||
for row in rows:
|
||
if row is None:
|
||
continue
|
||
if hasattr(row, "as_py"):
|
||
row = row.as_py()
|
||
if not isinstance(row, dict):
|
||
raise TypeError(f"Language rows must be dictionaries, got {type(row).__name__}.")
|
||
normalized.append(dict(row))
|
||
return normalized
|