From 9f4b1fd3d6dc261990be3af9d9c1f90b69887d71 Mon Sep 17 00:00:00 2001 From: Sergey Arkhangelskiy Date: Sat, 7 Mar 2026 12:38:25 +0200 Subject: [PATCH 1/5] Fix `lerobot-train` LR decay and add missing `jinja2` dep - Align `scheduler_decay_steps` with total training steps by default, preventing LR from hitting floor early in long runs - Add `jinja2` to dependencies (required by starlette's Jinja2Templates) - Remove `full_report=True` from sim eval `StackingSuccess` observer --- positronic/inference.py | 2 +- positronic/vendors/lerobot/train.py | 3 +++ pyproject.toml | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/positronic/inference.py b/positronic/inference.py index c0c44d51a..2a885703e 100644 --- a/positronic/inference.py +++ b/positronic/inference.py @@ -228,7 +228,7 @@ def main_sim( camera_dict={'image.wrist': 'handcam_left_ph', 'image.exterior': 'back_view_ph', 'image.agent_view': 'agentview'}, observers={ 'box_distance': BodyDistance('box_0_body', 'box_1_body'), - 'stacking_success': StackingSuccess('box_0_body', 'box_1_body', 'hand_ph', full_report=True), + 'stacking_success': StackingSuccess('box_0_body', 'box_1_body', 'hand_ph'), }, ) diff --git a/positronic/vendors/lerobot/train.py b/positronic/vendors/lerobot/train.py index 0a3d234a2..98f6e3a77 100644 --- a/positronic/vendors/lerobot/train.py +++ b/positronic/vendors/lerobot/train.py @@ -144,6 +144,9 @@ def train( _update_config(cfg, **cfg_kwargs) + if 'policy.scheduler_decay_steps' not in cfg_kwargs: + cfg.policy.scheduler_decay_steps = cfg.steps - cfg.policy.scheduler_warmup_steps + if cfg.resume: checkpoints_dir = Path(cfg.output_dir) / 'checkpoints' if not checkpoints_dir.exists(): diff --git a/pyproject.toml b/pyproject.toml index fb43baba7..f7cdd7db8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "dearpygui", "dm_control", "fastapi", + "jinja2", # Required by starlette's Jinja2Templates (optional dep of fastapi) "fire", "httpx", "msgpack", From 60d479e42293dbd25f0a3c3318d282b65dc36c54 Mon Sep 17 00:00:00 2001 From: Sergey Arkhangelskiy Date: Tue, 10 Mar 2026 08:02:10 +0200 Subject: [PATCH 2/5] Add SmolVLA `full_finetune` config and fix `batch_size` default - Fix SmolVLAConfig import (was XVLAConfig) - Add explicit `batch_size=64` (SmolVLA recommended default) - Extract `_train()` to support multiple training configs - Add `full_finetune` config (unfreezes vision encoder + all params) - Multi-config CLI: `train` and `full_finetune` subcommands --- positronic/vendors/lerobot/train.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/positronic/vendors/lerobot/train.py b/positronic/vendors/lerobot/train.py index 98f6e3a77..d4756b259 100644 --- a/positronic/vendors/lerobot/train.py +++ b/positronic/vendors/lerobot/train.py @@ -21,7 +21,7 @@ from lerobot.configs.policies import PreTrainedConfig from lerobot.configs.train import TrainPipelineConfig from lerobot.envs.configs import EnvConfig, FeatureType, PolicyFeature -from lerobot.policies.xvla.configuration_xvla import XVLAConfig # noqa: F401 — registers policy choices +from lerobot.policies.smolvla.configuration_smolvla import SmolVLAConfig # noqa: F401 — registers policy from lerobot.utils.constants import ACTION, OBS_IMAGES, OBS_STATE from positronic import utils @@ -93,15 +93,14 @@ def _update_config(cfg: TrainPipelineConfig, **cfg_kwargs): raise AttributeError(f'Could not update config for {k}') from e -@cfn.config(codec=lerobot_codecs.ee, base_model='lerobot/smolvla_base', num_train_steps=None) -@pos3.with_mirror() -def train( +def _train( input_path: str, exp_name: str, output_dir: str, codec: Codec, base_model: str, num_train_steps: int | None, + batch_size: int, **cfg_kwargs, ): if isinstance(codec, str): @@ -134,6 +133,7 @@ def train( job_name=exp_name, eval_freq=0, log_freq=10, + batch_size=batch_size, steps=num_train_steps if num_train_steps is not None else 100_000, ) @@ -185,9 +185,23 @@ def train( logging.info('Training finished.') +@cfn.config(codec=lerobot_codecs.ee, base_model='lerobot/smolvla_base', num_train_steps=None, batch_size=64) +@pos3.with_mirror() +def train(input_path, exp_name, output_dir, codec, base_model, num_train_steps, batch_size, **cfg_kwargs): + _train(input_path, exp_name, output_dir, codec, base_model, num_train_steps, batch_size, **cfg_kwargs) + + +@cfn.config(codec=lerobot_codecs.ee, base_model='lerobot/smolvla_base', num_train_steps=None, batch_size=64) +@pos3.with_mirror() +def full_finetune(input_path, exp_name, output_dir, codec, base_model, num_train_steps, batch_size, **cfg_kwargs): + cfg_kwargs.setdefault('policy.freeze_vision_encoder', False) + cfg_kwargs.setdefault('policy.train_expert_only', False) + _train(input_path, exp_name, output_dir, codec, base_model, num_train_steps, batch_size, **cfg_kwargs) + + def _internal_main(): init_logging() - cfn.cli(train) + cfn.cli({'train': train, 'full_finetune': full_finetune}) if __name__ == '__main__': From 821025d6762184475bef4e959409c6ac6b3720d3 Mon Sep 17 00:00:00 2001 From: Sergey Arkhangelskiy Date: Tue, 10 Mar 2026 08:02:16 +0200 Subject: [PATCH 3/5] Add SPA episode navigation to avoid rerun WASM reload Use rerun viewer's `add_receiver`/`remove_receiver` API to swap RRD data without reloading the iframe. Adds Service Worker to cache WASM assets and `/api/episode/{id}` endpoint for client-side navigation. --- positronic/server/positronic_server.py | 43 +++-- positronic/server/static/sw.js | 31 ++++ positronic/server/templates/base.html | 1 + positronic/server/templates/episode.html | 202 +++++++++++++++++------ 4 files changed, 215 insertions(+), 62 deletions(-) create mode 100644 positronic/server/static/sw.js diff --git a/positronic/server/positronic_server.py b/positronic/server/positronic_server.py index b65e84e34..377429b89 100644 --- a/positronic/server/positronic_server.py +++ b/positronic/server/positronic_server.py @@ -100,6 +100,17 @@ async def cache_rerun_assets(request: Request, call_next): return response +def _make_serializable(obj): + """Ensure obj is JSON serializable (e.g. convert datetime).""" + if isinstance(obj, datetime): + return obj.isoformat() + if isinstance(obj, dict): + return {k: _make_serializable(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_make_serializable(v) for v in obj] + return obj + + def _iter_file_chunks(path: str, *, chunk_size: int = 128 * 1024): with open(path, 'rb') as source: while True: @@ -185,16 +196,6 @@ async def episode_viewer(request: Request, episode_id: int): size_mb = meta.get('size_mb') size_mb_display = f'{size_mb:.2f}' if isinstance(size_mb, int | float) else None - # Ensure static_data is JSON serializable (e.g. handle datetime) - def _make_serializable(obj): - if isinstance(obj, datetime): - return obj.isoformat() - if isinstance(obj, dict): - return {k: _make_serializable(v) for k, v in obj.items()} - if isinstance(obj, list): - return [_make_serializable(v) for v in obj] - return obj - return templates.TemplateResponse( 'episode.html', { @@ -401,6 +402,28 @@ async def api_dataset_status(): } +@app.get('/api/episode/{episode_id}') +@require_dataset +async def api_episode(episode_id: int): + ds = app_state.get('dataset') + try: + episode = ds[episode_id] + except IndexError as e: + raise HTTPException(status_code=404, detail='Episode not found') from e + + meta = episode.meta + size_mb = meta.get('size_mb') + + return { + 'episode_id': episode_id, + 'num_episodes': len(ds), + 'task': episode.static.get('task', None), + 'episode_path': meta.get('path'), + 'episode_size_mb': f'{size_mb:.2f}' if isinstance(size_mb, int | float) else None, + 'static_data': _make_serializable(episode.static), + } + + @app.get('/api/episode_rrd/{episode_id}') @require_dataset async def api_episode_rrd(episode_id: int): diff --git a/positronic/server/static/sw.js b/positronic/server/static/sw.js new file mode 100644 index 000000000..3f204a035 --- /dev/null +++ b/positronic/server/static/sw.js @@ -0,0 +1,31 @@ +// Service Worker to cache rerun WASM and JS assets. +// These files are large (~35MB WASM) and don't change between episodes. + +const CACHE_NAME = 'rerun-assets-v1'; +const RERUN_PATH_PREFIX = '/static/rerun/'; + +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((names) => + Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (!url.pathname.startsWith(RERUN_PATH_PREFIX)) return; + + event.respondWith( + caches.open(CACHE_NAME).then((cache) => + cache.match(event.request).then((cached) => { + if (cached) return cached; + return fetch(event.request).then((response) => { + if (response.ok) cache.put(event.request, response.clone()); + return response; + }); + }) + ) + ); +}); diff --git a/positronic/server/templates/base.html b/positronic/server/templates/base.html index 5995fbb9a..a2f3427b3 100644 --- a/positronic/server/templates/base.html +++ b/positronic/server/templates/base.html @@ -28,6 +28,7 @@ + {% block scripts %}{% endblock %} diff --git a/positronic/server/templates/episode.html b/positronic/server/templates/episode.html index a642f8e8a..60ea7a0ef 100644 --- a/positronic/server/templates/episode.html +++ b/positronic/server/templates/episode.html @@ -7,48 +7,159 @@ {% endblock %} {% block scripts %} + + - + {% endblock %} @@ -150,31 +254,25 @@ Ep. -
- / {{ num_episodes }}
- {% if task %} : {{ task }}{% endif %} + {% if task %} : {{ task }}{% endif %}
- {% if episode_path or episode_size_mb %} -
- {% if episode_path %} - Episode path: - {{ episode_path }} - {% endif %} - {% if episode_size_mb %} - ({{ episode_size_mb }} MB) - {% endif %} +
+ Episode path: + {{ episode_path or '' }} + {% if episode_size_mb %}({{ episode_size_mb }} MB){% endif %}
- {% endif %}
From 6b4079ab9412d307a4a0f899679364b28c8a3d4c Mon Sep 17 00:00:00 2001 From: Sergey Arkhangelskiy Date: Tue, 10 Mar 2026 08:09:51 +0200 Subject: [PATCH 4/5] Fix SPA viewer review issues and restore `full_report` - Reset `viewerReady` on iframe fallback reload - Use `replaceState` in popstate handler to avoid double history entries - Restore `full_report=True` in sim config (unintentional removal) --- positronic/inference.py | 2 +- positronic/server/templates/episode.html | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/positronic/inference.py b/positronic/inference.py index 2a885703e..c0c44d51a 100644 --- a/positronic/inference.py +++ b/positronic/inference.py @@ -228,7 +228,7 @@ def main_sim( camera_dict={'image.wrist': 'handcam_left_ph', 'image.exterior': 'back_view_ph', 'image.agent_view': 'agentview'}, observers={ 'box_distance': BodyDistance('box_0_body', 'box_1_body'), - 'stacking_success': StackingSuccess('box_0_body', 'box_1_body', 'hand_ph'), + 'stacking_success': StackingSuccess('box_0_body', 'box_1_body', 'hand_ph', full_report=True), }, ) diff --git a/positronic/server/templates/episode.html b/positronic/server/templates/episode.html index 60ea7a0ef..8d79a1895 100644 --- a/positronic/server/templates/episode.html +++ b/positronic/server/templates/episode.html @@ -17,7 +17,7 @@ // SPA navigation: swap RRD data in the running rerun viewer via its JS API. // This keeps the WASM alive — no 35MB re-download/recompile on each episode. - async function navigateToEpisode(episodeId) { + async function navigateToEpisode(episodeId, {replace = false} = {}) { const resp = await fetch(`/api/episode/${episodeId}`); if (!resp.ok) return; const data = await resp.json(); @@ -46,6 +46,7 @@ if (!swapped) { // Fallback: reload the iframe (first load or if handle unavailable) + viewerReady = false; const viewerUrl = new URL(`/static/rerun/${RERUN_VERSION}/index.html`, window.location.origin); viewerUrl.searchParams.set('url', newRrdUrl); viewerUrl.searchParams.set('hide_welcome_screen', ''); @@ -54,7 +55,10 @@ } // Update browser URL - history.pushState({episodeId: currentEpisodeId}, '', `/episode/${currentEpisodeId}`); + const historyState = {episodeId: currentEpisodeId}; + const url = `/episode/${currentEpisodeId}`; + if (replace) history.replaceState(historyState, '', url); + else history.pushState(historyState, '', url); document.title = `Episode ${currentEpisodeId} / ${maxEpisodes}`; // Update nav controls @@ -127,7 +131,7 @@ // Handle browser back/forward window.addEventListener('popstate', (e) => { if (e.state && e.state.episodeId !== undefined) { - navigateToEpisode(e.state.episodeId); + navigateToEpisode(e.state.episodeId, {replace: true}); } }); From c72ff865551d0c4160239998280541d048b9f30d Mon Sep 17 00:00:00 2001 From: Sergey Arkhangelskiy Date: Tue, 10 Mar 2026 08:24:37 +0200 Subject: [PATCH 5/5] Fix sidebar accumulation and input maxLength on filter dismiss --- positronic/server/static/app.js | 1 + positronic/server/templates/episode.html | 2 ++ 2 files changed, 3 insertions(+) diff --git a/positronic/server/static/app.js b/positronic/server/static/app.js index 05e28b0c8..3d62d9747 100644 --- a/positronic/server/static/app.js +++ b/positronic/server/static/app.js @@ -522,6 +522,7 @@ document.addEventListener('DOMContentLoaded', () => { function initializeSidebar(staticData) { const sidebarContent = document.querySelector('.sidebar-content-wrapper tbody'); + sidebarContent.innerHTML = ''; function isNestable(value) { return typeof value === 'object' && value !== null; diff --git a/positronic/server/templates/episode.html b/positronic/server/templates/episode.html index 8d79a1895..8f7f4cb2a 100644 --- a/positronic/server/templates/episode.html +++ b/positronic/server/templates/episode.html @@ -99,10 +99,12 @@ const idx = filteredIds.indexOf(currentEpisodeId); input.value = idx + 1; input.dataset.original = idx + 1; + input.maxLength = String(filteredIds.length).length; document.getElementById('episode-count').textContent = ` / ${filteredIds.length}`; } else { input.value = currentEpisodeId; input.dataset.original = currentEpisodeId; + input.maxLength = String(maxEpisodes - 1).length; document.getElementById('episode-count').textContent = ` / ${maxEpisodes}`; } }