Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ clientapp = "src.client_app:app"
num-server-rounds = 20 # 250 # LeRobot SmolVLA guideline is for combined epochs X rounds ~= 20,000 for a single dataset with ~ 50 training episodes
local-epochs = 2 # 20 # steps/epochs per round
model-name = "ivelin/zk0-smolvla-fl" # fl fine tuned: "ivelin/zk0-smolvla-fl" # Baseline: "lerobot/smolvla_base"
model_type = "smolvla" # Phase 0: "smolvla" | "world_model" (see src/models/ + ROADMAP)
fraction-fit = 1 # 0.3 # set to 1 for all clients to train each round; 0.5 for half of all
min-fit-clients = 2 # Minimum clients to train each round (ensure at least 2 for SO-100 with 4 clients)
fraction-evaluate = 1 # Evaluate all clients when using client eval. For server eval, this is ignored.
Expand Down
8 changes: 5 additions & 3 deletions src/client/client_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,19 @@ def __init__(
batch_size=64,
dataset_repo_id=None,
is_simulation=True,
model_type: str = "smolvla",
) -> None:
self.client_id = client_id
self.trainloader = trainloader
self.local_epochs = local_epochs
self.device = nn_device
self.dataset_name = dataset_repo_id # Cache dataset name to avoid repeated loading
self.is_simulation = is_simulation
self.model_type = model_type

# Log CUDA availability on instantiation
logger.info(
f"Client {self.client_id}: Instantiated - CUDA available: {torch.cuda.is_available()}, using device: {self.device}"
f"Client {self.client_id}: Instantiated - CUDA available: {torch.cuda.is_available()}, using device: {self.device}, model_type={self.model_type}"
)

# Validate required parameters
Expand All @@ -57,8 +59,8 @@ def __init__(
trainloader.dataset.meta if hasattr(trainloader.dataset, "meta") else None
)

# Load model using global function
self.net = get_model(dataset_meta)
# Load model using global function (Phase 0: supports "smolvla" | "world_model")
self.net = get_model(dataset_meta, model_type=self.model_type)

# Store data
self.trainloader = trainloader
Expand Down
2 changes: 2 additions & 0 deletions src/client_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ def client_fn(context: Context) -> Client:
# Read the run config to get settings to configure the Client
model_name = context.run_config["model-name"]
local_epochs = int(context.run_config["local-epochs"])
model_type = context.run_config.get("model_type", context.run_config.get("model-type", "smolvla"))
logger.info(
f"✅ Client {client_id}: Config loaded - model_name={model_name}, local_epochs={local_epochs}"
)
Expand Down Expand Up @@ -233,6 +234,7 @@ def client_fn(context: Context) -> Client:
batch_size=batch_size,
dataset_repo_id=dataset_slug,
is_simulation=is_simulation,
model_type=model_type,
)
logger.info(f"✅ Client {client_id}: SmolVLAClient created successfully")
logger.info(f"🚀 Client {client_id}: Converting to Flower client")
Expand Down
3 changes: 1 addition & 2 deletions src/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
compute_param_update_norm,
get_client_dir,
save_client_round_metrics,
validate_and_log_parameters,
get_base_output_dir,
)
from .parameter_utils import compute_parameter_hash
from .parameter_utils import compute_parameter_hash, validate_and_log_parameters
11 changes: 6 additions & 5 deletions src/common/parameter_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def compute_rounded_hash(ndarrays, precision="float32"):


def validate_and_log_parameters(
parameters: List[np.ndarray], gate_name: str, expected_count: int = 506
parameters: List[np.ndarray], gate_name: str, expected_count: Optional[int] = None
) -> str:
"""
Validate parameters and log basic information about them.
Expand All @@ -71,10 +71,11 @@ def validate_and_log_parameters(
Raises:
AssertionError: If validation fails
"""
# Basic validation
assert len(parameters) == expected_count, (
f"Parameter count mismatch at {gate_name}: expected {expected_count}, got {len(parameters)}"
)
# Basic validation (only if expected provided)
if expected_count is not None:
assert len(parameters) == expected_count, (
f"Parameter count mismatch at {gate_name}: expected {expected_count}, got {len(parameters)}"
)

# Compute hash
current_hash = compute_parameter_hash(parameters)
Expand Down
49 changes: 10 additions & 39 deletions src/common/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,15 @@

from loguru import logger

# Import LeRobot components
try:
from lerobot.datasets.lerobot_dataset import LeRobotDataset, FilteredLeRobotDataset
except ImportError:
from lerobot.datasets.lerobot_dataset import LeRobotDataset

FilteredLeRobotDataset = None
from lerobot.datasets.factory import make_dataset

from lerobot.policies.smolvla.modeling_smolvla import SmolVLAPolicy

# Import additional utilities
# Import additional utilities (lazy for heavy ones)
from src.common.parameter_utils import compute_parameter_hash
from src.server.metrics_utils import create_client_metrics_dict

# Import torchvision for image transforms
# LeRobot heavy imports are done lazily inside functions to allow unit tests
# to collect and run in envs without the full runtime deps (lerobot, torch specifics).
# Real execution (tiny sim, docker CI) provides the full deps.

from .parameter_utils import validate_and_log_parameters # re-export after dedup


def load_env_safe():
Expand Down Expand Up @@ -217,7 +210,7 @@ def load_lerobot_dataset(
delta_timestamps: Optional[Dict[str, List[float]]] = None,
episode_filter: Optional[Dict[str, int]] = None,
split: Optional[str] = None,
) -> LeRobotDataset:
) -> Any: # LeRobotDataset (lazy import inside to allow test collection without full deps)
"""Load LeRobot dataset using the same approach as lerobot train.py.

This simplified version matches the working standalone training script,
Expand All @@ -237,6 +230,9 @@ def load_lerobot_dataset(
RuntimeError: If loading fails
"""
try:
# Lazy imports inside function to support test collection in envs without full deps
from lerobot.datasets.lerobot_dataset import LeRobotDataset
from lerobot.datasets.factory import make_dataset
# Load config from SmolVLA model hub (same as standalone training)
from lerobot.configs.train import TrainPipelineConfig

Expand Down Expand Up @@ -533,31 +529,6 @@ def save_client_round_metrics(
logger.warning(f"Client {client_id}: Failed to save per-round metrics: {e}")


def validate_and_log_parameters(parameters: List[np.ndarray], gate_name: str, expected_count: Optional[int] = None) -> str:
"""Validate parameter count (if expected_count provided) and compute hash for logging.

Args:
parameters: List of numpy arrays containing model parameters
gate_name: Name of the validation gate (for logging)
expected_count: Optional expected number of parameter arrays

Returns:
SHA256 hash string of the parameters

Raises:
AssertionError: If parameter count doesn't match expected count (when provided)
"""
if expected_count is not None:
assert len(parameters) == expected_count, f"Parameter count mismatch: got {len(parameters)}, expected {expected_count}"

# Compute hash
current_hash = compute_parameter_hash(parameters)

logger.info(f"🛡️ {gate_name}: {len(parameters)} params, hash={current_hash[:16]}...")

return current_hash


def get_dataset_slug(context: Any) -> str:
"""Get dataset slug from context for unified sim/prod path generation."""
logger.debug(f"get_dataset_slug node_config keys: {list(context.node_config.keys())}")
Expand Down
21 changes: 21 additions & 0 deletions src/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""zk0 Models: pluggable adapters for LeRobot-compatible models (VLAs, world models/WAMs, etc.).

Phase 0 skeleton for multi-model FL Arena support.
All models use the same LeRobotDataset + ds_meta harness for DRY/MECE.
"""

from .base import BaseModelAdapter # noqa: F401
from .smolvla_adapter import SmolVLAAdapter # noqa: F401
from .world_model_adapter import WorldModelAdapter # noqa: F401

# Minimal Phase 0 registry (used by future get_adapter(model_type))
ADAPTER_REGISTRY = {
"smolvla": SmolVLAAdapter,
"world_model": WorldModelAdapter,
}

def get_adapter(model_type: str = "smolvla"):
"""Return adapter class for the given model type (Phase 0)."""
if model_type not in ADAPTER_REGISTRY:
raise ValueError(f"Unknown model_type '{model_type}'. Registered: {list(ADAPTER_REGISTRY)}")
return ADAPTER_REGISTRY[model_type]()
54 changes: 54 additions & 0 deletions src/models/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Base adapter interface for zk0 models.

Goal (per Robotics Model Arena vision):
- LeRobot-compatible harness: same datasets + evals for any model (SmolVLA, Pi0, Diffusion Policy, World Models / WAMs).
- Standardized I/O: load via ds_meta, compute primary loss, expose trainable params, reset state per round.
- DRY/MECE: one canonical path for data, one for eval/metrics per model type.
- Future: world model adapters will implement next-obs / latent prediction using sequential episodes.

This is the Phase 0 thin hook. SmolVLA remains the initial concrete implementation.
"""

from abc import ABC, abstractmethod
from typing import Any, Dict, Optional

import torch


class BaseModelAdapter(ABC):
"""Abstract base for a zk0 model (policy or world model).

Adapters wrap LeRobot policies (or custom dynamics models) and provide a
uniform interface for the FL client/server/training code.
"""

name: str = "base"

@abstractmethod
def load(self, dataset_meta: Any, **config: Any) -> Any:
"""Load or construct the underlying model using LeRobot dataset metadata.

Returns the model instance (e.g. policy or world model module) ready for .to(device).
Must be deterministic given the same ds_meta + config.
"""
...

def get_underlying(self) -> Any:
"""Return the raw model object (for param get/set, forward, etc.)."""
raise NotImplementedError

# Optional hooks for Phase 1+ (world models, joint training, custom schedulers)
def compute_primary_loss(self, batch: Dict[str, torch.Tensor]) -> torch.Tensor:
"""Return the main scalar loss for this model (policy flow-match or WM prediction error)."""
raise NotImplementedError("Subclasses or concrete adapters implement")

def reset_for_round(self, round_num: int, initial_lr: Optional[float] = None, **cfg: Any) -> None:
"""Called at start of each FL round (e.g. reset schedulers, stats)."""
pass

def get_trainable_params(self) -> list:
"""Return trainable parameters in a form suitable for FedProx / exchange (often delegated)."""
raise NotImplementedError

# Future: prediction for world models, etc.
# def predict_next(self, obs, action): ...
37 changes: 37 additions & 0 deletions src/models/smolvla_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""SmolVLA concrete adapter (initial implementation for Phase 0).

Delegates to the existing LeRobot factory logic in src/training/model_utils.
This keeps 100% backward compatibility while providing the extension point.
"""

from typing import Any

from loguru import logger

from .base import BaseModelAdapter


class SmolVLAAdapter(BaseModelAdapter):
"""Adapter for lerobot/smolvla_base and fine-tunes (flow-matching VLA policy)."""

name = "smolvla"

def __init__(self):
self._model = None

def load(self, dataset_meta: Any, **config: Any) -> Any:
"""Load using the original factory path (no behavior change)."""
# Import here to avoid circulars during early bootstrap
from src.training.model_utils import _load_smolvla_model

self._model = _load_smolvla_model(dataset_meta)
logger.info("SmolVLAAdapter: loaded SmolVLA policy via legacy factory")
return self._model

def get_underlying(self) -> Any:
if self._model is None:
raise RuntimeError("Call load() before get_underlying()")
return self._model


# Default registration will happen in registry or on first use
80 changes: 80 additions & 0 deletions src/models/world_model_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""World Model (WAM) concrete adapter (minimal stub for Phase 0).

Provides a load-compatible implementation using ds_meta from LeRobotDataset.
This proves the harness (same datasets) can drive both policy (VLA) and
world-model paths without changing existing SmolVLA behavior.

For Phase 0 the implementation is intentionally a thin stub (no dynamics
training logic yet — that is Phase 1). It satisfies the documented
BaseModelAdapter contract so get_model(..., model_type="world_model") works.
"""

from typing import Any

from loguru import logger

from .base import BaseModelAdapter


class WorldModelAdapter(BaseModelAdapter):
"""Minimal adapter for a world model (dynamics predictor).

In later phases this will load a proper forward model (e.g. latent
predictor or observation predictor) using the same LeRobotDataset meta
that VLA policies use.
"""

name = "world_model"

def __init__(self):
self._model = None

def load(self, dataset_meta: Any, **config: Any) -> Any:
"""Load a stub world-model module using the provided dataset meta.

Uses the action dim / observation features from ds_meta so that
the same client datasets that work for SmolVLA also work here.
"""
# Minimal real module so that downstream code (param get/set,
# .to(device), state_dict etc.) can treat it like a policy.
import torch
import torch.nn as nn

# Extract basic shape info from LeRobot ds_meta (same as VLA path)
action_dim = 1
try:
if hasattr(dataset_meta, "features") and "action" in dataset_meta.features:
action_shape = dataset_meta.features["action"].get("shape", [1])
action_dim = action_shape[0] if isinstance(action_shape, (list, tuple)) else 1
elif hasattr(dataset_meta, "action_dim"):
action_dim = int(dataset_meta.action_dim)
except Exception:
action_dim = 6 # reasonable default for SO-100 arms

# Very small dynamics stub: takes (obs_embed + action) -> next_embed
# In Phase 0 we don't care about real obs embedding; this just proves
# the load path + get_underlying contract.
class _StubWorldModel(nn.Module):
def __init__(self, action_dim: int):
super().__init__()
self.action_dim = action_dim
# tiny linear just to have trainable params and forward
self.dynamics = nn.Linear(action_dim + 8, 8) # fake latent dim 8

def forward(self, action, latent=None):
if latent is None:
latent = torch.zeros(action.shape[0], 8, device=action.device)
x = torch.cat([latent, action], dim=-1)
return self.dynamics(x)

self._model = _StubWorldModel(action_dim)
logger.info(
f"WorldModelAdapter: loaded minimal WM stub (action_dim={action_dim}) "
"using ds_meta — harness compatibility proven for Phase 0"
)
return self._model

def get_underlying(self) -> Any:
if self._model is None:
raise RuntimeError("Call load() before get_underlying()")
return self._model
3 changes: 1 addition & 2 deletions src/server/evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,12 @@ def evaluate_model_on_datasets(
Returns:
tuple: (composite_loss, total_examples, composite_metrics, per_dataset_results)
"""
from lerobot.policies.factory import make_policy

dataset_losses = []
per_dataset_results = []
total_examples = 0

for server_config in datasets_config:
from lerobot.policies.factory import make_policy
dataset_result = evaluate_single_dataset(
global_parameters=global_parameters,
dataset_name=server_config.name,
Expand Down
Loading
Loading