fix(hub): pin pretrained artifacts to one commit

This commit is contained in:
Khalil Meftah
2026-07-28 14:16:09 +02:00
parent ffe25afb8f
commit 11dcdc6b23
17 changed files with 403 additions and 14 deletions
@@ -0,0 +1,77 @@
# 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.
import json
from dataclasses import dataclass
from lerobot.configs import PreTrainedConfig
@PreTrainedConfig.register_subclass("revision_pinning_test")
@dataclass
class RevisionPinningTestConfig(PreTrainedConfig):
@property
def observation_delta_indices(self) -> list | None:
return None
@property
def action_delta_indices(self) -> list | None:
return None
@property
def reward_delta_indices(self) -> list | None:
return None
def get_optimizer_preset(self):
raise NotImplementedError
def get_scheduler_preset(self):
raise NotImplementedError
def validate_features(self) -> None:
pass
def test_pretrained_config_pins_resolved_hub_commit(monkeypatch, tmp_path):
commit_hash = "a" * 40
snapshot_dir = tmp_path / "models--user--policy" / "snapshots" / commit_hash
RevisionPinningTestConfig(device="cpu").save_pretrained(snapshot_dir)
calls = []
def fake_hub_download(**kwargs):
calls.append(kwargs)
return str(snapshot_dir / "config.json")
monkeypatch.setattr("lerobot.configs.policies.hf_hub_download", fake_hub_download)
config = PreTrainedConfig.from_pretrained("user/policy", revision="main")
assert calls[0]["revision"] == "main"
assert config._commit_hash == commit_hash
assert config._commit_hash_source == "user/policy"
assert config.get_hub_revision("user/policy", "main") == commit_hash
assert config.get_hub_revision("user/base-policy", "base-tag") == "base-tag"
def test_runtime_commit_hash_is_not_serialized(tmp_path):
config = RevisionPinningTestConfig(device="cpu")
config._set_hub_commit_hash("a" * 40, "user/policy")
config.save_pretrained(tmp_path)
serialized_config = json.loads((tmp_path / "config.json").read_text())
assert "_commit_hash" not in serialized_config
assert "_commit_hash_source" not in serialized_config
assert "_runtime_commit_hash" not in serialized_config
assert "_runtime_commit_hash_source" not in serialized_config
@@ -0,0 +1,122 @@
# 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.
import torch
from lerobot.policies import factory
from lerobot.policies.act.configuration_act import ACTConfig
from lerobot.policies.pretrained import PreTrainedPolicy
from lerobot.processor import PolicyProcessorPipeline
class _MinimalPolicy(PreTrainedPolicy):
config_class = ACTConfig
name = "minimal_revision_test"
def get_optim_params(self) -> dict:
return {}
def reset(self) -> None:
pass
def forward(self, batch: dict[str, torch.Tensor]) -> tuple[torch.Tensor, dict | None]:
return torch.tensor(0), None
def predict_action_chunk(self, batch: dict[str, torch.Tensor], **kwargs) -> torch.Tensor:
return torch.tensor(0)
def select_action(self, batch: dict[str, torch.Tensor], **kwargs) -> torch.Tensor:
return torch.tensor(0)
def _skip_safetensor_loading(monkeypatch):
monkeypatch.setattr(
_MinimalPolicy,
"_load_as_safetensor",
classmethod(lambda cls, model, model_file, map_location, strict: model),
)
def test_policy_weights_use_config_commit_hash(monkeypatch):
config = ACTConfig(device="cpu")
config._set_hub_commit_hash("a" * 40, "user/policy")
calls = []
def fake_hub_download(**kwargs):
calls.append(kwargs)
return "/unused/model.safetensors"
monkeypatch.setattr("lerobot.policies.pretrained.hf_hub_download", fake_hub_download)
_skip_safetensor_loading(monkeypatch)
_MinimalPolicy.from_pretrained("user/policy", config=config, revision="main")
assert calls[0]["revision"] == "a" * 40
def test_policy_does_not_reuse_commit_hash_for_another_repo(monkeypatch):
config = ACTConfig(device="cpu")
config._set_hub_commit_hash("a" * 40, "user/adapter")
calls = []
def fake_hub_download(**kwargs):
calls.append(kwargs)
return "/unused/model.safetensors"
monkeypatch.setattr("lerobot.policies.pretrained.hf_hub_download", fake_hub_download)
_skip_safetensor_loading(monkeypatch)
_MinimalPolicy.from_pretrained("user/base-policy", config=config, revision="base-tag")
assert calls[0]["revision"] == "base-tag"
def test_policy_records_weight_commit_for_explicit_config(monkeypatch):
commit_hash = "a" * 40
config = ACTConfig(device="cpu")
def fake_hub_download(**kwargs):
return f"/cache/models--user--policy/snapshots/{commit_hash}/model.safetensors"
monkeypatch.setattr("lerobot.policies.pretrained.hf_hub_download", fake_hub_download)
_skip_safetensor_loading(monkeypatch)
_MinimalPolicy.from_pretrained("user/policy", config=config, revision="main")
assert config._commit_hash == commit_hash
assert config._commit_hash_source == "user/policy"
def test_processor_factory_uses_config_commit_hash(monkeypatch):
config = ACTConfig(device="cpu")
config._set_hub_commit_hash("a" * 40, "user/policy")
calls = []
def fake_from_pretrained(cls, **kwargs):
calls.append(kwargs)
return PolicyProcessorPipeline(steps=[])
monkeypatch.setattr(
factory.PolicyProcessorPipeline,
"from_pretrained",
classmethod(fake_from_pretrained),
)
factory.make_pre_post_processors(
config,
pretrained_path="user/policy",
pretrained_revision="main",
)
assert [call["revision"] for call in calls] == ["a" * 40, "a" * 40]
@@ -241,6 +241,68 @@ def test_from_pretrained_hub_source_missing_local_state_still_calls_hub(monkeypa
ProcessorStepRegistry.unregister("hub_state_step")
def test_from_pretrained_pins_hub_state_to_config_commit(monkeypatch, tmp_path):
"""A mutable processor revision is resolved once and reused for state files."""
@ProcessorStepRegistry.register("pinned_hub_state_step")
class PinnedHubStateStep(ProcessorStep):
def __init__(self):
self.value = torch.tensor(0)
def __call__(self, transition: EnvTransition) -> EnvTransition:
return transition
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
return features
def load_state_dict(self, state: dict[str, torch.Tensor]) -> None:
self.value = state["value"]
try:
commit_hash = "a" * 40
snapshot_dir = tmp_path / "models--user--policy" / "snapshots" / commit_hash
snapshot_dir.mkdir(parents=True)
config_path = snapshot_dir / "processor.json"
config_path.write_text(
json.dumps(
{
"name": "PinnedHubStatePipeline",
"steps": [
{
"registry_name": "pinned_hub_state_step",
"state_file": "hub_state.safetensors",
}
],
}
)
)
state_path = tmp_path / "downloaded.safetensors"
save_file({"value": torch.tensor(7)}, state_path)
calls = []
def fake_hub_download(**kwargs):
calls.append(kwargs)
if kwargs["filename"] == "processor.json":
return str(config_path)
return str(state_path)
monkeypatch.setattr("lerobot.processor.pipeline.hf_hub_download", fake_hub_download)
pipeline = DataProcessorPipeline.from_pretrained(
"user/policy",
config_filename="processor.json",
revision="main",
)
assert calls[0]["revision"] == "main"
assert calls[1]["revision"] == commit_hash
assert pipeline.steps[0].value.item() == 7
finally:
ProcessorStepRegistry.unregister("pinned_hub_state_step")
# Config Validation Tests
+18 -1
View File
@@ -14,7 +14,7 @@
from unittest.mock import MagicMock
from lerobot.utils.hub import find_latest_hub_checkpoint
from lerobot.utils.hub import extract_commit_hash, find_latest_hub_checkpoint
def _patch_list_files(monkeypatch, files):
@@ -52,3 +52,20 @@ def test_find_latest_hub_checkpoint_ignores_non_step_entries(monkeypatch):
def test_find_latest_hub_checkpoint_none_when_no_checkpoints(monkeypatch):
_patch_list_files(monkeypatch, ["config.json", "model.safetensors"])
assert find_latest_hub_checkpoint("u/run") is None
def test_extract_commit_hash_from_hub_snapshot_path():
commit_hash = "a" * 40
resolved_file = f"/cache/models--user--policy/snapshots/{commit_hash}/config.json"
assert extract_commit_hash(resolved_file, revision="main") == commit_hash
def test_extract_commit_hash_falls_back_to_full_sha_revision():
commit_hash = "b" * 40
assert extract_commit_hash("/custom/cache/config.json", revision=commit_hash) == commit_hash
def test_extract_commit_hash_rejects_mutable_revision_without_snapshot():
assert extract_commit_hash("/custom/cache/config.json", revision="main") is None