Skip to content

Commit bbfd8ce

Browse files
authored
fix(finetune): use LeRobot policy path selector (#310)
1 parent 16df9ad commit bbfd8ce

5 files changed

Lines changed: 95 additions & 32 deletions

File tree

scripts/modal_act_from_scratch_smoke.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
confirm:
1313
1. The tether install pulls all the new code (config + run + cli).
1414
2. _build_lerobot_command produces a valid lerobot-train invocation
15-
with --policy.type=act, no --policy.pretrained_path, --policy.chunk_size=N.
15+
with --policy.type=act, no pretrained policy path, --policy.chunk_size=N.
1616
3. lerobot-train accepts the args and starts training.
1717
4. A checkpoint actually lands at the expected path.
1818
@@ -166,7 +166,12 @@ def act_smoke(
166166
# Sanity check the construction matches Phase 4 expectations:
167167
cmd_str = " ".join(cmd)
168168
expected_in_cmd = ["--policy.type=act", "--policy.chunk_size=31"]
169-
forbidden_in_cmd = ["--policy.pretrained_path", "--peft.method_type"]
169+
forbidden_in_cmd = [
170+
"--policy.path",
171+
"--policy.pretrained_path",
172+
"--policy.pretrained_model_path",
173+
"--peft.method_type",
174+
]
170175
construction_ok = (
171176
all(s in cmd_str for s in expected_in_cmd)
172177
and not any(s in cmd_str for s in forbidden_in_cmd)

src/tether/finetune/cli.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ def finetune_command(
2828
policy: str = typer.Option(
2929
"auto",
3030
"--policy",
31-
help="Policy class. 'auto' (default) infers from --base. Set explicitly "
32-
"(e.g. 'act') for from-scratch training. Per ADR 2026-05-06.",
31+
help="Policy class. 'auto' (default) loads the config from --base. "
32+
"Set explicitly (e.g. 'act') for from-scratch training. "
33+
"Per ADR 2026-05-06.",
3334
),
3435
chunk_size: int = typer.Option(
3536
50,

src/tether/finetune/config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@ class FinetuneConfig:
4343

4444
policy: str = "auto"
4545
"""Policy class to train.
46-
- 'auto' (default): infer from `base` (e.g. lerobot/smolvla_base → smolvla).
46+
- 'auto' (default): load policy config + weights from `base`.
4747
- 'act' / 'diffusion' / 'pi0' / etc.: explicit lerobot policy.type.
48-
Used by from-scratch training paths where `base` is empty or a non-pretrained
49-
sentinel; lerobot-train requires --policy.type either way.
48+
Explicit types are used by from-scratch training paths where `base` is empty.
49+
Pretrained paths use LeRobot's --policy.path and must not also pass policy.type.
5050
5151
ACT-from-scratch (vendored from auto_soarm 2026-05-06): set policy='act',
5252
mode='full', leave base='' (or set to ''). Recipe defaults:

src/tether/finetune/run.py

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,11 @@ def _validate_config(cfg: FinetuneConfig) -> list[str]:
8181

8282

8383
def _infer_policy_type(base: str) -> str:
84-
"""Derive lerobot's policy registry name from the base model id.
84+
"""Derive LeRobot's policy registry name from a model id.
8585
86-
lerobot 0.5.1 registers policies by short name (smolvla, pi0, pi05,
87-
act, diffusion, vqbet, ...) and requires `--policy.type=<name>` at
88-
CLI time. The full HF model id (e.g. 'lerobot/smolvla_base') goes
89-
to `--policy.pretrained_model_path=...` separately.
90-
91-
Falls back to raising a clear error for unrecognized bases rather
92-
than guessing. Customers can override via extra_lerobot_args={"policy.type": "..."}.
86+
This metadata helper is retained for callers that need a short policy
87+
name. It is deliberately not used to select a pretrained policy on the
88+
LeRobot 0.5.1 CLI: ``--policy.path`` loads the type from the checkpoint.
9389
"""
9490
base_lower = base.lower()
9591
if "smolvla" in base_lower:
@@ -102,9 +98,8 @@ def _infer_policy_type(base: str) -> str:
10298
# lerobot 0.5.1 can't load N1.6 per prior Step-3 finding; v0.6 work.
10399
return "gr00t_n1_5"
104100
raise ValueError(
105-
f"Could not infer --policy.type from base={base!r}. "
106-
f"Supported in v0.3: lerobot/smolvla_base. For other bases, "
107-
f"pass policy.type explicitly via extra_lerobot_args."
101+
f"Could not infer policy type from base={base!r}. "
102+
"Supported identifiers include smolvla, pi0, pi05, and gr00t."
108103
)
109104

110105

@@ -121,25 +116,42 @@ def _build_lerobot_command(cfg: FinetuneConfig) -> list[str]:
121116
customers can reproduce manually.
122117
123118
Key arg names per lerobot 0.5.1:
124-
--policy.pretrained_model_path (NOT --policy.path)
119+
--policy.path (pretrained checkpoint)
120+
--policy.type (from-scratch policy only)
125121
--optimizer.lr (NOT --policy.optimizer_lr)
126122
--peft.method_type=lora (enables PEFT)
127123
--peft.r (LoRA rank)
128124
125+
LeRobot's parser rejects using ``--policy.path`` and ``--policy.type``
126+
together. A pretrained command therefore selects the policy only by
127+
checkpoint path; LeRobot loads the policy type from that checkpoint's
128+
config. From-scratch commands select the policy only by type.
129+
129130
cfg.precision is intentionally NOT passed through — lerobot 0.5.1
130131
doesn't expose a top-level precision flag; it's baked into the
131132
policy config. v0.5 will add per-policy precision overrides.
132133
"""
133-
# draccus requires `policy.type` to select which PreTrainedConfig
134-
# subclass to decode into. Use explicit cfg.policy when set, else
135-
# infer from the base-model id.
136134
explicit_policy = getattr(cfg, "policy", "auto")
137-
if explicit_policy != "auto":
138-
policy_type = explicit_policy
139-
else:
140-
policy_type = _infer_policy_type(cfg.base)
141135
is_from_scratch = (explicit_policy != "auto" and cfg.mode == "full")
142136

137+
# These select the policy and must be emitted exactly once by this
138+
# builder. In particular, LeRobot 0.5.1 rejects policy.path + policy.type,
139+
# while the older pretrained_* spellings are not valid selection flags.
140+
reserved_policy_args = {
141+
"policy.path",
142+
"policy.type",
143+
"policy.pretrained_path",
144+
"policy.pretrained_model_path",
145+
}
146+
conflicting_args = reserved_policy_args.intersection(cfg.extra_lerobot_args)
147+
if conflicting_args:
148+
names = ", ".join(sorted(conflicting_args))
149+
raise ValueError(
150+
f"extra_lerobot_args cannot override policy selection ({names}); "
151+
"use FinetuneConfig.base for pretrained policies or "
152+
"FinetuneConfig.policy for from-scratch policies"
153+
)
154+
143155
# lerobot-train wants to OWN its output_dir (errors if pre-existing
144156
# and resume=False). We keep cfg.output as the tether orchestration
145157
# root, and give lerobot a subdirectory it creates fresh on each run.
@@ -153,7 +165,6 @@ def _build_lerobot_command(cfg: FinetuneConfig) -> list[str]:
153165

154166
cmd = [
155167
"lerobot-train",
156-
f"--policy.type={policy_type}",
157168
f"--policy.repo_id={repo_id}",
158169
f"--policy.push_to_hub=false",
159170
f"--dataset.repo_id={cfg.dataset}",
@@ -163,9 +174,11 @@ def _build_lerobot_command(cfg: FinetuneConfig) -> list[str]:
163174
f"--optimizer.lr={cfg.learning_rate}",
164175
f"--seed={cfg.seed}",
165176
]
166-
if not is_from_scratch:
167-
# Pretrained-base path: pass the HF id / local checkpoint to load weights from.
168-
cmd.append(f"--policy.pretrained_path={cfg.base}")
177+
if is_from_scratch:
178+
cmd.append(f"--policy.type={explicit_policy}")
179+
else:
180+
# LeRobot 0.5.1 loads both config and weights through policy.path.
181+
cmd.append(f"--policy.path={cfg.base}")
169182
if is_from_scratch and cfg.chunk_size:
170183
# ACT (and similar chunked policies) need chunk_size; pretrained bases
171184
# bake this in. auto_soarm convention (per its train.py): set

tests/test_finetune.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,10 @@ def test_basic_command_shape(self, tmp_path):
124124
# Schema is pinned to lerobot 0.5.1. If lerobot renames flags
125125
# upstream, this test catches it.
126126
joined = " ".join(cmd)
127-
assert "--policy.type=smolvla" in joined
128-
assert "--policy.pretrained_path=lerobot/smolvla_base" in joined
127+
assert cmd.count("--policy.path=lerobot/smolvla_base") == 1
128+
assert "--policy.type=" not in joined
129+
assert "--policy.pretrained_path=" not in joined
130+
assert "--policy.pretrained_model_path=" not in joined
129131
assert "--policy.repo_id=" in joined
130132
assert "--policy.push_to_hub=false" in joined
131133
assert "--dataset.repo_id=lerobot/libero" in joined
@@ -139,16 +141,58 @@ def test_basic_command_shape(self, tmp_path):
139141

140142
def test_policy_type_inference(self):
141143
from tether.finetune.run import _infer_policy_type
144+
142145
assert _infer_policy_type("lerobot/smolvla_base") == "smolvla"
143146
assert _infer_policy_type("lerobot/pi0_base") == "pi0"
144147
assert _infer_policy_type("lerobot/pi05_base") == "pi05"
145148
assert _infer_policy_type("nvidia/GR00T-N1.6-3B") == "gr00t_n1_5"
146149

147150
def test_policy_type_unknown_rejected(self):
148151
from tether.finetune.run import _infer_policy_type
152+
149153
with pytest.raises(ValueError, match="Could not infer"):
150154
_infer_policy_type("some-random/unknown-model")
151155

156+
def test_from_scratch_selects_type_without_pretrained_path(self, tmp_path):
157+
cfg = FinetuneConfig(
158+
base="",
159+
dataset="lerobot/pusht",
160+
output=tmp_path,
161+
mode="full",
162+
policy="act",
163+
chunk_size=31,
164+
)
165+
166+
cmd = _build_lerobot_command(cfg)
167+
joined = " ".join(cmd)
168+
169+
assert cmd.count("--policy.type=act") == 1
170+
assert "--policy.path=" not in joined
171+
assert "--policy.pretrained_path=" not in joined
172+
assert "--policy.pretrained_model_path=" not in joined
173+
174+
@pytest.mark.parametrize(
175+
"arg_name",
176+
[
177+
"policy.path",
178+
"policy.type",
179+
"policy.pretrained_path",
180+
"policy.pretrained_model_path",
181+
],
182+
)
183+
def test_policy_selection_cannot_be_duplicated_by_extra_args(
184+
self, tmp_path, arg_name
185+
):
186+
cfg = FinetuneConfig(
187+
base="lerobot/smolvla_base",
188+
dataset="lerobot/libero",
189+
output=tmp_path,
190+
extra_lerobot_args={arg_name: "other/value"},
191+
)
192+
193+
with pytest.raises(ValueError, match="cannot override policy selection"):
194+
_build_lerobot_command(cfg)
195+
152196
def test_extra_args_pass_through(self, tmp_path):
153197
cfg = FinetuneConfig(
154198
base="lerobot/smolvla_base",

0 commit comments

Comments
 (0)